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/render.py ADDED
@@ -0,0 +1,2574 @@
1
+ """Render a :class:`PlotDocument` to deterministic SVG bytes.
2
+
3
+ Rendering goes through ``matplotlib.figure.Figure`` + ``backend_svg`` inside
4
+ ``rc_context`` — pyplot and the global backend are never touched. The rc
5
+ context is seeded from ``matplotlib.rcParamsDefault`` (minus the backend)
6
+ so user rc files/styles cannot leak a dark background or foreign defaults
7
+ into exported plots. The SVG carries the *original* (unscaled) document
8
+ JSON in its metadata node, so the file remains editable no matter which
9
+ ``style_scale`` produced the snapshot.
10
+
11
+ ``style_scale`` multiplies every physical size (fonts, line/marker widths,
12
+ spine and tick lengths, paddings) without changing figure geometry or data.
13
+ ILM uses it to keep typography at true final-figure points when a panel is
14
+ placed larger or smaller than the document's native size.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import functools
20
+ import io
21
+ import json
22
+ import math
23
+ import os
24
+ import re
25
+ import tempfile
26
+ import xml.etree.ElementTree as ET
27
+ from collections import OrderedDict
28
+ from dataclasses import dataclass
29
+
30
+ from .document import (
31
+ MAX_DIMENSION_MM, MAX_FILE_BYTES, PlotDocument, PlotDocumentError,
32
+ embed_metadata,
33
+ )
34
+ from matplotlib.ticker import Formatter
35
+
36
+ from .mathtext import safe_text
37
+
38
+ _HASHSALT = 'ilm-plot-editor-v1'
39
+
40
+ # Text ids: matplotlib lands set_gid() on a wrapping <g>; ILM text groups
41
+ # key individual <text> elements, so these get stable per-run element ids.
42
+ _TEXT_GROUPS = ('ilmplot-title', 'ilmplot-xlabel', 'ilmplot-ylabel')
43
+
44
+ _SVG_NS = 'http://www.w3.org/2000/svg'
45
+ _XLINK_NS = 'http://www.w3.org/1999/xlink'
46
+
47
+ # QtSvg parses small inline font sizes with integer rounding, so native
48
+ # text is emitted at a 128 px reference and compensated with a scale()
49
+ # transform — the same high-reference technique ILM uses for overrides.
50
+ _REF_FONT_PX = 128.0
51
+
52
+ _LEN_RE = re.compile(
53
+ r'([-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?)(%|[A-Za-z]+)?')
54
+ _FONT_SIZE_STYLE_RE = re.compile(
55
+ r'(?:^|;)\s*font-size\s*:\s*([-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?)'
56
+ r'(px|pt)?')
57
+ _FONT_FAMILY_STYLE_RE = re.compile(r'font-family\s*:[^;]+;?')
58
+
59
+
60
+ def _local(elem) -> str:
61
+ return elem.tag.rsplit('}', 1)[-1]
62
+
63
+
64
+ def _register_namespaces(svg: bytes) -> None:
65
+ try:
66
+ for _event, data in ET.iterparse(io.BytesIO(svg),
67
+ events=['start-ns']):
68
+ prefix, uri = data
69
+ try:
70
+ ET.register_namespace(prefix, uri)
71
+ except Exception:
72
+ pass
73
+ except Exception:
74
+ pass
75
+ ET.register_namespace('', _SVG_NS)
76
+ ET.register_namespace('xlink', _XLINK_NS)
77
+
78
+
79
+ def _flatten_positioned_runs(root):
80
+ """Turn <text> elements that are pure lists of positioned <tspan>s into
81
+ <g> containers of <text> runs (MathText emits these; flattening keeps
82
+ per-run font sizes instead of normalizing superscripts).
83
+
84
+ Returns the set of converted child elements — these are math runs and
85
+ must keep their own Matplotlib math font families."""
86
+ runs = set()
87
+ for elem in list(root.iter()):
88
+ if _local(elem) != 'text' or (elem.text or '').strip():
89
+ continue
90
+ children = list(elem)
91
+ if not children or any(k in elem.attrib for k in
92
+ ('rotate', 'textLength')):
93
+ continue
94
+ if any(_local(c) != 'tspan' or 'x' not in c.attrib
95
+ or 'y' not in c.attrib or 'transform' in c.attrib
96
+ or (c.tail or '').strip()
97
+ or any(_local(n) == 'textPath' for n in c.iter())
98
+ for c in children):
99
+ continue
100
+ namespace = elem.tag[:-len('text')]
101
+ parent_id = elem.get('id')
102
+ elem.tag = namespace + 'g'
103
+ for key in ('x', 'y', 'dx', 'dy'):
104
+ elem.attrib.pop(key, None)
105
+ for i, child in enumerate(children):
106
+ child.tag = namespace + 'text'
107
+ if parent_id and child.get('id') is None:
108
+ child.set('id', f'{parent_id}-run-{i}')
109
+ runs.add(child)
110
+ return runs
111
+
112
+
113
+ def _assign_text_ids(root) -> bool:
114
+ """Give <text> descendants of the labelled groups stable element ids."""
115
+ changed = False
116
+ for gid in _TEXT_GROUPS:
117
+ for elem in root.iter():
118
+ if elem.get('id') == gid:
119
+ idx = 0
120
+ for node in elem.iter():
121
+ if _local(node) == 'text':
122
+ node.set('id', f'{gid}-text-{idx}')
123
+ idx += 1
124
+ changed = True
125
+ return changed
126
+
127
+
128
+ def _explicit_font_px(node):
129
+ """Font size in px declared inline (style property or attribute)."""
130
+ style = node.get('style') or ''
131
+ m = _FONT_SIZE_STYLE_RE.search(style)
132
+ if m:
133
+ unit = m.group(2) or 'px'
134
+ if unit == 'px':
135
+ return float(m.group(1))
136
+ return float(m.group(1)) * 96.0 / 72.0
137
+ attr = node.get('font-size')
138
+ if attr:
139
+ m = _LEN_RE.fullmatch(attr.strip())
140
+ if m:
141
+ unit = m.group(2) or 'px'
142
+ if unit == 'px':
143
+ return float(m.group(1))
144
+ if unit == 'pt':
145
+ return float(m.group(1)) * 96.0 / 72.0
146
+ return None
147
+
148
+
149
+ def _divide_font_px(node, comp: float) -> None:
150
+ style = node.get('style')
151
+ if style and _FONT_SIZE_STYLE_RE.search(style):
152
+ def repl(m):
153
+ unit = m.group(2) or 'px'
154
+ return (m.group(0).replace(m.group(1),
155
+ f'{float(m.group(1)) / comp:.12g}', 1))
156
+ node.set('style', _FONT_SIZE_STYLE_RE.sub(repl, style))
157
+ elif node.get('font-size'):
158
+ m = _LEN_RE.fullmatch(node.get('font-size').strip())
159
+ if m:
160
+ node.set('font-size',
161
+ f'{float(m.group(1)) / comp:.12g}{m.group(2) or ""}')
162
+
163
+
164
+ def _rescale_text_subtree(elem, comp: float) -> None:
165
+ """Divide absolute lengths by *comp* (inverse of the scale transform)."""
166
+ def inv(m):
167
+ unit = m.group(2) or ''
168
+ if unit in ('em', 'ex'):
169
+ return m.group(0)
170
+ return f'{float(m.group(1)) / comp:.12g}{unit}'
171
+ for node in elem.iter():
172
+ if _local(node) not in ('text', 'tspan'):
173
+ continue
174
+ for key in ('x', 'y', 'dx', 'dy', 'textLength',
175
+ 'letter-spacing', 'word-spacing', 'stroke-width'):
176
+ if key in node.attrib:
177
+ node.set(key, _LEN_RE.sub(inv, node.get(key)))
178
+ style = node.get('style')
179
+ if style:
180
+ node.set('style', re.sub(
181
+ r'((?:letter-spacing|word-spacing|stroke-width)\s*:\s*)'
182
+ r'([^;]+)',
183
+ lambda m: m.group(1) + _LEN_RE.sub(inv, m.group(2)),
184
+ style))
185
+ if node is not elem:
186
+ # Nested runs keep their size relative to the parent's 128 px.
187
+ _divide_font_px(node, comp)
188
+
189
+
190
+ def _set_font_px_128(node) -> None:
191
+ """Replace any font-size (px, pt or presentation attribute) with a
192
+ literal ``font-size:128px`` appended after the other properties."""
193
+ style = node.get('style') or ''
194
+ if _FONT_SIZE_STYLE_RE.search(style):
195
+ style = _FONT_SIZE_STYLE_RE.sub('', style).strip('; ')
196
+ node.attrib.pop('font-size', None)
197
+ node.set('style',
198
+ f'{style};font-size:128px' if style else 'font-size:128px')
199
+
200
+
201
+ def _precise_text(root, font_family: str, math_runs) -> bool:
202
+ """128 px reference fonts + a single explicit family for <text> roots.
203
+
204
+ Qt misparses Matplotlib's CSS fallback list ('Arial', 'DejaVu Sans',
205
+ sans-serif), so ordinary text elements get one presentation
206
+ ``font-family`` attribute; flattened math runs (*math_runs*) keep
207
+ their own Matplotlib math families and styles.
208
+ """
209
+ changed = False
210
+ for node in root.iter():
211
+ if _local(node) != 'text':
212
+ continue
213
+ px = _explicit_font_px(node)
214
+ if px is not None and px > 0:
215
+ comp = px / _REF_FONT_PX
216
+ if abs(comp - 1.0) > 1e-12:
217
+ _rescale_text_subtree(node, comp)
218
+ node.set('transform',
219
+ (node.get('transform', '') +
220
+ f' scale({comp:.12g})').strip())
221
+ _set_font_px_128(node)
222
+ changed = True
223
+ if node not in math_runs:
224
+ style = node.get('style')
225
+ family = None
226
+ if style and _FONT_FAMILY_STYLE_RE.search(style):
227
+ m = re.search(r'font-family\s*:\s*([^;]+)', style)
228
+ if m:
229
+ # First family of the node's own CSS list wins, so
230
+ # per-element style families survive normalization.
231
+ family = m.group(1).split(',')[0] \
232
+ .strip().strip('"\'') or None
233
+ node.set('style', _FONT_FAMILY_STYLE_RE.sub('', style)
234
+ .strip('; '))
235
+ family = family or font_family
236
+ if family:
237
+ node.set('font-family', family)
238
+ changed = True
239
+ return changed
240
+
241
+
242
+ def _prepare_svg(svg: bytes, font_family: str) -> bytes:
243
+ """Post-process raw Matplotlib SVG for Qt: flatten positioned runs,
244
+ assign stable text ids, emit high-precision 128 px reference text and
245
+ a single font family. Returns *svg* unchanged on any parse problem."""
246
+ _register_namespaces(svg)
247
+ try:
248
+ root = ET.fromstring(svg)
249
+ except ET.ParseError:
250
+ return svg
251
+ math_runs = _flatten_positioned_runs(root)
252
+ changed = bool(math_runs)
253
+ changed |= _assign_text_ids(root)
254
+ changed |= _precise_text(root, font_family, math_runs)
255
+ if not changed:
256
+ return svg
257
+ try:
258
+ return ET.tostring(root, encoding='unicode').encode('utf-8')
259
+ except Exception:
260
+ return svg
261
+
262
+
263
+ @dataclass(frozen=True)
264
+ class PlotRender:
265
+ svg: bytes
266
+ # normalized (left, top, right, bottom) of the axes rectangle — the
267
+ # "plot interior" ILM alignment marks refer to.
268
+ plot_area: tuple
269
+
270
+
271
+ def _canonical(document: PlotDocument) -> str:
272
+ return json.dumps(document.to_dict(), sort_keys=True,
273
+ separators=(',', ':'))
274
+
275
+
276
+ _render_cache: OrderedDict = OrderedDict()
277
+ _RENDER_CACHE_MAX = 64
278
+
279
+
280
+ def _deterministic_rc(document: PlotDocument) -> dict:
281
+ """rc seed built from matplotlib defaults, not the caller's rcParams."""
282
+ import matplotlib
283
+ rc = {k: v for k, v in matplotlib.rcParamsDefault.items()
284
+ if not k.startswith('backend')}
285
+ rc.update({
286
+ 'svg.fonttype': 'none',
287
+ 'text.usetex': False,
288
+ 'svg.hashsalt': _HASHSALT,
289
+ 'font.family': 'sans-serif',
290
+ 'font.sans-serif': [document.font_family, 'DejaVu Sans'],
291
+ 'axes.unicode_minus': False,
292
+ 'figure.dpi': 100,
293
+ })
294
+ return rc
295
+
296
+
297
+ def render_document(document: PlotDocument, *,
298
+ style_scale: float = 1.0) -> PlotRender:
299
+ """Render *document* to SVG bytes with embedded metadata.
300
+
301
+ ``style_scale`` scales typography/line weights only; figure size, axes
302
+ rectangle and data are unchanged, so the SVG viewBox stays identical for
303
+ every scale.
304
+ """
305
+ document.validate()
306
+ if not (isinstance(style_scale, (int, float))
307
+ and 0 < float(style_scale) < float('inf')):
308
+ raise PlotDocumentError(
309
+ f"style_scale must be a positive finite number, got {style_scale!r}")
310
+ key = (_canonical(document), round(float(style_scale), 6))
311
+ hit = _render_cache.get(key)
312
+ if hit is not None:
313
+ _render_cache.move_to_end(key)
314
+ return hit
315
+
316
+ import matplotlib
317
+
318
+ s = float(style_scale)
319
+ with matplotlib.rc_context(_deterministic_rc(document)):
320
+ fig = None
321
+ try:
322
+ fig, ax = _build_figure(document, s, document.width_mm,
323
+ document.height_mm)
324
+ _fit_figure(document, fig, ax)
325
+ pos = ax.get_position()
326
+ buf = io.BytesIO()
327
+ fig.savefig(buf, format='svg',
328
+ metadata={'Date': None,
329
+ 'Creator': 'ILM Plot Editor'})
330
+ finally:
331
+ if fig is not None:
332
+ fig.clear()
333
+
334
+ svg = embed_metadata(
335
+ _prepare_svg(buf.getvalue(), document.font_family), document)
336
+ if len(svg) > MAX_FILE_BYTES:
337
+ raise PlotDocumentError(
338
+ f"rendered SVG exceeds {MAX_FILE_BYTES // (1024 * 1024)} MiB — "
339
+ f"reduce the number of data points")
340
+ render = PlotRender(
341
+ svg=svg, plot_area=(pos.x0, 1.0 - pos.y1, pos.x1, 1.0 - pos.y0))
342
+ _render_cache[key] = render
343
+ if len(_render_cache) > _RENDER_CACHE_MAX:
344
+ _render_cache.popitem(last=False)
345
+ return render
346
+
347
+
348
+ @functools.lru_cache(maxsize=1)
349
+ def available_font_families():
350
+ """Sorted unique family names matplotlib can resolve."""
351
+ from matplotlib import font_manager
352
+ return tuple(sorted({f.name for f in font_manager.fontManager.ttflist}))
353
+
354
+
355
+ def resolve_font_family(text, families):
356
+ """Typed font input → ``(family | None, ok)``.
357
+
358
+ Empty means the default (``(None, True)``); a case-insensitive match
359
+ resolves to the canonical family name; anything else is ``(None,
360
+ False)`` so the caller can flag the field instead of applying.
361
+ """
362
+ text = (text or '').strip()
363
+ if not text:
364
+ return None, True
365
+ lowered = text.lower()
366
+ for fam in families:
367
+ if fam.lower() == lowered:
368
+ return fam, True
369
+ return None, False
370
+
371
+
372
+ class _AffixFormatter(Formatter):
373
+ """Wraps an axis formatter: prefix/suffix, optional fixed decimals.
374
+
375
+ A real ``Formatter`` subclass — otherwise ``set_major_formatter``
376
+ would wrap it in a ``FuncFormatter`` and the wrapped formatter would
377
+ never see ``set_locs`` (categorical ``FixedFormatter`` would get
378
+ ``pos=None`` too, yielding empty labels). ``set_axis``/``set_locs``/
379
+ ``format_ticks``/``get_offset`` delegate so offset text and tick
380
+ behaviour are unchanged.
381
+ """
382
+ def __init__(self, wrapped, prefix='', suffix='', decimals=None,
383
+ numeric=True):
384
+ super().__init__()
385
+ self._wrapped = wrapped
386
+ self._prefix = prefix
387
+ self._suffix = suffix
388
+ self._decimals = decimals if numeric else None
389
+
390
+ def _text(self, x, pos):
391
+ if self._decimals is not None:
392
+ try:
393
+ return f'{float(x):.{self._decimals}f}'
394
+ except (TypeError, ValueError):
395
+ pass
396
+ return self._wrapped(x, pos)
397
+
398
+ def __call__(self, x, pos=None):
399
+ return f'{self._prefix}{self._text(x, pos)}{self._suffix}'
400
+
401
+ def set_axis(self, axis):
402
+ super().set_axis(axis)
403
+ if hasattr(self._wrapped, 'set_axis'):
404
+ self._wrapped.set_axis(axis)
405
+
406
+ def set_locs(self, locs):
407
+ self._wrapped.set_locs(locs)
408
+
409
+ def format_ticks(self, values):
410
+ self.set_locs(values)
411
+ if self._decimals is not None:
412
+ texts = [self._text(v, None) for v in values]
413
+ else:
414
+ texts = [self._wrapped(v, pos=i)
415
+ for i, v in enumerate(values)]
416
+ return [f'{self._prefix}{t}{self._suffix}' for t in texts]
417
+
418
+ def get_offset(self):
419
+ return self._wrapped.get_offset()
420
+
421
+
422
+ def clip_series_to_view(x, y, xlim, ylim, xlog=False, ylog=False):
423
+ """Clip a polyline to a view box (Liang–Barsky, per segment).
424
+
425
+ QtSvg ignores ``clipPath``, so data outside the axes box must be
426
+ clipped in the data itself. Works in transformed space (log10 for
427
+ log axes; non-positive inputs count as outside and are dropped).
428
+ Returns ``(xs, ys, mask)`` — ``NaN`` breaks where the line leaves the
429
+ box, interpolated boundary points where it crosses, and ``mask[i]``
430
+ True only where ``(xs[i], ys[i])`` is an original in-view point (so
431
+ callers can feed it to ``set_markevery`` to keep boundary points
432
+ unmarked).
433
+ """
434
+ def bound(v, log, extreme):
435
+ try:
436
+ v = float(v)
437
+ except (TypeError, ValueError):
438
+ return extreme
439
+ if log:
440
+ return math.log10(v) if v > 0 else extreme
441
+ return v
442
+
443
+ # Transform the limits into the same space as the points.
444
+ x0 = bound(min(xlim), xlog, -math.inf)
445
+ x1 = bound(max(xlim), xlog, math.inf)
446
+ y0 = bound(min(ylim), ylog, -math.inf)
447
+ y1 = bound(max(ylim), ylog, math.inf)
448
+
449
+ pts = []
450
+ for xv, yv in zip(x, y):
451
+ try:
452
+ xv, yv = float(xv), float(yv)
453
+ except (TypeError, ValueError):
454
+ pts.append(None)
455
+ continue
456
+ if xlog and xv <= 0 or ylog and yv <= 0:
457
+ pts.append(None) # untransformable → outside
458
+ continue
459
+ if xlog:
460
+ xv = math.log10(xv)
461
+ if ylog:
462
+ yv = math.log10(yv)
463
+ if math.isnan(xv) or math.isnan(yv):
464
+ pts.append(None)
465
+ continue
466
+ pts.append((xv, yv))
467
+
468
+ out_x, out_y, mask = [], [], []
469
+ connected = False
470
+
471
+ def emit(px, py, is_original):
472
+ out_x.append(10 ** px if xlog else px)
473
+ out_y.append(10 ** py if ylog else py)
474
+ mask.append(is_original)
475
+
476
+ def emit_break():
477
+ nonlocal connected
478
+ if connected:
479
+ out_x.append(float('nan'))
480
+ out_y.append(float('nan'))
481
+ mask.append(False)
482
+ connected = False
483
+
484
+ prev = None
485
+ for q in pts:
486
+ if q is None:
487
+ emit_break()
488
+ prev = None
489
+ continue
490
+ if prev is None:
491
+ prev = q
492
+ continue
493
+ p, prev = prev, q
494
+ # Liang–Barsky on segment p→q in transformed space.
495
+ t0, t1, ok = 0.0, 1.0, True
496
+ for pv, qv, lo, hi in ((p[0], q[0], x0, x1),
497
+ (p[1], q[1], y0, y1)):
498
+ d = qv - pv
499
+ for p_, q_ in ((-d, pv - lo), (d, hi - pv)):
500
+ if p_ == 0:
501
+ if q_ < 0:
502
+ ok = False
503
+ break
504
+ continue
505
+ t = q_ / p_
506
+ if p_ < 0:
507
+ if t > t1:
508
+ ok = False
509
+ break
510
+ t0 = max(t0, t)
511
+ else:
512
+ if t < t0:
513
+ ok = False
514
+ break
515
+ t1 = min(t1, t)
516
+ if not ok:
517
+ break
518
+ if ok and t0 <= t1:
519
+ in_p = x0 <= p[0] <= x1 and y0 <= p[1] <= y1
520
+ in_q = x0 <= q[0] <= x1 and y0 <= q[1] <= y1
521
+ if not connected:
522
+ emit_break()
523
+ if t0 > 0 or not (out_x and connected):
524
+ ax_ = p[0] + t0 * (q[0] - p[0])
525
+ ay_ = p[1] + t0 * (q[1] - p[1])
526
+ emit(ax_, ay_, t0 == 0 and in_p)
527
+ bx_ = p[0] + t1 * (q[0] - p[0])
528
+ by_ = p[1] + t1 * (q[1] - p[1])
529
+ emit(bx_, by_, t1 == 1 and in_q)
530
+ connected = True
531
+ if t1 < 1:
532
+ emit_break()
533
+ else:
534
+ emit_break()
535
+ return out_x, out_y, mask
536
+
537
+
538
+ def clip_polygon_to_view(xs, ys, xlim, ylim, xlog=False, ylog=False):
539
+ """Clip a closed polygon to a view box (Sutherland–Hodgman).
540
+
541
+ QtSvg ignores ``clipPath``, so filled areas (violin bodies, stacked
542
+ bars, ridgeline fills) are clipped in data space when the document
543
+ pins a limit. Works in transformed space (log10 on log axes;
544
+ non-positive vertices are dropped). Returns ``(xs, ys)`` — empty
545
+ when the polygon lies fully outside.
546
+ """
547
+ def bound(v, log, extreme):
548
+ try:
549
+ v = float(v)
550
+ except (TypeError, ValueError):
551
+ return extreme
552
+ if log:
553
+ return math.log10(v) if v > 0 else extreme
554
+ return v
555
+
556
+ x0 = bound(min(xlim), xlog, -math.inf)
557
+ x1 = bound(max(xlim), xlog, math.inf)
558
+ y0 = bound(min(ylim), ylog, -math.inf)
559
+ y1 = bound(max(ylim), ylog, math.inf)
560
+
561
+ pts = []
562
+ for xv, yv in zip(xs, ys):
563
+ try:
564
+ xv, yv = float(xv), float(yv)
565
+ except (TypeError, ValueError):
566
+ continue
567
+ if xlog and xv <= 0 or ylog and yv <= 0:
568
+ continue # untransformable → outside
569
+ if xlog:
570
+ xv = math.log10(xv)
571
+ if ylog:
572
+ yv = math.log10(yv)
573
+ if math.isnan(xv) or math.isnan(yv):
574
+ continue
575
+ pts.append((xv, yv))
576
+ if len(pts) < 3:
577
+ return [], []
578
+
579
+ def clip_bound(points, axis, bound_v, is_lo):
580
+ """Sequential Sutherland–Hodgman against one half-plane."""
581
+ if bound_v in (-math.inf, math.inf):
582
+ return points
583
+ if (lo if is_lo else hi) is None:
584
+ return points
585
+ bound_v = lo if is_lo else hi
586
+ if bound_v in (-math.inf, math.inf):
587
+ return points
588
+ out = []
589
+
590
+ def inside(p):
591
+ return p[axis] >= bound_v if is_lo else p[axis] <= bound_v
592
+
593
+ for i, p in enumerate(points):
594
+ q = points[i - 1]
595
+ ip, iq = inside(p), inside(q)
596
+ if ip:
597
+ if not iq:
598
+ dq = p[axis] - q[axis]
599
+ t = (bound_v - q[axis]) / dq if dq else 0.0
600
+ out.append((q[0] + t * (p[0] - q[0]),
601
+ q[1] + t * (p[1] - q[1])))
602
+ out.append(p)
603
+ elif iq:
604
+ dq = p[axis] - q[axis]
605
+ t = (bound_v - q[axis]) / dq if dq else 0.0
606
+ out.append((q[0] + t * (p[0] - q[0]),
607
+ q[1] + t * (p[1] - q[1])))
608
+ return out
609
+
610
+ for axis, lo, hi in ((0, x0, x1), (1, y0, y1)):
611
+ pts = clip_bound(pts, axis, lo, True)
612
+ pts = clip_bound(pts, axis, hi, False)
613
+ if not pts:
614
+ return [], []
615
+
616
+ out_x = [10 ** p[0] if xlog else p[0] for p in pts]
617
+ out_y = [10 ** p[1] if ylog else p[1] for p in pts]
618
+ return out_x, out_y
619
+
620
+
621
+ def _add_underline(ax, get_texts):
622
+ """Add an ``Artist`` that underlines text artists (matplotlib has none).
623
+
624
+ ``get_texts`` is a callable returning the current texts to underline —
625
+ for tick labels it resolves labels at draw time so the underline follows
626
+ ``_fit_axes`` repositioning and only covers visible ticks. A line just
627
+ below each visible text's window-extent bottom edge is drawn in the
628
+ text's own colour at ≈ 0.06 × its fontsize.
629
+ """
630
+ from matplotlib.artist import Artist
631
+ from matplotlib.path import Path
632
+ from matplotlib.transforms import IdentityTransform
633
+
634
+ class _Underline(Artist):
635
+ def draw(self, renderer):
636
+ gc = renderer.new_gc()
637
+ try:
638
+ for text in get_texts():
639
+ try:
640
+ if not text.get_visible() or not text.get_text():
641
+ continue
642
+ bb = text.get_window_extent(renderer=renderer)
643
+ except Exception:
644
+ continue
645
+ if bb.width <= 0:
646
+ continue
647
+ pad = max(0.04 * text.get_fontsize(), 0.5)
648
+ path = Path([(bb.x0, bb.y0 - pad),
649
+ (bb.x1, bb.y0 - pad)])
650
+ gc.set_foreground(text.get_color())
651
+ gc.set_linewidth(
652
+ max(0.06 * text.get_fontsize(), 0.3))
653
+ renderer.draw_path(gc, path, IdentityTransform())
654
+ finally:
655
+ gc.restore()
656
+ return super().draw(renderer)
657
+
658
+ u = _Underline()
659
+ u.axes = ax
660
+ u.set_figure(ax.figure)
661
+ u.set_zorder(10)
662
+ ax.add_artist(u)
663
+
664
+
665
+ def _text_kwargs(ts, family, size_pt, s):
666
+ """Font kwargs for a ``TextStyle`` override (may be None)."""
667
+ kw = {}
668
+ if ts is not None:
669
+ kw['fontfamily'] = ts.family or family
670
+ kw['fontweight'] = 'bold' if ts.bold else 'normal'
671
+ kw['fontstyle'] = 'italic' if ts.italic else 'normal'
672
+ kw['color'] = ts.color
673
+ if ts.size_pt is not None:
674
+ kw['fontsize'] = ts.size_pt * s
675
+ return kw
676
+ kw['fontsize'] = size_pt * s
677
+ return kw
678
+
679
+
680
+ def _view_limits(document, ax, default_xlim, default_ylim):
681
+ """Final (xlim, ylim): document pins win, else the computed default."""
682
+ xlim = document.xlim if document.xlim is not None else default_xlim
683
+ ylim = document.ylim if document.ylim is not None else default_ylim
684
+ return xlim, ylim
685
+
686
+
687
+ def _clip_needed(document):
688
+ return document.xlim is not None or document.ylim is not None
689
+
690
+
691
+ def _n_text(opt, group):
692
+ return safe_text(opt.n_format.format(n=len(group.values)))
693
+
694
+
695
+ def _draw_violin(document, ax, s):
696
+ """Violin bodies + inner box + jittered points (NCPlot port).
697
+
698
+ ``opt.body`` swaps the density body for a plain box ('none') or a
699
+ mean column ('bar'); the 'violin' path is byte-identical to the
700
+ original.
701
+ """
702
+ from matplotlib.patches import Polygon
703
+ from matplotlib.colors import to_rgb, to_rgba, rgb_to_hsv
704
+ from . import stats
705
+ opt = document.violin
706
+ if opt is None:
707
+ from .document import ViolinOptions
708
+ opt = ViolinOptions()
709
+ groups = document.groups
710
+ n = len(groups)
711
+ clip = _clip_needed(document)
712
+ y_axis = getattr(document.style, 'yaxis', None) \
713
+ if document.style is not None else None
714
+ xlog = False # violin x is always categorical positions
715
+ ylog = y_axis is not None and y_axis.scale == 'log'
716
+ body = opt.body
717
+ box_full = opt.box_width if opt.box_width is not None \
718
+ else (0.5 if body == 'none' else 0.16)
719
+ box_w = box_full / 2.0
720
+ extra = 0.45 if opt.show_points and opt.points_beside else 0.0
721
+ default_xlim = (1 - 0.7, n + 0.7 + extra)
722
+ all_vals = [v for g in groups for v in g.values]
723
+ pad = (max(all_vals) - min(all_vals)) * 0.05 or 1.0
724
+ bar_stats = None
725
+ if body == 'bar':
726
+ import numpy as np
727
+ bar_stats = []
728
+ for g in groups:
729
+ n_v = len(g.values)
730
+ mean = float(np.mean(g.values))
731
+ sd = float(np.std(g.values, ddof=1)) if n_v >= 2 else 0.0
732
+ if opt.bar_error == 'sem':
733
+ err = sd / math.sqrt(n_v) if n_v else 0.0
734
+ elif opt.bar_error == 'sd':
735
+ err = sd
736
+ else:
737
+ err = None
738
+ bar_stats.append((mean, err))
739
+ top = max([max(all_vals)]
740
+ + [m + e for m, e in bar_stats if e is not None])
741
+ default_ylim = (0.0 if min(all_vals) >= 0
742
+ else min(all_vals) - pad, top + pad)
743
+ else:
744
+ default_ylim = (min(all_vals) - pad, max(all_vals) + pad)
745
+ xlim, ylim = _view_limits(document, ax, default_xlim, default_ylim)
746
+ ax.set_xlim(xlim)
747
+ ax.set_ylim(ylim)
748
+ gid = 'ilmplot-violin-'
749
+ tops = []
750
+ for i, (g, pos) in enumerate(zip(groups, range(1, n + 1))):
751
+ g_top = max(g.values) if g.values else 0.0
752
+ edge = g.color
753
+ fill_alpha = opt.fill_alpha
754
+ edge_w = opt.edge_width_pt
755
+ dot_edge = 'face'
756
+ dot_alpha = opt.point_alpha
757
+ dot_lw = opt.point_edge_width_pt
758
+ if opt.enhance_contrast:
759
+ hsv = rgb_to_hsv(to_rgb(g.color))
760
+ if hsv[2] > 0.8 and hsv[1] < 0.5:
761
+ edge = '#666666'
762
+ fill_alpha = 0.7
763
+ edge_w = 1.2
764
+ dot_edge = '#666666'
765
+ dot_alpha = 0.9
766
+ if opt.edge_color is not None:
767
+ edge = opt.edge_color
768
+ if opt.point_edge_color is not None:
769
+ dot_edge = opt.point_edge_color
770
+ if body == 'violin':
771
+ shape = stats.violin_shape(g.values, opt.bandwidth)
772
+ if shape is not None:
773
+ pts, half = shape
774
+ g_top = float(pts[-1])
775
+ xs = [pos + h for h in half] + \
776
+ [pos - h for h in half[::-1]]
777
+ ys = list(pts) + list(pts[::-1])
778
+ if clip:
779
+ xs, ys = clip_polygon_to_view(xs, ys, xlim, ylim,
780
+ xlog, ylog)
781
+ if xs:
782
+ # QtSvg draws a 0-width stroke as a 1-px hairline —
783
+ # remove the edge instead of passing width 0.
784
+ patch = Polygon(list(zip(xs, ys)), closed=True,
785
+ facecolor=g.color,
786
+ edgecolor='none' if edge_w == 0
787
+ else edge,
788
+ alpha=fill_alpha,
789
+ linewidth=edge_w * s)
790
+ patch.set_gid(gid + g.id)
791
+ ax.add_patch(patch)
792
+ elif body == 'bar':
793
+ mean, err = bar_stats[i]
794
+ if err is not None and err > 0:
795
+ g_top = max(g_top, mean + err)
796
+ hw = opt.bar_width / 2.0
797
+ base = min(ylim) if ylog else 0.0
798
+ rx = [pos - hw, pos + hw, pos + hw, pos - hw]
799
+ ry = [base, base, mean, mean]
800
+ if clip:
801
+ rx, ry = clip_polygon_to_view(rx, ry, xlim, ylim,
802
+ xlog, ylog)
803
+ if rx:
804
+ patch = Polygon(list(zip(rx, ry)), closed=True,
805
+ facecolor=to_rgba(g.color, fill_alpha),
806
+ edgecolor='none' if edge_w == 0
807
+ else edge,
808
+ linewidth=edge_w * s, zorder=2)
809
+ patch.set_gid(gid + g.id)
810
+ ax.add_patch(patch)
811
+ if err is not None and err > 0:
812
+ lo_e, hi_e = mean - err, mean + err
813
+ if ylog and lo_e <= 0:
814
+ lo_e = mean / 10.0
815
+ in_view = True
816
+ if clip:
817
+ if lo_e > max(ylim) or hi_e < min(ylim):
818
+ in_view = False
819
+ else:
820
+ lo_e = max(lo_e, min(ylim))
821
+ hi_e = min(hi_e, max(ylim))
822
+ if in_view and hi_e >= lo_e:
823
+ from matplotlib.collections import LineCollection
824
+ coll = LineCollection(
825
+ [((pos, lo_e), (pos, hi_e))],
826
+ colors='#333333', linewidths=1.2 * s, zorder=3)
827
+ coll.set_gid(gid + g.id)
828
+ ax.add_collection(coll)
829
+ box = None
830
+ if opt.show_box or (body == 'none' and opt.show_outliers
831
+ and not opt.show_points):
832
+ box = stats.box_stats(g.values)
833
+ if opt.show_box:
834
+ q1, med, q3, lo, hi = box
835
+ gid_prefix = gid + g.id
836
+ wl, = ax.plot([pos, pos], [lo, hi], color='#333333',
837
+ linewidth=1.2 * s, zorder=3)
838
+ wl.set_gid(gid_prefix)
839
+ rx, ry = ([pos - box_w, pos + box_w, pos + box_w,
840
+ pos - box_w], [q1, q1, q3, q3])
841
+ if clip:
842
+ rx, ry = clip_polygon_to_view(rx, ry, xlim, ylim,
843
+ xlog, ylog)
844
+ if rx:
845
+ rect = Polygon(
846
+ list(zip(rx, ry)), closed=True,
847
+ facecolor=(to_rgba(g.color, opt.fill_alpha)
848
+ if body == 'none' else 'white'),
849
+ edgecolor='#333333',
850
+ linewidth=1.2 * s, zorder=4)
851
+ rect.set_gid(gid_prefix)
852
+ ax.add_patch(rect)
853
+ ml, = ax.plot([pos - box_w, pos + box_w], [med, med],
854
+ color='#333333', linewidth=1.8 * s, zorder=5,
855
+ solid_capstyle='butt')
856
+ ml.set_gid(gid_prefix)
857
+ caps = []
858
+ if body == 'none':
859
+ for cv in (lo, hi):
860
+ cl, = ax.plot([pos - box_w / 2, pos + box_w / 2],
861
+ [cv, cv], color='#333333',
862
+ linewidth=1.2 * s, zorder=3,
863
+ solid_capstyle='butt')
864
+ cl.set_gid(gid_prefix)
865
+ caps.append((cl, cv))
866
+ if clip:
867
+ cx, cy, _m = clip_series_to_view(
868
+ [pos, pos], [lo, hi], xlim, ylim, xlog, ylog)
869
+ wl.set_data(cx, cy)
870
+ mx, my, _m = clip_series_to_view(
871
+ [pos - box_w, pos + box_w], [med, med],
872
+ xlim, ylim, xlog, ylog)
873
+ ml.set_data(mx, my)
874
+ for cl, cv in caps:
875
+ cx, cy, _m = clip_series_to_view(
876
+ [pos - box_w / 2, pos + box_w / 2], [cv, cv],
877
+ xlim, ylim, xlog, ylog)
878
+ cl.set_data(cx, cy)
879
+ if body == 'none' and opt.show_outliers and not opt.show_points:
880
+ _q1, _med, _q3, lo, hi = box
881
+ xs = [pos] * len(g.values)
882
+ ys = [v for v in g.values if v < lo or v > hi]
883
+ xs = xs[:len(ys)]
884
+ if clip:
885
+ pts_xy = [(x, y) for x, y in zip(xs, ys)
886
+ if min(xlim) <= x <= max(xlim)
887
+ and min(ylim) <= y <= max(ylim)]
888
+ xs = [p[0] for p in pts_xy]
889
+ ys = [p[1] for p in pts_xy]
890
+ if xs:
891
+ coll = ax.scatter(
892
+ xs, ys, s=(opt.point_size_pt * s) ** 2,
893
+ facecolors='none', edgecolors='#333333',
894
+ linewidths=1.0 * s, zorder=6)
895
+ coll.set_gid(gid + g.id)
896
+ if opt.show_points:
897
+ jit = stats.jitter(len(g.values), opt.points_beside,
898
+ 42 + i)
899
+ xs = [pos + j for j in jit]
900
+ ys = list(g.values)
901
+ if clip:
902
+ pts_xy = [(x, y) for x, y in zip(xs, ys)
903
+ if min(xlim) <= x <= max(xlim)
904
+ and min(ylim) <= y <= max(ylim)]
905
+ xs = [p[0] for p in pts_xy]
906
+ ys = [p[1] for p in pts_xy]
907
+ coll = ax.scatter(
908
+ xs, ys, s=(opt.point_size_pt * s) ** 2,
909
+ color=g.color, alpha=dot_alpha,
910
+ edgecolors='none' if dot_lw == 0 else dot_edge,
911
+ linewidths=0.0 if dot_lw == 0 else dot_lw * s,
912
+ zorder=6)
913
+ coll.set_gid(gid + g.id)
914
+ tops.append(g_top)
915
+ if opt.show_n and opt.n_position in ('top', 'bottom'):
916
+ y_rng = max(ylim) - min(ylim)
917
+ for g, pos, g_top in zip(groups, range(1, n + 1), tops):
918
+ if opt.n_position == 'top':
919
+ y = g_top + 0.02 * y_rng
920
+ else:
921
+ y = min(ylim) + 0.02 * y_rng
922
+ if clip and not (min(xlim) <= pos <= max(xlim)
923
+ and min(ylim) <= y <= max(ylim)):
924
+ continue
925
+ t = ax.text(pos, y, _n_text(opt, g), ha='center',
926
+ va='bottom',
927
+ fontsize=(opt.n_size_pt if opt.n_size_pt
928
+ is not None else
929
+ document.font_size_pt) * s,
930
+ color=opt.n_color or '#333333', zorder=7)
931
+ t.set_gid(gid + g.id)
932
+
933
+
934
+ def _draw_ridgeline(document, ax, s, lines):
935
+ """Ridgeline (mountain-stacked) series — NCPlot's generate_plot."""
936
+ import types
937
+ from matplotlib.patches import Polygon
938
+ from .document import RidgeOptions
939
+ opt = document.ridgeline or RidgeOptions()
940
+ series = document.series
941
+ n = len(series)
942
+ max_range = 0.0
943
+ all_x = []
944
+ for s_ in series:
945
+ if s_.y:
946
+ max_range = max(max_range, max(s_.y) - min(s_.y))
947
+ all_x.extend(s_.x)
948
+ offset = opt.offset or (max_range * 1.05 if max_range > 0 else 1.0)
949
+ diffs = sorted(set(all_x))
950
+ steps = [b - a for a, b in zip(diffs, diffs[1:]) if b - a > 0]
951
+ min_step = min(steps) if steps else 1.0
952
+ half = min_step * 0.48
953
+ indices = list(range(n))
954
+ if opt.reverse:
955
+ indices.reverse()
956
+ clip = _clip_needed(document)
957
+ x_axis = getattr(document.style, 'xaxis', None) \
958
+ if document.style is not None else None
959
+ y_axis = getattr(document.style, 'yaxis', None) \
960
+ if document.style is not None else None
961
+ xlog = x_axis is not None and x_axis.scale == 'log'
962
+ ylog = y_axis is not None and y_axis.scale == 'log'
963
+ tick_ts = getattr(getattr(document.style, 'yaxis', None),
964
+ 'ticks', None) if document.style else None
965
+ label_kw = _text_kwargs(tick_ts, document.font_family,
966
+ document.font_size_pt, s)
967
+ y_top = 0.0
968
+ x_min = min(all_x) if all_x else 0.0
969
+ x_max = max(all_x) if all_x else 1.0
970
+ for s_ in series:
971
+ if s_.y:
972
+ i = series.index(s_)
973
+ y_top = max(y_top, i * offset + max(s_.y) - min(s_.y))
974
+ pad = (x_max - x_min) * 0.04 if x_max > x_min else 0.5
975
+ view_xlim = document.xlim or (x_min - pad, x_max + pad)
976
+ view_ylim = document.ylim or (-offset * 0.15,
977
+ y_top + offset * 0.25)
978
+ ax.set_xlim(view_xlim)
979
+ ax.set_ylim(view_ylim)
980
+ for i in indices:
981
+ s_ = series[i]
982
+ ymin = min(s_.y)
983
+ baseline = i * offset
984
+ y_plot = [y - ymin + baseline for y in s_.y]
985
+ y_top = max(y_top, max(y_plot))
986
+ poly_x = list(s_.x) + list(s_.x[::-1])
987
+ poly_y = list(y_plot) + [baseline] * len(s_.x)
988
+ if clip:
989
+ px, py = clip_polygon_to_view(poly_x, poly_y,
990
+ view_xlim, view_ylim,
991
+ xlog, ylog)
992
+ else:
993
+ px, py = poly_x, poly_y
994
+ if px:
995
+ patch = Polygon(list(zip(px, py)), closed=True,
996
+ facecolor=s_.color, alpha=opt.fill_alpha,
997
+ edgecolor='none', zorder=2 + i)
998
+ patch.set_gid(f'ilmplot-series-{s_.id}')
999
+ ax.add_patch(patch)
1000
+ line, = ax.plot(s_.x, y_plot, color=s_.color,
1001
+ linewidth=max(s_.linewidth_pt * s, 1e-3),
1002
+ linestyle='None' if s_.linewidth_pt == 0 else
1003
+ (s_.linestyle if s_.linestyle else 'None'),
1004
+ marker=s_.marker or None,
1005
+ markersize=max(s_.markersize_pt * s, 0.0),
1006
+ markeredgewidth=1.0 * s, zorder=3 + i,
1007
+ label='_nolegend_')
1008
+ line.set_gid(f'ilmplot-series-{s_.id}')
1009
+ lines.append((line, types.SimpleNamespace(
1010
+ x=list(s_.x), y=list(y_plot), marker=s_.marker)))
1011
+ xmin, xmax = min(s_.x), max(s_.x)
1012
+ if opt.baseline_width_pt > 0:
1013
+ ax.plot([xmin - half, xmax + half], [baseline, baseline],
1014
+ color=opt.baseline_color,
1015
+ linewidth=opt.baseline_width_pt * s, zorder=1)
1016
+ if opt.labels:
1017
+ t = ax.text(xmin - half * 1.4,
1018
+ baseline + (max(y_plot) - baseline) * 0.5,
1019
+ safe_text(s_.label), ha='right', va='center',
1020
+ **label_kw)
1021
+ t.set_gid(f'ilmplot-series-label-{s_.id}')
1022
+ ax.set_yticks([])
1023
+
1024
+
1025
+ def _yerr_bounds(item):
1026
+ """Per-point (lower, upper) error extents from either
1027
+ representation, or None."""
1028
+ if item.yerr_minus is not None:
1029
+ return list(item.yerr_minus), list(item.yerr_plus)
1030
+ if item.yerr is not None:
1031
+ return list(item.yerr), list(item.yerr)
1032
+ return None
1033
+
1034
+
1035
+ def _draw_series_yerr(document, ax, ser, s, gid):
1036
+ """Capless vertical error bars for a line series, in its colour."""
1037
+ from matplotlib.collections import LineCollection
1038
+ lower, upper = _yerr_bounds(ser)
1039
+ xlim = document.xlim
1040
+ ylim = document.ylim
1041
+ y_axis = getattr(document.style, 'yaxis', None) \
1042
+ if document.style is not None else None
1043
+ ylog = y_axis is not None and y_axis.scale == 'log'
1044
+ segs = []
1045
+ for x, y, lo_e, hi_e in zip(ser.x, ser.y, lower, upper):
1046
+ if xlim is not None and not (min(xlim) <= x <= max(xlim)):
1047
+ continue
1048
+ lo, hi = y - lo_e, y + hi_e
1049
+ if ylog and lo <= 0:
1050
+ lo = y / 10.0
1051
+ if ylim is not None:
1052
+ if lo > max(ylim) or hi < min(ylim):
1053
+ continue
1054
+ lo, hi = max(lo, min(ylim)), min(hi, max(ylim))
1055
+ if lo > hi:
1056
+ continue
1057
+ segs.append(((x, lo), (x, hi)))
1058
+ if not segs:
1059
+ return
1060
+ lw = (ser.linewidth_pt if ser.linewidth_pt > 0 else 1.5) * s
1061
+ coll = LineCollection(segs, colors=ser.color,
1062
+ linewidths=max(lw, 1e-3), zorder=1.9)
1063
+ coll.set_gid(gid)
1064
+ ax.add_collection(coll)
1065
+
1066
+
1067
+ def _series_errband_poly(document, ax, ser):
1068
+ """Shaded y±err band polygon for a line series, or ``None``.
1069
+
1070
+ Returns ``(poly, xs, ys)``; the caller clips and finalises it when
1071
+ the document pins limits. Same gid as the error-bar collection so
1072
+ hit-testing is unchanged.
1073
+ """
1074
+ from matplotlib.colors import to_rgba
1075
+ from matplotlib.patches import Polygon
1076
+ bounds = _yerr_bounds(ser)
1077
+ if bounds is None:
1078
+ return None
1079
+ lower, upper = bounds
1080
+ y_axis = getattr(document.style, 'yaxis', None) \
1081
+ if document.style is not None else None
1082
+ ylog = y_axis is not None and y_axis.scale == 'log'
1083
+ lo_pts, hi_pts = [], []
1084
+ for x, y, lo_e, hi_e in zip(ser.x, ser.y, lower, upper):
1085
+ lo, hi = y - lo_e, y + hi_e
1086
+ if ylog and lo <= 0:
1087
+ lo = y / 10.0
1088
+ lo_pts.append((x, lo))
1089
+ hi_pts.append((x, hi))
1090
+ xs = [p[0] for p in lo_pts] + [p[0] for p in hi_pts][::-1]
1091
+ ys = [p[1] for p in lo_pts] + [p[1] for p in hi_pts][::-1]
1092
+ r, g, b, a = to_rgba(ser.color)
1093
+ poly = Polygon(list(zip(xs, ys)), closed=True,
1094
+ facecolor=(r, g, b, a * ser.error_alpha),
1095
+ edgecolor='none', linewidth=0, zorder=1.8)
1096
+ poly.set_gid(f'ilmplot-series-{ser.id}')
1097
+ ax.add_patch(poly)
1098
+ ax.update_datalim(poly.get_path().vertices)
1099
+ return poly, xs, ys
1100
+
1101
+
1102
+ def _draw_stacked(document, ax, s):
1103
+ """Stacked or grouped columns (percent or absolute) — NCPlot
1104
+ port. With ``opt.horizontal`` the bars run left-to-right: all
1105
+ geometry below stays in (category c, value v) terms and ``P``
1106
+ swaps the axes at emit time."""
1107
+ from matplotlib.patches import Polygon
1108
+ from .document import StackOptions
1109
+ opt = document.stacked or StackOptions()
1110
+ horizontal = opt.horizontal
1111
+ cats = document.categories
1112
+ n_bars = len(cats[0].values)
1113
+ clip = _clip_needed(document)
1114
+ x_axis = getattr(document.style, 'xaxis', None) \
1115
+ if document.style is not None else None
1116
+ y_axis = getattr(document.style, 'yaxis', None) \
1117
+ if document.style is not None else None
1118
+ # The value axis carries the log scale — y for columns, x for bars.
1119
+ if horizontal:
1120
+ xlog = x_axis is not None and x_axis.scale == 'log'
1121
+ ylog = False
1122
+ else:
1123
+ xlog = False # stacked x is always categorical positions
1124
+ ylog = y_axis is not None and y_axis.scale == 'log'
1125
+
1126
+ def P(c, v):
1127
+ return (v, c) if horizontal else (c, v)
1128
+ totals = [sum(c.values[i] for c in cats) for i in range(n_bars)]
1129
+ widths = opt.bar_width / 2.0
1130
+ if opt.percent:
1131
+ y_max = 100.0
1132
+ elif opt.grouped:
1133
+ uppers = [(_yerr_bounds(c) or (None, [0.0] * n_bars))[1]
1134
+ for c in cats]
1135
+ y_max = max(c.values[b] + uppers[i][b]
1136
+ for i, c in enumerate(cats)
1137
+ for b in range(n_bars)) * 1.02
1138
+ else:
1139
+ y_max = max(totals) * 1.02
1140
+ cat_lim = (-0.8, n_bars - 1 + 0.8)
1141
+ val_lim = (0.0, y_max)
1142
+ if horizontal:
1143
+ xlim = (document.xlim if document.xlim is not None
1144
+ else val_lim)
1145
+ ylim = (document.ylim if document.ylim is not None
1146
+ else cat_lim)
1147
+ ax.set_ylim(ylim)
1148
+ # Bars are bare Polygon patches, which never trigger
1149
+ # autoscaling, so the default range is set explicitly (a log
1150
+ # axis keeps matplotlib's own range: a 0 lower bound is
1151
+ # invalid there).
1152
+ if opt.percent or document.xlim is not None or not xlog:
1153
+ ax.set_xlim(xlim)
1154
+ cat_view, val_view = ylim, xlim
1155
+ else:
1156
+ xlim, ylim = _view_limits(document, ax, cat_lim, val_lim)
1157
+ ax.set_xlim(xlim)
1158
+ # Bars are bare Polygon patches, which never trigger y
1159
+ # autoscaling, so the default range is set explicitly (a log
1160
+ # axis keeps matplotlib's own range: a 0 lower bound is
1161
+ # invalid there).
1162
+ if opt.percent or document.ylim is not None or not ylog:
1163
+ ax.set_ylim(ylim)
1164
+ cat_view, val_view = xlim, ylim
1165
+ bottoms = [0.0] * n_bars
1166
+ value_fs = (opt.value_size_pt
1167
+ if opt.value_size_pt is not None
1168
+ else document.font_size_pt * 6.0 / 7.0)
1169
+ if opt.grouped:
1170
+ n_cats = len(cats)
1171
+ w = opt.bar_width / n_cats
1172
+ for i, c in enumerate(cats):
1173
+ gid = f'ilmplot-stack-{c.id}'
1174
+ err_segs = []
1175
+ bounds = _yerr_bounds(c)
1176
+ for b in range(n_bars):
1177
+ v = c.values[b]
1178
+ x = b - opt.bar_width / 2.0 + w * (i + 0.5)
1179
+ if bounds is not None and (bounds[0][b] > 0
1180
+ or bounds[1][b] > 0):
1181
+ lo, hi = v - bounds[0][b], v + bounds[1][b]
1182
+ in_x = not clip or (min(cat_view) <= x
1183
+ <= max(cat_view))
1184
+ if in_x and clip:
1185
+ if lo > max(val_view) or hi < min(val_view):
1186
+ in_x = False
1187
+ else:
1188
+ lo = max(lo, min(val_view))
1189
+ hi = min(hi, max(val_view))
1190
+ if in_x and hi >= lo:
1191
+ err_segs.append((P(x, lo), P(x, hi)))
1192
+ pts = [P(x - w / 2, 0.0), P(x + w / 2, 0.0),
1193
+ P(x + w / 2, v), P(x - w / 2, v)]
1194
+ xs = [p[0] for p in pts]
1195
+ ys = [p[1] for p in pts]
1196
+ if clip:
1197
+ xs, ys = clip_polygon_to_view(xs, ys, xlim, ylim,
1198
+ xlog, ylog)
1199
+ if xs:
1200
+ patch = Polygon(list(zip(xs, ys)), closed=True,
1201
+ facecolor=c.color,
1202
+ edgecolor='none'
1203
+ if opt.edge_width_pt == 0
1204
+ else opt.edge_color,
1205
+ linewidth=opt.edge_width_pt * s,
1206
+ zorder=2)
1207
+ patch.set_gid(gid)
1208
+ ax.add_patch(patch)
1209
+ if opt.show_values and v > 0:
1210
+ if clip and not (min(val_view) <= v
1211
+ <= max(val_view)):
1212
+ continue
1213
+ text = f'{v:.{opt.value_decimals}f}'
1214
+ # Labels sit past the bar end on the background:
1215
+ # the default white (meant for inside stacked
1216
+ # segments) would vanish, so it falls back to
1217
+ # black.
1218
+ tx, ty = P(x, v)
1219
+ t = ax.text(tx, ty, safe_text(text),
1220
+ ha='left' if horizontal else 'center',
1221
+ va='center' if horizontal else 'bottom',
1222
+ fontsize=value_fs * s,
1223
+ color='#000000'
1224
+ if opt.value_color == '#ffffff'
1225
+ else opt.value_color,
1226
+ fontweight='bold' if opt.value_bold
1227
+ else 'normal', zorder=3)
1228
+ t.set_gid(gid)
1229
+ if err_segs:
1230
+ from matplotlib.collections import LineCollection
1231
+ coll = LineCollection(err_segs, colors='#000000',
1232
+ linewidths=1.5 * s, zorder=3)
1233
+ coll.set_gid(gid)
1234
+ ax.add_collection(coll)
1235
+ return
1236
+ for c in cats:
1237
+ gid = f'ilmplot-stack-{c.id}'
1238
+ for b in range(n_bars):
1239
+ v = c.values[b]
1240
+ share = v / totals[b] * 100.0 if totals[b] else 0.0
1241
+ h = share if opt.percent else v
1242
+ y0, y1 = bottoms[b], bottoms[b] + h
1243
+ pts = [P(b - widths, y0), P(b + widths, y0),
1244
+ P(b + widths, y1), P(b - widths, y1)]
1245
+ xs = [p[0] for p in pts]
1246
+ ys = [p[1] for p in pts]
1247
+ if clip:
1248
+ xs, ys = clip_polygon_to_view(xs, ys, xlim, ylim,
1249
+ xlog, ylog)
1250
+ if xs:
1251
+ patch = Polygon(list(zip(xs, ys)), closed=True,
1252
+ facecolor=c.color,
1253
+ edgecolor='none'
1254
+ if opt.edge_width_pt == 0
1255
+ else opt.edge_color,
1256
+ linewidth=opt.edge_width_pt * s,
1257
+ zorder=2)
1258
+ patch.set_gid(gid)
1259
+ ax.add_patch(patch)
1260
+ bottoms[b] += h
1261
+ if opt.show_values and share >= opt.value_threshold \
1262
+ and h > 0:
1263
+ mid = (y0 + y1) / 2.0
1264
+ if clip and not (min(val_view) <= mid
1265
+ <= max(val_view)):
1266
+ continue
1267
+ text = (f'{share:.{opt.value_decimals}f}%'
1268
+ if opt.percent
1269
+ else f'{v:.{opt.value_decimals}f}')
1270
+ tx, ty = P(b, mid)
1271
+ t = ax.text(tx, ty, safe_text(text), ha='center',
1272
+ va='center', fontsize=value_fs * s,
1273
+ color=opt.value_color,
1274
+ fontweight='bold' if opt.value_bold
1275
+ else 'normal', zorder=3)
1276
+ t.set_gid(gid)
1277
+
1278
+
1279
+ def _hist_bins(document):
1280
+ """Shared bin ``edges`` plus per-group counts/density.
1281
+
1282
+ Every group in a histogram shares one edge list so the outlines
1283
+ compare directly.
1284
+ """
1285
+ import numpy as np
1286
+ from .document import HistOptions
1287
+ opt = document.histogram or HistOptions()
1288
+ pooled = np.asarray([v for g in document.groups
1289
+ for v in g.values], dtype=float)
1290
+ lo, hi = float(pooled.min()), float(pooled.max())
1291
+ if lo == hi:
1292
+ edges = np.asarray([lo - 0.5, hi + 0.5])
1293
+ elif opt.bin_width:
1294
+ w = float(opt.bin_width)
1295
+ start = math.floor(lo / w) * w
1296
+ stop = math.ceil(hi / w) * w
1297
+ if stop <= start:
1298
+ stop = start + w
1299
+ edges = np.arange(start, stop + w / 2.0, w)
1300
+ if len(edges) > 2000:
1301
+ edges = np.linspace(start, stop, 2000)
1302
+ elif opt.bins:
1303
+ edges = np.linspace(lo, hi, opt.bins + 1)
1304
+ else:
1305
+ edges = np.histogram_bin_edges(pooled, bins='auto')
1306
+ if len(edges) > 2000:
1307
+ edges = np.linspace(lo, hi, 2000)
1308
+ counts = [np.histogram(g.values, edges, density=opt.density)[0]
1309
+ for g in document.groups]
1310
+ return list(edges), counts
1311
+
1312
+
1313
+ def _hist_label(opt, g):
1314
+ label = safe_text(g.label)
1315
+ if opt.show_n:
1316
+ label = f"{label} ({safe_text(opt.n_format.format(n=len(g.values)))})"
1317
+ return label
1318
+
1319
+
1320
+ def _draw_histogram(document, ax, s):
1321
+ """Overlaid group histograms (bars outline or step) + optional KDE."""
1322
+ import numpy as np
1323
+ from matplotlib.patches import Polygon
1324
+ from matplotlib.colors import to_rgba
1325
+ from . import stats
1326
+ from .document import HistOptions
1327
+ opt = document.histogram or HistOptions()
1328
+ clip = _clip_needed(document)
1329
+ x_axis = getattr(document.style, 'xaxis', None) \
1330
+ if document.style is not None else None
1331
+ y_axis = getattr(document.style, 'yaxis', None) \
1332
+ if document.style is not None else None
1333
+ xlog = x_axis is not None and x_axis.scale == 'log'
1334
+ ylog = y_axis is not None and y_axis.scale == 'log'
1335
+ edges, counts = _hist_bins(document)
1336
+ top = max((float(c.max()) for c in counts if len(c)), default=0.0)
1337
+ pad = (edges[-1] - edges[0]) * 0.02
1338
+ xlim, ylim = _view_limits(document, ax,
1339
+ (edges[0] - pad, edges[-1] + pad),
1340
+ (0.0, top * 1.05 or 1.0))
1341
+ ax.set_xlim(xlim)
1342
+ # Polygons don't autoscale: pin the default range (a log y axis
1343
+ # keeps matplotlib's own range unless the document pins one).
1344
+ if document.ylim is not None or not ylog:
1345
+ ax.set_ylim(ylim)
1346
+ for g, c in zip(document.groups, counts):
1347
+ gid = f'ilmplot-hist-{g.id}'
1348
+ xs = [e for e in edges for _ in (0, 1)]
1349
+ ys = [0.0] + [float(v) for v in c for _ in (0, 1)] + [0.0]
1350
+ if opt.style == 'step':
1351
+ if clip:
1352
+ xs_c, ys_c, _m = clip_series_to_view(
1353
+ xs, ys, xlim, ylim, xlog, ylog)
1354
+ else:
1355
+ xs_c, ys_c = xs, ys
1356
+ line, = ax.plot(xs_c, ys_c, color=g.color,
1357
+ linewidth=1.5 * s, zorder=2,
1358
+ label=_hist_label(opt, g))
1359
+ line.set_gid(gid)
1360
+ else:
1361
+ px, py = xs, ys
1362
+ if clip:
1363
+ px, py = clip_polygon_to_view(px, py, xlim, ylim,
1364
+ xlog, ylog)
1365
+ if px:
1366
+ patch = Polygon(
1367
+ list(zip(px, py)), closed=True,
1368
+ facecolor=to_rgba(g.color, opt.fill_alpha),
1369
+ edgecolor='none' if opt.edge_width_pt == 0
1370
+ else (opt.edge_color or g.color),
1371
+ linewidth=opt.edge_width_pt * s, zorder=2,
1372
+ label=_hist_label(opt, g))
1373
+ patch.set_gid(gid)
1374
+ ax.add_patch(patch)
1375
+ if opt.kde and len(g.values) >= 2:
1376
+ sd = float(np.std(g.values, ddof=1))
1377
+ if sd > 0:
1378
+ evaluate, bw = stats.gaussian_kde(g.values,
1379
+ opt.bandwidth)
1380
+ grid = np.linspace(edges[0], edges[-1], 200)
1381
+ scale = 1.0 if opt.density else \
1382
+ len(g.values) * float(np.mean(np.diff(edges)))
1383
+ y = evaluate(grid) * scale
1384
+ gx, gy = list(grid), list(y)
1385
+ if clip:
1386
+ gx, gy, _m = clip_series_to_view(
1387
+ gx, gy, xlim, ylim, xlog, ylog)
1388
+ line, = ax.plot(gx, gy, color=g.color,
1389
+ linewidth=1.5 * s, zorder=3,
1390
+ label='_nolegend_')
1391
+ line.set_gid(gid)
1392
+
1393
+
1394
+ _ANCHOR_XY = {
1395
+ 'upper left': (0.02, 0.98, 'left', 'top'),
1396
+ 'upper center': (0.5, 0.98, 'center', 'top'),
1397
+ 'upper right': (0.98, 0.98, 'right', 'top'),
1398
+ 'lower left': (0.02, 0.02, 'left', 'bottom'),
1399
+ 'lower center': (0.5, 0.02, 'center', 'bottom'),
1400
+ 'lower right': (0.98, 0.02, 'right', 'bottom'),
1401
+ 'center left': (0.02, 0.5, 'left', 'center'),
1402
+ 'center right': (0.98, 0.5, 'right', 'center'),
1403
+ }
1404
+
1405
+
1406
+ def _data_y_range(document):
1407
+ """(y_max, y_range) for bracket placement, per kind."""
1408
+ if document.kind == 'violin':
1409
+ vals = [v for g in document.groups for v in g.values]
1410
+ v_opt = document.violin
1411
+ if v_opt is not None and v_opt.body == 'bar':
1412
+ import numpy as np
1413
+ top = max(vals)
1414
+ for g in document.groups:
1415
+ mean = float(np.mean(g.values))
1416
+ n_v = len(g.values)
1417
+ sd = float(np.std(g.values, ddof=1)) if n_v >= 2 \
1418
+ else 0.0
1419
+ if v_opt.bar_error == 'sem':
1420
+ err = sd / math.sqrt(n_v) if n_v else 0.0
1421
+ elif v_opt.bar_error == 'sd':
1422
+ err = sd
1423
+ else:
1424
+ err = 0.0
1425
+ top = max(top, mean + err)
1426
+ rng = (top - min(0.0, min(vals))) or 1.0
1427
+ if v_opt.show_n and v_opt.n_position == 'top':
1428
+ top += 0.08 * rng
1429
+ return top, rng
1430
+ rng = (max(vals) - min(vals)) or 1.0
1431
+ top = max(vals)
1432
+ if v_opt is not None and v_opt.show_n \
1433
+ and v_opt.n_position == 'top':
1434
+ top += 0.08 * rng
1435
+ return top, rng
1436
+ if document.kind == 'histogram':
1437
+ _edges, counts = _hist_bins(document)
1438
+ top = max((float(c.max()) for c in counts if len(c)),
1439
+ default=0.0)
1440
+ return top, top or 1.0
1441
+ if document.kind == 'stacked_column':
1442
+ opt = document.stacked
1443
+ percent = opt.percent if opt is not None else True
1444
+ if percent:
1445
+ return 100.0, 100.0
1446
+ if opt is not None and opt.grouped:
1447
+ uppers = [
1448
+ (_yerr_bounds(c)
1449
+ or (None, [0.0] * len(c.values)))[1]
1450
+ for c in document.categories]
1451
+ top = max(v + uppers[j][i]
1452
+ for j, c in enumerate(document.categories)
1453
+ for i, v in enumerate(c.values))
1454
+ return top, top or 1.0
1455
+ n_bars = len(document.categories[0].values)
1456
+ totals = [sum(c.values[i] for c in document.categories)
1457
+ for i in range(n_bars)]
1458
+ top = max(totals)
1459
+ return top, top or 1.0
1460
+ if document.kind == 'ridgeline':
1461
+ offset = _ridge_offset(document)
1462
+ top = 0.0
1463
+ for i, s_ in enumerate(document.series):
1464
+ if s_.y:
1465
+ top = max(top, i * offset + max(s_.y) - min(s_.y))
1466
+ return top, top or 1.0
1467
+ ys = [y for s_ in document.series for y in s_.y]
1468
+ for b_ in document.bands:
1469
+ ys += list(b_.y1) + list(b_.y2)
1470
+ return max(ys), (max(ys) - min(ys)) or 1.0
1471
+
1472
+
1473
+ def _ridge_offset(document):
1474
+ opt = document.ridgeline
1475
+ if opt is not None and opt.offset:
1476
+ return opt.offset
1477
+ max_range = 0.0
1478
+ for s_ in document.series:
1479
+ if s_.y:
1480
+ max_range = max(max_range, max(s_.y) - min(s_.y))
1481
+ return max_range * 1.05 if max_range > 0 else 1.0
1482
+
1483
+
1484
+ def _draw_annotations(document, ax, s):
1485
+ for ann in document.annotations:
1486
+ if ann.x is not None and ann.y is not None:
1487
+ x, y, ha, va = ann.x, ann.y, 'center', 'center'
1488
+ else:
1489
+ x, y, ha, va = _ANCHOR_XY[ann.anchor]
1490
+ kw = _text_kwargs(ann.style, document.font_family,
1491
+ document.font_size_pt, s)
1492
+ if ann.box:
1493
+ kw['bbox'] = dict(boxstyle='round,pad=0.3',
1494
+ facecolor='white', edgecolor='none',
1495
+ alpha=0.8)
1496
+ t = ax.text(x, y, safe_text(ann.text), transform=ax.transAxes,
1497
+ ha=ha, va=va, zorder=10, **kw)
1498
+ t.set_gid(f'ilmplot-annotation-{ann.id}')
1499
+
1500
+
1501
+ def _draw_brackets(document, ax, s):
1502
+ if not document.brackets:
1503
+ return
1504
+ ymax, yrange = _data_y_range(document)
1505
+ index_kind = document.kind in ('violin', 'stacked_column')
1506
+ for br in document.brackets:
1507
+ pa = br.a + 1 if document.kind == 'violin' else br.a
1508
+ pb = br.b + 1 if document.kind == 'violin' else br.b
1509
+ y = ymax + yrange * br.offset
1510
+ tick = yrange * 0.01
1511
+ color = br.style.color if br.style is not None else '#333333'
1512
+ kw = _text_kwargs(br.style, document.font_family, 8.0, s)
1513
+ if br.style is None:
1514
+ kw['color'] = '#333333'
1515
+ gid = f'ilmplot-bracket-{br.id}'
1516
+ for xs, ys in (((pa, pb), (y, y)),
1517
+ ((pa, pa), (y - tick, y)),
1518
+ ((pb, pb), (y - tick, y))):
1519
+ line, = ax.plot(xs, ys, color=color, linewidth=1.0 * s,
1520
+ zorder=10, clip_on=False)
1521
+ line.set_gid(gid)
1522
+ t = ax.text((pa + pb) / 2.0, y + yrange * 0.01,
1523
+ safe_text(br.text), ha='center', va='bottom',
1524
+ zorder=10, clip_on=False, **kw)
1525
+ t.set_gid(gid)
1526
+
1527
+
1528
+ def _build_figure(document: PlotDocument, s: float,
1529
+ width_mm: float, height_mm: float):
1530
+ """Build (Figure, Axes) for *document* at typography scale *s*."""
1531
+ from matplotlib.figure import Figure
1532
+ from matplotlib.ticker import (AutoMinorLocator, LogLocator,
1533
+ MultipleLocator, NullFormatter)
1534
+ style = document.style
1535
+ x_axis = getattr(style, 'xaxis', None) if style is not None else None
1536
+ y_axis = getattr(style, 'yaxis', None) if style is not None else None
1537
+ kind = document.kind
1538
+ horizontal_bars = kind == 'stacked_column' \
1539
+ and document.stacked is not None and document.stacked.horizontal
1540
+ categorical_y = horizontal_bars
1541
+ categorical_x = (bool(document.x_tick_labels) or kind in (
1542
+ 'violin', 'stacked_column')) and not horizontal_bars
1543
+ fig = Figure(figsize=(width_mm / 25.4, height_mm / 25.4))
1544
+ ax = fig.add_axes(list(document.axes_rect))
1545
+ lines = []
1546
+ span_polys = []
1547
+ band_polys = []
1548
+ err_polys = []
1549
+ if kind == 'violin':
1550
+ _draw_violin(document, ax, s)
1551
+ elif kind == 'ridgeline':
1552
+ _draw_ridgeline(document, ax, s, lines)
1553
+ elif kind == 'stacked_column':
1554
+ _draw_stacked(document, ax, s)
1555
+ elif kind == 'histogram':
1556
+ _draw_histogram(document, ax, s)
1557
+ else:
1558
+ for sp_ in document.spans:
1559
+ # Full-height/width strips sit below everything (zorder 0.9):
1560
+ # a unit rect through a blended axis transform.
1561
+ from matplotlib.patches import Polygon
1562
+ if sp_.axis == 'x':
1563
+ verts = [(sp_.lo, 0.0), (sp_.hi, 0.0),
1564
+ (sp_.hi, 1.0), (sp_.lo, 1.0)]
1565
+ trans = ax.get_xaxis_transform()
1566
+ ax.update_datalim([(sp_.lo, 0.0), (sp_.hi, 0.0)],
1567
+ updatey=False)
1568
+ else:
1569
+ verts = [(0.0, sp_.lo), (1.0, sp_.lo),
1570
+ (1.0, sp_.hi), (0.0, sp_.hi)]
1571
+ trans = ax.get_yaxis_transform()
1572
+ ax.update_datalim([(0.0, sp_.lo), (0.0, sp_.hi)],
1573
+ updatex=False)
1574
+ poly = Polygon(verts, closed=True, transform=trans,
1575
+ facecolor=sp_.color, edgecolor='none',
1576
+ linewidth=0, zorder=0.9)
1577
+ poly.set_gid(f'ilmplot-span-{sp_.id}')
1578
+ ax.add_patch(poly)
1579
+ span_polys.append((poly, sp_))
1580
+ # On a log axis non-positive band coordinates are excluded
1581
+ # from datalim (a baseline of 0 must not poison autoscale) and
1582
+ # clipped to the view once limits are final (below).
1583
+ xlog_d = x_axis is not None and x_axis.scale == 'log' \
1584
+ and not categorical_x
1585
+ ylog_d = y_axis is not None and y_axis.scale == 'log'
1586
+ for b_ in document.bands:
1587
+ from matplotlib.patches import Polygon
1588
+ xs = list(b_.x) + list(b_.x)[::-1]
1589
+ ys = list(b_.y1) + list(b_.y2)[::-1]
1590
+ poly = Polygon(list(zip(xs, ys)), closed=True,
1591
+ facecolor=b_.color, edgecolor='none',
1592
+ linewidth=0, zorder=1)
1593
+ poly.set_gid(f'ilmplot-band-{b_.id}')
1594
+ ax.add_patch(poly)
1595
+ ax.update_datalim([
1596
+ (x, y) for x, y in poly.get_path().vertices
1597
+ if (not xlog_d or x > 0) and (not ylog_d or y > 0)])
1598
+ band_polys.append((poly, b_))
1599
+ for s_ in document.series:
1600
+ # A 0-width stroke renders as a hairline under QtSvg — hide
1601
+ # the stroke instead (markers still draw).
1602
+ ls = s_.linestyle if s_.linestyle else 'None'
1603
+ line, = ax.plot(
1604
+ s_.x, s_.y,
1605
+ color=s_.color,
1606
+ linewidth=max(s_.linewidth_pt * s, 1e-3),
1607
+ linestyle='None' if s_.linewidth_pt == 0 else ls,
1608
+ marker=s_.marker or None,
1609
+ markersize=max(s_.markersize_pt * s, 0.0),
1610
+ markeredgewidth=1.0 * s,
1611
+ label=safe_text(s_.label) or '_nolegend_',
1612
+ )
1613
+ line.set_gid(f'ilmplot-series-{s_.id}')
1614
+ lines.append((line, s_))
1615
+ if _yerr_bounds(s_) is not None:
1616
+ if getattr(s_, 'error_style', 'bars') == 'band':
1617
+ got = _series_errband_poly(document, ax, s_)
1618
+ if got is not None:
1619
+ err_polys.append(got)
1620
+ else:
1621
+ _draw_series_yerr(document, ax, s_, s,
1622
+ f'ilmplot-series-{s_.id}')
1623
+ if kind == 'violin':
1624
+ v_opt = document.violin
1625
+ tick_n = v_opt is not None and v_opt.show_n \
1626
+ and v_opt.n_position == 'tick'
1627
+ ax.set_xticks(list(range(1, len(document.groups) + 1)),
1628
+ [safe_text(g.label) + '\n' + _n_text(v_opt, g)
1629
+ if tick_n else safe_text(g.label)
1630
+ for g in document.groups])
1631
+ elif kind == 'stacked_column':
1632
+ n_bars = len(document.categories[0].values)
1633
+ if document.x_tick_labels:
1634
+ labels = {p: safe_text(l)
1635
+ for p, l in document.x_tick_labels}
1636
+ else:
1637
+ labels = {}
1638
+ set_ticks = ax.set_yticks if horizontal_bars else ax.set_xticks
1639
+ set_ticks(list(range(n_bars)),
1640
+ [labels.get(float(i), str(i))
1641
+ for i in range(n_bars)])
1642
+ elif categorical_x:
1643
+ ax.set_xticks([p for p, _l in document.x_tick_labels],
1644
+ [safe_text(l) for _p, l in document.x_tick_labels])
1645
+ # Axis scales first so locators/formatters see the right transform.
1646
+ if x_axis is not None and x_axis.scale != 'linear' and not categorical_x:
1647
+ ax.set_xscale(x_axis.scale)
1648
+ if y_axis is not None and y_axis.scale != 'linear' \
1649
+ and not categorical_y:
1650
+ ax.set_yscale(y_axis.scale)
1651
+ if document.xlim is not None:
1652
+ ax.set_xlim(document.xlim)
1653
+ if document.ylim is not None:
1654
+ ax.set_ylim(document.ylim)
1655
+ # Tick locators (need final limits for the step density check).
1656
+ for axis, ast, axis_obj in (
1657
+ (ax.xaxis, x_axis, 'x'), (ax.yaxis, y_axis, 'y')):
1658
+ if ast is None:
1659
+ continue
1660
+ categorical = (categorical_x and axis_obj == 'x') \
1661
+ or (categorical_y and axis_obj == 'y')
1662
+ if ast.step is not None and not categorical \
1663
+ and ast.scale == 'linear':
1664
+ lo, hi = (ax.get_xlim() if axis_obj == 'x'
1665
+ else ax.get_ylim())
1666
+ if abs(hi - lo) / ast.step <= 1000:
1667
+ axis.set_major_locator(MultipleLocator(ast.step))
1668
+ if ast.minor:
1669
+ if ast.scale == 'log' and not categorical:
1670
+ axis.set_minor_locator(LogLocator(base=10.0, subs='auto'))
1671
+ elif not categorical:
1672
+ axis.set_minor_locator(AutoMinorLocator())
1673
+ axis.set_minor_formatter(NullFormatter())
1674
+ if ast.prefix or ast.suffix or ast.decimals is not None:
1675
+ axis.set_major_formatter(_AffixFormatter(
1676
+ axis.get_major_formatter(), ast.prefix, ast.suffix,
1677
+ ast.decimals, numeric=not categorical))
1678
+ # Reversed axes after limits are final.
1679
+ if x_axis is not None and x_axis.reversed \
1680
+ and not ax.xaxis_inverted():
1681
+ ax.invert_xaxis()
1682
+ if y_axis is not None and y_axis.reversed \
1683
+ and not ax.yaxis_inverted():
1684
+ ax.invert_yaxis()
1685
+ # QtSvg ignores clipPath: clip data to the axes box ourselves, but
1686
+ # only when the document pins a limit (unlimited docs keep the
1687
+ # autoscaled box that already contains every point → identical bytes).
1688
+ if document.xlim is not None or document.ylim is not None:
1689
+ ax.autoscale(False)
1690
+ xlog = ax.get_xscale() == 'log'
1691
+ ylog = ax.get_yscale() == 'log'
1692
+ for line, s_ in lines:
1693
+ xs, ys, mask = clip_series_to_view(
1694
+ s_.x, s_.y, ax.get_xlim(), ax.get_ylim(), xlog, ylog)
1695
+ line.set_data(xs, ys)
1696
+ if s_.marker:
1697
+ line.set_markevery(mask)
1698
+ for poly, b_ in band_polys:
1699
+ xs, ys = clip_polygon_to_view(
1700
+ list(b_.x) + list(b_.x)[::-1],
1701
+ list(b_.y1) + list(b_.y2)[::-1],
1702
+ ax.get_xlim(), ax.get_ylim(), xlog, ylog)
1703
+ if xs:
1704
+ poly.set_xy(list(zip(xs, ys)))
1705
+ else:
1706
+ poly.set_visible(False)
1707
+ for poly, xs0, ys0 in err_polys:
1708
+ xs, ys = clip_polygon_to_view(
1709
+ xs0, ys0, ax.get_xlim(), ax.get_ylim(), xlog, ylog)
1710
+ if xs:
1711
+ poly.set_xy(list(zip(xs, ys)))
1712
+ else:
1713
+ poly.set_visible(False)
1714
+ # Span strips draw through a blended axis transform, so their
1715
+ # data-axis extent is clipped to the final view limits by hand —
1716
+ # QtSvg ignores clipPath. ``get_xlim``/``get_ylim`` un-stale the
1717
+ # view (autoscale has already run when limits were pinned).
1718
+ if span_polys:
1719
+ ax.autoscale_view(tight=False)
1720
+ for poly, sp_ in span_polys:
1721
+ log = (ax.get_xscale() == 'log' if sp_.axis == 'x'
1722
+ else ax.get_yscale() == 'log')
1723
+ view = (ax.get_xlim() if sp_.axis == 'x'
1724
+ else ax.get_ylim())
1725
+ lo_v, hi_v = min(view), max(view)
1726
+ lo, hi = sp_.lo, sp_.hi
1727
+ if log and lo <= 0:
1728
+ lo = lo_v
1729
+ if hi < lo_v or lo > hi_v:
1730
+ poly.set_visible(False)
1731
+ continue
1732
+ lo, hi = max(lo, lo_v), min(hi, hi_v)
1733
+ if not lo < hi:
1734
+ poly.set_visible(False)
1735
+ continue
1736
+ if sp_.axis == 'x':
1737
+ poly.set_xy([(lo, 0.0), (hi, 0.0), (hi, 1.0),
1738
+ (lo, 1.0)])
1739
+ else:
1740
+ poly.set_xy([(0.0, lo), (1.0, lo), (1.0, hi),
1741
+ (0.0, hi)])
1742
+ # Bands on a log axis are clipped once limits are final even when
1743
+ # nothing is pinned (re-clipping a pinned band is idempotent).
1744
+ # Non-positive coordinates count as the view minimum, so a baseline
1745
+ # of 0 clamps to the bottom edge instead of being dropped.
1746
+ if band_polys:
1747
+ xlog_v = ax.get_xscale() == 'log'
1748
+ ylog_v = ax.get_yscale() == 'log'
1749
+ if xlog_v or ylog_v:
1750
+ ax.autoscale_view(tight=False)
1751
+ vx, vy = ax.get_xlim(), ax.get_ylim()
1752
+ for poly, b_ in band_polys:
1753
+ xs = [v if not xlog_v or v > 0 else min(vx)
1754
+ for v in list(b_.x) + list(b_.x)[::-1]]
1755
+ ys = [v if not ylog_v or v > 0 else min(vy)
1756
+ for v in list(b_.y1) + list(b_.y2)[::-1]]
1757
+ xs2, ys2 = clip_polygon_to_view(
1758
+ xs, ys, vx, vy, xlog_v, ylog_v)
1759
+ if xs2:
1760
+ poly.set_xy(list(zip(xs2, ys2)))
1761
+ else:
1762
+ poly.set_visible(False)
1763
+ underlines = []
1764
+ t_style = getattr(style, 'title', None) if style is not None else None
1765
+ if document.title:
1766
+ kw = _text_kwargs(t_style, document.font_family,
1767
+ document.title_size_pt, s)
1768
+ loc = t_style.align if t_style is not None else 'center'
1769
+ t = ax.set_title(safe_text(document.title), pad=6.0 * s,
1770
+ loc=loc, **kw)
1771
+ t.set_gid('ilmplot-title')
1772
+ if t_style is not None and t_style.underline:
1773
+ underlines.append(lambda t=t: [t])
1774
+ xl_style = getattr(style, 'xlabel', None) if style is not None else None
1775
+ yl_style = getattr(style, 'ylabel', None) if style is not None else None
1776
+ if document.xlabel:
1777
+ xl = ax.set_xlabel(
1778
+ safe_text(document.xlabel),
1779
+ **_text_kwargs(xl_style, document.font_family,
1780
+ document.font_size_pt, s))
1781
+ xl.set_gid('ilmplot-xlabel')
1782
+ if xl_style is not None and xl_style.underline:
1783
+ underlines.append(lambda t=xl: [t])
1784
+ if document.ylabel and kind != 'ridgeline':
1785
+ yl = ax.set_ylabel(
1786
+ safe_text(document.ylabel),
1787
+ **_text_kwargs(yl_style, document.font_family,
1788
+ document.font_size_pt, s))
1789
+ yl.set_gid('ilmplot-ylabel')
1790
+ if yl_style is not None and yl_style.underline:
1791
+ underlines.append(lambda t=yl: [t])
1792
+ ax.tick_params(labelsize=document.font_size_pt * s,
1793
+ length=3.5 * s, width=0.8 * s,
1794
+ pad=3.5 * s)
1795
+ for axis_name, ast in (('x', x_axis), ('y', y_axis)):
1796
+ if ast is None:
1797
+ continue
1798
+ ax.tick_params(axis=axis_name, direction=ast.direction,
1799
+ length=ast.length_pt * s,
1800
+ labelrotation=ast.rotation or 0)
1801
+ ts = ast.ticks
1802
+ tick_labels = (ax.get_xticklabels() if axis_name == 'x'
1803
+ else ax.get_yticklabels())
1804
+ if ts is not None:
1805
+ tick_kw = {}
1806
+ if ts.family is not None:
1807
+ tick_kw['labelfontfamily'] = ts.family
1808
+ if ts.size_pt is not None:
1809
+ tick_kw['labelsize'] = ts.size_pt * s
1810
+ tick_kw['labelcolor'] = ts.color
1811
+ ax.tick_params(axis=axis_name, **tick_kw)
1812
+ # weight/style are not tick_params keys: set them on the
1813
+ # (persistent, post-locator) tick label Text objects.
1814
+ for label in tick_labels:
1815
+ label.set_fontweight('bold' if ts.bold else 'normal')
1816
+ label.set_fontstyle('italic' if ts.italic else 'normal')
1817
+ if axis_name == 'x' and ast.rotation:
1818
+ ha = 'right' if ast.rotation > 0 else 'left'
1819
+ for label in tick_labels:
1820
+ label.set(ha=ha, rotation_mode='anchor')
1821
+ if ts is not None and ts.underline:
1822
+ labels = (ax.get_xticklabels if axis_name == 'x'
1823
+ else ax.get_yticklabels)
1824
+ underlines.append(
1825
+ lambda labels=labels:
1826
+ [l for l in labels() if l.get_visible()])
1827
+ frame = getattr(style, 'frame', None) if style is not None else None
1828
+ # Ridgeline always drops left/top/right and its y axis, like NCPlot.
1829
+ forced = kind == 'ridgeline'
1830
+ hide = {
1831
+ 'top': forced or (frame is not None and frame.hide_top),
1832
+ 'right': forced or (frame is not None and frame.hide_right),
1833
+ 'left': forced or (frame is not None and frame.hide_left),
1834
+ 'bottom': frame is not None and frame.hide_bottom,
1835
+ }
1836
+ for name, spine in ax.spines.items():
1837
+ if hide.get(name):
1838
+ spine.set_visible(False)
1839
+ continue
1840
+ if frame is not None:
1841
+ spine.set_color(frame.color)
1842
+ if frame.linewidth_pt == 0:
1843
+ spine.set_visible(False)
1844
+ else:
1845
+ spine.set_linewidth(frame.linewidth_pt * s)
1846
+ else:
1847
+ spine.set_linewidth(0.8 * s)
1848
+ # A hidden left/bottom spine also hides that axis's ticks/labels.
1849
+ if hide['left']:
1850
+ ax.tick_params(axis='y', which='both',
1851
+ left=False, labelleft=False)
1852
+ if hide['bottom']:
1853
+ ax.tick_params(axis='x', which='both',
1854
+ bottom=False, labelbottom=False)
1855
+ ax.xaxis.labelpad = 4.0 * s
1856
+ ax.yaxis.labelpad = 4.0 * s
1857
+ # Tick offset / scientific-notation text is not covered by
1858
+ # tick_params labelsize — set it explicitly.
1859
+ for axis in (ax.xaxis, ax.yaxis):
1860
+ try:
1861
+ axis.get_offset_text().set_size(
1862
+ document.font_size_pt * s)
1863
+ except Exception:
1864
+ pass
1865
+ if document.grid:
1866
+ grid = getattr(style, 'grid', None) if style is not None else None
1867
+ if grid is None:
1868
+ ax.grid(True, color='#b0b0b0', linewidth=0.8 * s, alpha=1.0)
1869
+ else:
1870
+ if grid.which == 'both':
1871
+ for axis_name, ast in (('x', x_axis), ('y', y_axis)):
1872
+ if grid.axis in ('both', axis_name) and (
1873
+ ast is None or not ast.minor):
1874
+ axis = ax.xaxis if axis_name == 'x' else ax.yaxis
1875
+ categorical = (categorical_x
1876
+ and axis_name == 'x') or \
1877
+ (categorical_y and axis_name == 'y')
1878
+ if categorical:
1879
+ continue
1880
+ if ast is not None and ast.scale == 'log':
1881
+ axis.set_minor_locator(
1882
+ LogLocator(base=10.0, subs='auto'))
1883
+ else:
1884
+ axis.set_minor_locator(AutoMinorLocator())
1885
+ axis.set_minor_formatter(NullFormatter())
1886
+ if grid.linewidth_pt > 0:
1887
+ ax.grid(True, which=grid.which, axis=grid.axis,
1888
+ color=grid.color, linestyle=grid.linestyle,
1889
+ linewidth=grid.linewidth_pt * s,
1890
+ alpha=grid.alpha)
1891
+ if document.legend:
1892
+ from matplotlib.patches import Patch
1893
+ if kind == 'stacked_column':
1894
+ handles = [Patch(facecolor=c.color,
1895
+ label=safe_text(c.label) or '_nolegend_')
1896
+ for c in document.categories]
1897
+ patch_handles = True
1898
+ elif kind == 'histogram':
1899
+ h_opt = document.histogram
1900
+ if h_opt is not None and h_opt.show_n:
1901
+ h_labels = [_hist_label(h_opt, g)
1902
+ for g in document.groups]
1903
+ else:
1904
+ h_labels = [safe_text(g.label)
1905
+ for g in document.groups]
1906
+ if h_opt is not None and h_opt.style == 'step':
1907
+ from matplotlib.lines import Line2D
1908
+ handles = [Line2D([], [], color=g.color, linewidth=1.5,
1909
+ label=l or '_nolegend_')
1910
+ for g, l in zip(document.groups, h_labels)]
1911
+ else:
1912
+ from matplotlib.colors import to_rgba
1913
+ alpha = h_opt.fill_alpha if h_opt is not None else 0.5
1914
+ handles = [
1915
+ Patch(facecolor=to_rgba(g.color, alpha),
1916
+ label=l or '_nolegend_')
1917
+ for g, l in zip(document.groups, h_labels)]
1918
+ patch_handles = True
1919
+ else:
1920
+ patch_handles = False
1921
+ handles = [l for l in ax.get_lines()
1922
+ if l.get_label()
1923
+ and not l.get_label().startswith('_')]
1924
+ handles += [Patch(facecolor=b_.color,
1925
+ label=safe_text(b_.label))
1926
+ for b_ in document.bands if b_.label]
1927
+ handles += [Patch(facecolor=sp_.color,
1928
+ label=safe_text(sp_.label))
1929
+ for sp_ in document.spans if sp_.label]
1930
+ if handles:
1931
+ leg_style = getattr(style, 'legend', None) \
1932
+ if style is not None else None
1933
+ loc = document.legend_location
1934
+ leg_kw = {'loc': loc,
1935
+ 'fontsize': document.font_size_pt * s}
1936
+ if loc == 'outside right':
1937
+ leg_kw['loc'] = 'center left'
1938
+ leg_kw['bbox_to_anchor'] = (1.02, 0.5)
1939
+ if leg_style is not None:
1940
+ leg_kw['ncols'] = leg_style.ncols
1941
+ leg_kw['frameon'] = leg_style.frame
1942
+ if leg_style.frame_color != '#cccccc':
1943
+ leg_kw['edgecolor'] = leg_style.frame_color
1944
+ if leg_style.text is not None:
1945
+ from matplotlib.font_manager import FontProperties
1946
+ ts = leg_style.text
1947
+ # ``prop`` supersedes ``fontsize`` — drop it.
1948
+ del leg_kw['fontsize']
1949
+ leg_kw['prop'] = FontProperties(
1950
+ family=ts.family or document.font_family,
1951
+ size=(ts.size_pt * s if ts.size_pt is not None
1952
+ else document.font_size_pt * s),
1953
+ weight='bold' if ts.bold else 'normal',
1954
+ style='italic' if ts.italic else 'normal')
1955
+ leg_kw['labelcolor'] = ts.color
1956
+ leg = ax.legend(handles=handles, **leg_kw)
1957
+ if leg is not None:
1958
+ frame = leg.get_frame()
1959
+ frame.set_linewidth(frame.get_linewidth() * s)
1960
+ if leg_style is not None and leg_style.text is not None \
1961
+ and leg_style.text.underline:
1962
+ underlines.append(
1963
+ lambda leg=leg: [t for t in leg.get_texts()
1964
+ if t.get_visible()])
1965
+ _draw_annotations(document, ax, s)
1966
+ _draw_brackets(document, ax, s)
1967
+ # Brackets sit above the data; auto-scaled y limits don't reach them.
1968
+ # Extend the top so the bracket line plus ~10% (its text) fits —
1969
+ # except percent-stacked bars, where 0–100 stays fixed like NCPlot.
1970
+ if document.brackets and document.ylim is None:
1971
+ percent = (kind == 'stacked_column'
1972
+ and (document.stacked is None
1973
+ or document.stacked.percent))
1974
+ if not percent:
1975
+ ymax, yrange = _data_y_range(document)
1976
+ top = ymax + yrange * (
1977
+ max(b.offset for b in document.brackets) + 0.10)
1978
+ lo, hi = ax.get_ylim()
1979
+ if top > hi:
1980
+ ax.set_ylim(lo, top)
1981
+ if underlines:
1982
+ _add_underline(ax, lambda: [t for get in underlines
1983
+ for t in get()])
1984
+ return fig, ax
1985
+
1986
+
1987
+ _regions_cache: OrderedDict = OrderedDict()
1988
+ _REGIONS_CACHE_MAX = 32
1989
+
1990
+
1991
+ def element_regions(document: PlotDocument) -> dict:
1992
+ """Bounding boxes of every drawable element, in figure fractions.
1993
+
1994
+ Coordinates are ``(x0, y0, x1, y1)`` with top-left origin (``x/W``,
1995
+ ``1 - y/H``) so the editor can map them onto the SVG item's rect.
1996
+ Cached by the canonical document like ``render_document``.
1997
+ """
1998
+ document.validate()
1999
+ key = ('regions', _canonical(document))
2000
+ hit = _regions_cache.get(key)
2001
+ if hit is not None:
2002
+ _regions_cache.move_to_end(key)
2003
+ return hit
2004
+
2005
+ import matplotlib
2006
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
2007
+
2008
+ regions = {}
2009
+ with matplotlib.rc_context(_deterministic_rc(document)):
2010
+ fig = None
2011
+ try:
2012
+ fig, ax = _build_figure(document, 1.0, document.width_mm,
2013
+ document.height_mm)
2014
+ _fit_figure(document, fig, ax)
2015
+ canvas = FigureCanvasAgg(fig)
2016
+ canvas.draw()
2017
+ renderer = canvas.get_renderer()
2018
+ W, H = canvas.get_width_height()
2019
+
2020
+ def frac(bb):
2021
+ # Hit-testing happens inside the canvas — clamp to it.
2022
+ return (max(0.0, min(1.0, bb.x0 / W)),
2023
+ max(0.0, min(1.0, 1.0 - bb.y1 / H)),
2024
+ max(0.0, min(1.0, bb.x1 / W)),
2025
+ max(0.0, min(1.0, 1.0 - bb.y0 / H)))
2026
+
2027
+ # loc='left'/'right' titles live on _left_title/_right_title.
2028
+ t_style = getattr(document.style, 'title', None) \
2029
+ if document.style is not None else None
2030
+ title_artist = {None: ax.title, 'center': ax.title,
2031
+ 'left': ax._left_title,
2032
+ 'right': ax._right_title}[
2033
+ getattr(t_style, 'align', None)]
2034
+ for name, artist in (('title', title_artist),
2035
+ ('xlabel', ax.xaxis.label),
2036
+ ('ylabel', ax.yaxis.label)):
2037
+ if artist.get_visible() and artist.get_text():
2038
+ regions[name] = frac(
2039
+ artist.get_window_extent(renderer))
2040
+ for name, labels in (('xticks', ax.get_xticklabels()),
2041
+ ('yticks', ax.get_yticklabels())):
2042
+ boxes = [l.get_window_extent(renderer) for l in labels
2043
+ if l.get_visible() and l.get_text()]
2044
+ if boxes:
2045
+ x0 = min(b.x0 for b in boxes)
2046
+ y0 = min(b.y0 for b in boxes)
2047
+ x1 = max(b.x1 for b in boxes)
2048
+ y1 = max(b.y1 for b in boxes)
2049
+
2050
+ class _B:
2051
+ pass
2052
+ bb = _B()
2053
+ bb.x0, bb.y0, bb.x1, bb.y1 = x0, y0, x1, y1
2054
+ regions[name] = frac(bb)
2055
+ leg = ax.get_legend()
2056
+ if leg is not None:
2057
+ regions['legend'] = frac(leg.get_window_extent(renderer))
2058
+ rect = ax.get_position()
2059
+ regions['frame'] = (rect.x0, 1.0 - rect.y1,
2060
+ rect.x1, 1.0 - rect.y0)
2061
+ if document.grid:
2062
+ regions['grid'] = regions['frame']
2063
+ series_regions = {}
2064
+ for line in ax.get_lines():
2065
+ gid = line.get_gid() or ''
2066
+ if not gid.startswith('ilmplot-series-'):
2067
+ continue
2068
+ sid = gid[len('ilmplot-series-'):]
2069
+ try:
2070
+ pts = ax.transData.transform(
2071
+ list(zip(line.get_xdata(), line.get_ydata())))
2072
+ except Exception:
2073
+ continue
2074
+ n = len(pts)
2075
+ if not n:
2076
+ continue
2077
+ step = max(1, -(-n // 2000))
2078
+ sampled = pts[::step]
2079
+ fps = [(float(p[0]) / W, 1.0 - float(p[1]) / H)
2080
+ for p in sampled]
2081
+ xs = [p[0] for p in fps]
2082
+ ys = [p[1] for p in fps]
2083
+ series_regions[sid] = {
2084
+ 'bbox': (min(xs), min(ys), max(xs), max(ys)),
2085
+ 'points': fps,
2086
+ }
2087
+ regions['series'] = series_regions
2088
+ # Kind-specific element groups keyed by id.
2089
+ for prefix, key in (('ilmplot-violin-', 'violins'),
2090
+ ('ilmplot-stack-', 'stacks'),
2091
+ ('ilmplot-hist-', 'hists'),
2092
+ ('ilmplot-annotation-', 'annotations'),
2093
+ ('ilmplot-bracket-', 'brackets'),
2094
+ ('ilmplot-band-', 'bands'),
2095
+ ('ilmplot-span-', 'spans')):
2096
+ grouped = {}
2097
+ points = {}
2098
+ for artist in ax.findobj():
2099
+ gid = artist.get_gid() or ''
2100
+ if not gid.startswith(prefix):
2101
+ continue
2102
+ aid = gid[len(prefix):]
2103
+ if not artist.get_visible():
2104
+ continue
2105
+ try:
2106
+ bb = artist.get_window_extent(renderer)
2107
+ except Exception:
2108
+ continue
2109
+ if bb.width <= 0 and bb.height <= 0:
2110
+ continue
2111
+ if aid in grouped:
2112
+ prev = grouped[aid]
2113
+ bb = type(bb).from_extents(
2114
+ min(prev.x0, bb.x0), min(prev.y0, bb.y0),
2115
+ max(prev.x1, bb.x1), max(prev.y1, bb.y1))
2116
+ grouped[aid] = bb
2117
+ if key in ('bands', 'spans'):
2118
+ # The region polygon for point-in-polygon picks.
2119
+ try:
2120
+ tp = artist.get_transform().transform(
2121
+ artist.get_path().vertices)
2122
+ points[aid] = [
2123
+ (float(q[0]) / W,
2124
+ 1.0 - float(q[1]) / H)
2125
+ for q in tp]
2126
+ except Exception:
2127
+ pass
2128
+ if grouped:
2129
+ regions[key] = {
2130
+ aid: {'bbox': frac(bb),
2131
+ 'points': points.get(aid)}
2132
+ for aid, bb in grouped.items()}
2133
+ finally:
2134
+ if fig is not None:
2135
+ fig.clear()
2136
+ _regions_cache[key] = regions
2137
+ if len(_regions_cache) > _REGIONS_CACHE_MAX:
2138
+ _regions_cache.popitem(last=False)
2139
+ return regions
2140
+
2141
+
2142
+ def _atomic_write(path, write):
2143
+ """Temp-sibling write, fsync, ``os.replace`` (same pattern as
2144
+ ``export._atomic_write`` — duplicated to avoid an import cycle)."""
2145
+ parent = os.path.dirname(os.path.abspath(path))
2146
+ os.makedirs(parent, exist_ok=True)
2147
+ fd, tmp = tempfile.mkstemp(dir=parent,
2148
+ prefix='.' + os.path.basename(path),
2149
+ suffix='.tmp')
2150
+ try:
2151
+ with os.fdopen(fd, 'wb') as fh:
2152
+ write(fh)
2153
+ fh.flush()
2154
+ os.fsync(fh.fileno())
2155
+ os.replace(tmp, path)
2156
+ except BaseException:
2157
+ try:
2158
+ os.unlink(tmp)
2159
+ except OSError:
2160
+ pass
2161
+ raise
2162
+
2163
+
2164
+ def export_document(document: PlotDocument, path: str, fmt: str,
2165
+ dpi: int = 300, *, transparent: bool = False):
2166
+ """Export *document* via the native figure to pdf/svg/png/tiff/jpeg.
2167
+
2168
+ Same figure and rc context as ``render_document`` (style scale 1);
2169
+ writes atomically. ``transparent`` applies only to png/svg: it drops
2170
+ the figure and axes background patches; other formats stay opaque.
2171
+ """
2172
+ document.validate()
2173
+ if fmt not in ('pdf', 'svg', 'png', 'tiff', 'jpeg'):
2174
+ raise PlotDocumentError(f"unsupported export format {fmt!r}")
2175
+ import matplotlib
2176
+ with matplotlib.rc_context(_deterministic_rc(document)):
2177
+ fig = None
2178
+ try:
2179
+ fig, ax = _build_figure(document, 1.0, document.width_mm,
2180
+ document.height_mm)
2181
+ _fit_figure(document, fig, ax)
2182
+
2183
+ def _write(fh):
2184
+ if fmt == 'svg':
2185
+ fig.savefig(fh, format=fmt, metadata={'Date': None},
2186
+ transparent=transparent)
2187
+ elif fmt == 'pdf':
2188
+ fig.savefig(fh, format=fmt, metadata={'Date': None})
2189
+ elif fmt == 'png':
2190
+ fig.savefig(fh, format=fmt, dpi=dpi,
2191
+ transparent=transparent)
2192
+ else:
2193
+ fig.savefig(fh, format=fmt, dpi=dpi)
2194
+
2195
+ _atomic_write(path, _write)
2196
+ finally:
2197
+ if fig is not None:
2198
+ fig.clear()
2199
+
2200
+
2201
+ FIT_PAD_MM = 0.5
2202
+ MIN_AXES_FRACTION = 0.2
2203
+
2204
+
2205
+ def _needs_fit(document) -> bool:
2206
+ """True when out-of-axes content requires ``_fit_axes`` margins.
2207
+
2208
+ Plain line documents (the historical path) keep their fixed
2209
+ ``axes_rect`` so their rendered bytes stay identical.
2210
+ """
2211
+ return (document.kind != 'line'
2212
+ or document.legend_location == 'outside right'
2213
+ or bool(document.annotations)
2214
+ or bool(document.brackets))
2215
+
2216
+
2217
+ _MATH_TOKEN = re.compile(r'\$[^$]*\$|\S+')
2218
+
2219
+
2220
+ def _text_length_px(artist, renderer, text):
2221
+ """Unrotated length of *text* drawn with *artist*'s font (px)."""
2222
+ if not text:
2223
+ return 0.0
2224
+ prev = artist.get_text()
2225
+ try:
2226
+ artist.set_text(text)
2227
+ bb = artist.get_window_extent(renderer)
2228
+ finally:
2229
+ artist.set_text(prev)
2230
+ rot = artist.get_rotation() % 180.0
2231
+ return bb.height if abs(rot - 90.0) < 1.0 else bb.width
2232
+
2233
+
2234
+ def _wrap_text(artist, renderer, limit_px):
2235
+ """Greedy word-wrap *artist* to *limit_px*; True when text changed.
2236
+
2237
+ The unwrapped source is cached on ``artist._ilm_unwrapped`` so
2238
+ repeated passes re-wrap from the original. Existing newlines are
2239
+ hard breaks; ``$...$`` math segments stay atomic and a single token
2240
+ longer than the limit keeps its own line (never split mid-word).
2241
+ """
2242
+ if limit_px <= 0:
2243
+ return False
2244
+ text = getattr(artist, '_ilm_unwrapped', None)
2245
+ if text is None:
2246
+ text = artist.get_text()
2247
+ artist._ilm_unwrapped = text
2248
+ if not text:
2249
+ return False
2250
+ lines = []
2251
+ for para in text.split('\n'):
2252
+ tokens = _MATH_TOKEN.findall(para)
2253
+ if not tokens:
2254
+ lines.append('')
2255
+ continue
2256
+ cur = ''
2257
+ for tok in tokens:
2258
+ cand = tok if not cur else cur + ' ' + tok
2259
+ if not cur \
2260
+ or _text_length_px(artist, renderer, cand) \
2261
+ <= limit_px:
2262
+ cur = cand
2263
+ else:
2264
+ lines.append(cur)
2265
+ cur = tok
2266
+ lines.append(cur)
2267
+ new = '\n'.join(lines)
2268
+ if new == artist.get_text():
2269
+ return False
2270
+ artist.set_text(new)
2271
+ return True
2272
+
2273
+
2274
+ def _title_limits(fig, ax, renderer):
2275
+ """(horizontal limit, vertical limit) in px for title wrapping."""
2276
+ W = fig.get_figwidth() * fig.dpi
2277
+ H = fig.get_figheight() * fig.dpi
2278
+ pos = ax.get_window_extent(renderer)
2279
+ return (max(pos.width, 0.5 * W), max(pos.height, 0.5 * H))
2280
+
2281
+
2282
+ def _wrap_axis_titles(fig, ax, renderer):
2283
+ """Wrap title(s) and axis labels to their axis extent; True if any
2284
+ text changed."""
2285
+ lim_x, lim_y = _title_limits(fig, ax, renderer)
2286
+ changed = False
2287
+ for artist in (ax.title, ax._left_title, ax._right_title,
2288
+ ax.xaxis.label):
2289
+ changed = _wrap_text(artist, renderer, lim_x) or changed
2290
+ changed = _wrap_text(ax.yaxis.label, renderer, lim_y) or changed
2291
+ return changed
2292
+
2293
+
2294
+ def _titles_overflow(fig, ax) -> bool:
2295
+ """True when any title/axis label's unwrapped length exceeds its
2296
+ wrap limit. No text is modified."""
2297
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
2298
+ FigureCanvasAgg(fig)
2299
+ renderer = fig.canvas.get_renderer()
2300
+ lim_x, lim_y = _title_limits(fig, ax, renderer)
2301
+ for artist in (ax.title, ax._left_title, ax._right_title,
2302
+ ax.xaxis.label, ax.yaxis.label):
2303
+ limit = lim_y if artist is ax.yaxis.label else lim_x
2304
+ text = getattr(artist, '_ilm_unwrapped', None)
2305
+ if text is None:
2306
+ text = artist.get_text()
2307
+ if _text_length_px(artist, renderer, text) > limit:
2308
+ return True
2309
+ return False
2310
+
2311
+
2312
+ def _fit_figure(document, fig, ax) -> None:
2313
+ """Fit the axes rectangle when the document needs it."""
2314
+ if _needs_fit(document) or _titles_overflow(fig, ax):
2315
+ _fit_axes(fig, ax, FIT_PAD_MM / 25.4 * fig.dpi)
2316
+
2317
+
2318
+ def _fit_axes(fig, ax, pad_px: float, fixed_y=None) -> None:
2319
+ """Reposition *ax* so nothing outside the axes box is clipped.
2320
+
2321
+ Iterates a margin-feedback loop: each pass shrinks the axes rect by the
2322
+ measured overflow of tick labels, axis labels, title, offset text and
2323
+ legend plus *pad_px*, until every edge moves under 0.05 px (or 4
2324
+ passes). Typography is never scaled down — when the text does not fit,
2325
+ the axes clamps to ``MIN_AXES_FRACTION`` of the figure instead.
2326
+
2327
+ ``fixed_y=(y0_px, y1_px)`` (bottom-origin px) pins the axes' vertical
2328
+ extent — used to share an axes frame across a row of cells — and only
2329
+ the horizontal fit is solved.
2330
+ """
2331
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
2332
+ FigureCanvasAgg(fig)
2333
+ renderer = fig.canvas.get_renderer()
2334
+ W = fig.get_figwidth() * fig.dpi
2335
+ H = fig.get_figheight() * fig.dpi
2336
+ for _ in range(6):
2337
+ wrapped = _wrap_axis_titles(fig, ax, renderer)
2338
+ pos = ax.get_window_extent(renderer)
2339
+ # Measure the out-of-axes extras first: only the artists that can
2340
+ # legitimately stick out (legend, brackets, annotations). Other
2341
+ # clip_on=False artists (stack value labels, legend packer
2342
+ # internals) report stale/degenerate extents mid-iteration and
2343
+ # would feed a bogus, frozen overflow back into the loop.
2344
+ from matplotlib.transforms import Bbox
2345
+ extras = []
2346
+ leg = ax.get_legend()
2347
+ if leg is not None and leg.get_visible():
2348
+ try:
2349
+ extras.append(leg.get_window_extent(renderer))
2350
+ except Exception:
2351
+ pass
2352
+ for artist in ax.findobj():
2353
+ gid = artist.get_gid() or ''
2354
+ if not gid.startswith(('ilmplot-bracket-',
2355
+ 'ilmplot-annotation-')):
2356
+ continue
2357
+ try:
2358
+ bb = artist.get_window_extent(renderer)
2359
+ except Exception:
2360
+ continue
2361
+ if math.isfinite(bb.x0) and math.isfinite(bb.y0) \
2362
+ and math.isfinite(bb.x1) and math.isfinite(bb.y1) \
2363
+ and (bb.width > 0 or bb.height > 0):
2364
+ extras.append(bb)
2365
+ # Wrapped titles/labels can exceed what get_tightbbox reports;
2366
+ # feed their measured extents so the margins account for them.
2367
+ for artist in (ax.title, ax._left_title, ax._right_title,
2368
+ ax.xaxis.label, ax.yaxis.label):
2369
+ if getattr(artist, '_ilm_unwrapped', None) is None \
2370
+ or artist.get_text() == artist._ilm_unwrapped:
2371
+ continue
2372
+ try:
2373
+ bb = artist.get_window_extent(renderer)
2374
+ except Exception:
2375
+ continue
2376
+ if math.isfinite(bb.x0) and math.isfinite(bb.y0) \
2377
+ and math.isfinite(bb.x1) and math.isfinite(bb.y1):
2378
+ extras.append(bb)
2379
+ tight = ax.get_tightbbox(renderer)
2380
+ if extras:
2381
+ union = Bbox.union(extras)
2382
+ tight = union if tight is None \
2383
+ else Bbox.union([tight, union])
2384
+ left = max(pos.x0 - tight.x0, 0.0)
2385
+ right = max(tight.x1 - pos.x1, 0.0)
2386
+ bottom = max(pos.y0 - tight.y0, 0.0)
2387
+ top = max(tight.y1 - pos.y1, 0.0)
2388
+ x0, x1 = left + pad_px, W - right - pad_px
2389
+ if x1 - x0 < MIN_AXES_FRACTION * W:
2390
+ spare = (1.0 - MIN_AXES_FRACTION) * W
2391
+ denom = left + pad_px + right + pad_px
2392
+ x0 = spare * ((left + pad_px) / denom) if denom > 0 else spare * 0.5
2393
+ x1 = x0 + MIN_AXES_FRACTION * W
2394
+ if fixed_y is not None:
2395
+ y0, y1 = fixed_y
2396
+ else:
2397
+ y0, y1 = bottom + pad_px, H - top - pad_px
2398
+ if y1 - y0 < MIN_AXES_FRACTION * H:
2399
+ spare = (1.0 - MIN_AXES_FRACTION) * H
2400
+ denom = bottom + pad_px + top + pad_px
2401
+ y0 = spare * ((bottom + pad_px) / denom) if denom > 0 else spare * 0.5
2402
+ y1 = y0 + MIN_AXES_FRACTION * H
2403
+ if not wrapped and max(abs(x0 - pos.x0), abs(x1 - pos.x1),
2404
+ abs(y0 - pos.y0), abs(y1 - pos.y1)) \
2405
+ < 0.05:
2406
+ break
2407
+ ax.set_position([x0 / W, y0 / H, (x1 - x0) / W, (y1 - y0) / H])
2408
+
2409
+
2410
+ def _fixed_y_px(fig, v_span_mm):
2411
+ """(top_mm, bottom_mm) from the figure top -> bottom-origin px span."""
2412
+ H = fig.get_figheight() * fig.dpi
2413
+ return (H - v_span_mm[1] / 25.4 * fig.dpi,
2414
+ H - v_span_mm[0] / 25.4 * fig.dpi)
2415
+
2416
+
2417
+ def _quantized_dims(width_mm, height_mm):
2418
+ w, h = float(width_mm), float(height_mm)
2419
+ for name, v in (('width_mm', w), ('height_mm', h)):
2420
+ if not math.isfinite(v) or v <= 0 or v > MAX_DIMENSION_MM:
2421
+ raise PlotDocumentError(
2422
+ f"{name}: expected 0 < value <= {MAX_DIMENSION_MM} mm, "
2423
+ f"got {v!r}")
2424
+ return round(w, 2), round(h, 2)
2425
+
2426
+
2427
+ def _quantized_v_span(v_span_mm, h):
2428
+ if v_span_mm is None:
2429
+ return None
2430
+ top, bottom = float(v_span_mm[0]), float(v_span_mm[1])
2431
+ if not (math.isfinite(top) and math.isfinite(bottom)
2432
+ and 0.0 <= top < bottom <= h):
2433
+ raise PlotDocumentError(
2434
+ f"v_span_mm: expected 0 <= top < bottom <= {h} mm, "
2435
+ f"got {v_span_mm!r}")
2436
+ return round(top, 2), round(bottom, 2)
2437
+
2438
+
2439
+ _measure_cache: OrderedDict = OrderedDict()
2440
+ _MEASURE_CACHE_MAX = 64
2441
+
2442
+
2443
+ def fit_plot_area(document: PlotDocument, width_mm: float,
2444
+ height_mm: float, *,
2445
+ pad_mm: float = FIT_PAD_MM) -> tuple:
2446
+ """Measure-only :func:`render_document_fitted` plot_area (no savefig)."""
2447
+ document.validate()
2448
+ w, h = _quantized_dims(width_mm, height_mm)
2449
+ key = ('measure', _canonical(document), w, h, float(pad_mm))
2450
+ hit = _measure_cache.get(key)
2451
+ if hit is not None:
2452
+ _measure_cache.move_to_end(key)
2453
+ return hit
2454
+
2455
+ import matplotlib
2456
+ with matplotlib.rc_context(_deterministic_rc(document)):
2457
+ fig = None
2458
+ try:
2459
+ fig, ax = _build_figure(document, 1.0, w, h)
2460
+ _fit_axes(fig, ax, float(pad_mm) / 25.4 * fig.dpi)
2461
+ pos = ax.get_position()
2462
+ finally:
2463
+ if fig is not None:
2464
+ fig.clear()
2465
+ area = (pos.x0, 1.0 - pos.y1, pos.x1, 1.0 - pos.y0)
2466
+ _measure_cache[key] = area
2467
+ if len(_measure_cache) > _MEASURE_CACHE_MAX:
2468
+ _measure_cache.popitem(last=False)
2469
+ return area
2470
+
2471
+
2472
+ def render_document_fitted(document: PlotDocument, width_mm: float,
2473
+ height_mm: float, *,
2474
+ pad_mm: float = FIT_PAD_MM,
2475
+ v_span_mm=None) -> PlotRender:
2476
+ """Render *document* at exactly ``width_mm`` × ``height_mm``.
2477
+
2478
+ Unlike :func:`render_document` the figure is sized to the target box
2479
+ (style scale 1.0, so point sizes are true) and the axes rectangle is
2480
+ solved by :func:`_fit_axes` so no text is clipped. ``v_span_mm`` may
2481
+ pin the axes' vertical extent to ``(top_mm, bottom_mm)`` measured from
2482
+ the figure's top edge (row-aligned axes frames). The embedded metadata
2483
+ carries the *original* document, not the fitted geometry.
2484
+ """
2485
+ document.validate()
2486
+ w, h = _quantized_dims(width_mm, height_mm)
2487
+ span = _quantized_v_span(v_span_mm, h)
2488
+ key = ('fitted', _canonical(document), w, h, float(pad_mm), span)
2489
+ hit = _render_cache.get(key)
2490
+ if hit is not None:
2491
+ _render_cache.move_to_end(key)
2492
+ return hit
2493
+
2494
+ import matplotlib
2495
+ with matplotlib.rc_context(_deterministic_rc(document)):
2496
+ fig = None
2497
+ try:
2498
+ fig, ax = _build_figure(document, 1.0, w, h)
2499
+ _fit_axes(fig, ax, float(pad_mm) / 25.4 * fig.dpi,
2500
+ _fixed_y_px(fig, span) if span is not None else None)
2501
+ pos = ax.get_position()
2502
+ buf = io.BytesIO()
2503
+ fig.savefig(buf, format='svg',
2504
+ metadata={'Date': None,
2505
+ 'Creator': 'ILM Plot Editor'})
2506
+ finally:
2507
+ if fig is not None:
2508
+ fig.clear()
2509
+
2510
+ svg = embed_metadata(
2511
+ _prepare_svg(buf.getvalue(), document.font_family), document)
2512
+ if len(svg) > MAX_FILE_BYTES:
2513
+ raise PlotDocumentError(
2514
+ f"rendered SVG exceeds {MAX_FILE_BYTES // (1024 * 1024)} MiB — "
2515
+ f"reduce the number of data points")
2516
+ render = PlotRender(
2517
+ svg=svg, plot_area=(pos.x0, 1.0 - pos.y1, pos.x1, 1.0 - pos.y0))
2518
+ _render_cache[key] = render
2519
+ if len(_render_cache) > _RENDER_CACHE_MAX:
2520
+ _render_cache.popitem(last=False)
2521
+ return render
2522
+
2523
+
2524
+ def save_document(document: PlotDocument, path: str) -> None:
2525
+ """Atomically write the self-contained ``*.ilmplot.svg`` for *document*.
2526
+
2527
+ Stdlib-only (temp sibling + fsync + ``os.replace``) so the core package
2528
+ does not depend on the ILM figpack module. The destination is never
2529
+ touched when rendering fails.
2530
+ """
2531
+ data = render_document(document).svg
2532
+ parent = os.path.dirname(os.path.abspath(path))
2533
+ os.makedirs(parent, exist_ok=True)
2534
+ fd, tmp = tempfile.mkstemp(dir=parent,
2535
+ prefix='.' + os.path.basename(path),
2536
+ suffix='.tmp')
2537
+ try:
2538
+ with os.fdopen(fd, 'wb') as fh:
2539
+ fh.write(data)
2540
+ fh.flush()
2541
+ os.fsync(fh.fileno())
2542
+ os.replace(tmp, path)
2543
+ except BaseException:
2544
+ try:
2545
+ os.unlink(tmp)
2546
+ except OSError:
2547
+ pass
2548
+ raise
2549
+
2550
+
2551
+ # ── file-backed document recognition (bounded cache) ─────────────────────
2552
+
2553
+ _file_doc_cache: OrderedDict = OrderedDict()
2554
+ _FILE_DOC_CACHE_MAX = 64
2555
+
2556
+
2557
+ def load_rendered_document(path: str) -> PlotDocument:
2558
+ """Load a document from *path*, cached on (path, size, mtime_ns).
2559
+
2560
+ Returns a clone — the cached instance is private so callers cannot
2561
+ mutate shared state.
2562
+ """
2563
+ from .document import load_document
2564
+ st = os.stat(path)
2565
+ key = (os.path.abspath(path), st.st_size, st.st_mtime_ns)
2566
+ hit = _file_doc_cache.get(key)
2567
+ if hit is not None:
2568
+ _file_doc_cache.move_to_end(key)
2569
+ return hit.clone()
2570
+ doc = load_document(path)
2571
+ _file_doc_cache[key] = doc
2572
+ if len(_file_doc_cache) > _FILE_DOC_CACHE_MAX:
2573
+ _file_doc_cache.popitem(last=False)
2574
+ return doc.clone()