plotpress 0.23.2__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.
plotpress/raster.py ADDED
@@ -0,0 +1,1316 @@
1
+ """Raster (PNG) backend via Pillow.
2
+
3
+ A second renderer that draws a Figure's primitives directly onto a Pillow canvas
4
+ (supersampled, then downscaled for antialiasing). Pillow ships as a pure wheel
5
+ on every platform, so PNG export needs no cairo/native SVG rasterizer. The
6
+ geometry mirrors :mod:`plotpress.svg` -- both consume the same transforms.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+
13
+ import numpy as np
14
+
15
+ from .artists import (
16
+ Annotation, Barbs, Bars, BoxPlot, Contour, ErrorBar, EventPlot, FillBetween,
17
+ FrameLine2D, FrameQuadMesh, Pie, Polygon, Quiver, ScatterCollection, Span,
18
+ Stem, Table, Text, Violin,
19
+ )
20
+ from .colors import colorbar_ticks, to_hex
21
+ # Which files can draw a given font stack is declared once, in fonts/families,
22
+ # next to which width table measures it -- see that module for why the two must
23
+ # be decided together. Imported under the old private names so anything
24
+ # monkeypatching this module keeps working.
25
+ from .fonts.families import HELVETICA_FILES as _HELVETICA_METRIC_FILES
26
+ from .fonts.families import HELVETICA_FILES_BOLD as _HELVETICA_METRIC_FILES_BOLD
27
+ from .fonts.families import font_files as _font_files
28
+ from .primitives import artist_to_prims
29
+ from .primitives import ImagePrim as PImage
30
+ from .primitives import Line as PLine
31
+ from .primitives import Markers as PMarkers
32
+ from .primitives import Path as PPath
33
+ from .primitives import PolygonBatch as PPolyBatch
34
+ from .primitives import Rect as PRect
35
+ from .primitives import Segments as PSegments
36
+ from .svg import (
37
+ _effective_rect, _group_axes_extra, _group_colorbar_extra, _group_colorbars,
38
+ _max_ytick_width, _pixel_rect,
39
+ _resolve_tick_labels,
40
+ )
41
+ from .ticker import log_ticks, nice_ticks
42
+ from .transform import LinearTransform
43
+
44
+ _DASH = {"-": None, "--": (6, 4), ":": (1, 3), "-.": (6, 3, 1, 3)}
45
+ _font_cache = {}
46
+ _PIL_H = {"left": "l", "center": "m", "right": "r"}
47
+ _PIL_V = {"baseline": "s", "center": "m", "top": "a", "bottom": "d"}
48
+
49
+
50
+ def _rgb(color):
51
+ c = to_hex(color).lstrip("#")
52
+ if len(c) == 3:
53
+ c = "".join(ch * 2 for ch in c)
54
+ return (int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16))
55
+
56
+
57
+ def _rgba(color, alpha=1.0):
58
+ return _rgb(color) + (int(round(alpha * 255)),)
59
+
60
+
61
+ def _composite_polygon(canvas, pts, rgba, outline=None, outline_width=1):
62
+ """Draw a filled polygon with correct alpha by compositing a bbox layer.
63
+
64
+ Drawing directly with an RGBA fill *replaces* pixels (alpha is then dropped
65
+ by the final RGB conversion), so translucent fills would render opaque.
66
+ Compositing a small transparent layer blends properly instead.
67
+ """
68
+ from PIL import Image as PILImage, ImageDraw
69
+
70
+ xs = [p[0] for p in pts]
71
+ ys = [p[1] for p in pts]
72
+ x0, y0 = int(math.floor(min(xs))), int(math.floor(min(ys)))
73
+ x1, y1 = int(math.ceil(max(xs))), int(math.ceil(max(ys)))
74
+ w, h = max(1, x1 - x0), max(1, y1 - y0)
75
+ layer = PILImage.new("RGBA", (w, h), (0, 0, 0, 0))
76
+ ldraw = ImageDraw.Draw(layer)
77
+ ldraw.polygon([(px - x0, py - y0) for px, py in pts], fill=rgba,
78
+ outline=outline, width=max(1, int(round(outline_width))))
79
+ canvas.alpha_composite(layer, (x0, y0))
80
+
81
+
82
+ def _font(size, family=None, bold=False):
83
+ """A Pillow font for ``size`` px, honoring ``family`` where the system has it.
84
+
85
+ Falls back to Pillow's built-in face when nothing else resolves, which keeps
86
+ headless machines rendering rather than failing.
87
+ """
88
+ from PIL import ImageFont
89
+
90
+ key = (int(round(size)), family, bool(bold))
91
+ if key in _font_cache:
92
+ return _font_cache[key]
93
+
94
+ font = None
95
+ for name in _font_files(family, bold):
96
+ try:
97
+ font = ImageFont.truetype(name, key[0])
98
+ break
99
+ except OSError:
100
+ continue # not installed here; try the next
101
+ if font is None:
102
+ try:
103
+ font = ImageFont.load_default(size=key[0])
104
+ except Exception:
105
+ # TypeError on very old Pillow (no size=); OSError/ImportError if the
106
+ # sized default needs FreeType and this build lacks it. The unsized
107
+ # bitmap default is the last resort that always exists.
108
+ font = ImageFont.load_default()
109
+ _font_cache[key] = font
110
+ return font
111
+
112
+
113
+ def figure_to_image(fig, scale=2, frame=0, animate_unit="main"):
114
+ """Render ``fig`` to a Pillow ``Image`` (RGB), supersampled by ``scale``.
115
+
116
+ ``frame``/``animate_unit`` select which frame of any ``plot_frames()``
117
+ series registered under ``animate_unit`` to draw (see :func:`save_gif`);
118
+ every other artist, and any ``FrameLine2D`` under a different slider
119
+ unit, is unaffected and renders as it always has.
120
+ """
121
+ from PIL import Image as PILImage, ImageDraw
122
+
123
+ fig._settle_layout()
124
+ dpi = fig.style.dpi
125
+ if not (dpi > 0):
126
+ # See the matching check in svg.figure_to_svg -- dpi is a plain,
127
+ # freely-mutable Style attribute, and a non-positive value produces
128
+ # an empty or negative-size image Pillow itself rejects with much
129
+ # less clarity (a bare "cannot write empty image").
130
+ raise ValueError(f"Figure.style.dpi must be > 0, got {dpi!r}")
131
+ W = int(round(fig.figsize[0] * dpi))
132
+ H = int(round(fig.figsize[1] * dpi))
133
+ S = max(1, int(scale))
134
+ canvas = PILImage.new("RGBA", (W * S, H * S), _rgba(fig.style.facecolor))
135
+ draw = ImageDraw.Draw(canvas)
136
+
137
+ for ax in fig.axes:
138
+ _raster_axes(ax, fig, W * S, H * S, S, draw, canvas, frame, animate_unit)
139
+ _raster_figtexts(fig, W * S, H * S, S, draw)
140
+ _raster_figure_legend(fig, fig.style, W * S, H * S, S, draw)
141
+ _raster_groups(fig, W * S, H * S, S, draw)
142
+
143
+ if S > 1:
144
+ canvas = canvas.resize((W, H), PILImage.LANCZOS)
145
+ return canvas.convert("RGB")
146
+
147
+
148
+ def save_png(fig, path, scale=2):
149
+ figure_to_image(fig, scale=scale).save(path, format="PNG")
150
+ return path
151
+
152
+
153
+ def save_gif(fig, path, fps=10, scale=2, slider_unit="main", label_frames=True):
154
+ """Animate a ``plot_frames()`` figure to a looping GIF via Pillow.
155
+
156
+ Every ``FrameLine2D``/``FrameQuadMesh`` series registered under
157
+ ``slider_unit`` (``"main"``, the figure's shared/global slider, by
158
+ default) is stepped through all of its frames and the results stitched
159
+ into a looping GIF -- the same data an interactive HTML slider scrubs
160
+ through, as a self-contained file. A series under a *different* slider
161
+ unit (an axes-local, non-shared ``plot_frames(..., shared=False)``) stays
162
+ on its own frame 0 throughout, since only one unit can drive the
163
+ animation at a time; pass its ``slider_group``/axes unit name to animate
164
+ that one instead.
165
+
166
+ ``label_frames`` stamps each frame with its slider value in the top-right
167
+ corner (``"{slider_label} = {value}"``) -- an interactive HTML shows this
168
+ right next to its slider, and a GIF has no slider to show it on, so
169
+ without a label an exported frame is anonymous about which one it is.
170
+ Pass ``False`` for bare frames.
171
+
172
+ Raises ``ValueError`` if the figure has no ``plot_frames()`` series
173
+ registered under ``slider_unit`` -- there is nothing to animate.
174
+ """
175
+ if slider_unit not in fig._sliders:
176
+ available = sorted(fig._sliders) or ["(none)"]
177
+ raise ValueError(
178
+ f"no plot_frames() series registered under slider_unit={slider_unit!r}; "
179
+ f"available slider units: {available}"
180
+ )
181
+ spec = fig._sliders[slider_unit]
182
+ frames = []
183
+ for f in range(spec["n"]):
184
+ im = figure_to_image(fig, scale=scale, frame=f, animate_unit=slider_unit)
185
+ if label_frames:
186
+ _label_frame(im, spec["label"], spec["values"][f])
187
+ frames.append(im)
188
+ duration_ms = max(1, round(1000.0 / fps))
189
+ frames[0].save(path, format="GIF", save_all=True, append_images=frames[1:],
190
+ duration=duration_ms, loop=0)
191
+ return path
192
+
193
+
194
+ def _label_frame(im, label, value):
195
+ """Stamp ``label = value`` in the top-right corner, in place."""
196
+ from PIL import ImageDraw
197
+
198
+ draw = ImageDraw.Draw(im)
199
+ text = f"{label} = {_format_slider_value(value)}"
200
+ pad = 10
201
+ _text(draw, im.width - pad, pad, text, (17, 17, 17), _font(13.0, None),
202
+ ha="right", va="top", outline=(255, 255, 255), stroke=2.5)
203
+
204
+
205
+ def _format_slider_value(v):
206
+ """An integral slider value (a day, a month) reads as ``5``, not ``5.0``."""
207
+ return str(int(v)) if float(v).is_integer() else f"{v:.3g}"
208
+
209
+
210
+ def save_pdf(fig, path):
211
+ """Vector PDF via svglib + reportlab (no cairo needed)."""
212
+ import io
213
+
214
+ try:
215
+ from reportlab.graphics import renderPDF
216
+ from svglib.svglib import svg2rlg
217
+ except ImportError as e:
218
+ raise RuntimeError(
219
+ "PDF export needs svglib + reportlab (standard dependencies); "
220
+ "reinstall plotpress to restore them"
221
+ ) from e
222
+ drawing = svg2rlg(io.StringIO(fig.to_svg()))
223
+ renderPDF.drawToFile(drawing, path)
224
+ return path
225
+
226
+
227
+ # -- axes -------------------------------------------------------------------
228
+ def _raster_spines(ax, L, T, Wp, Hp, S, draw):
229
+ """Per-side box outline -- the raster counterpart of ``svg._render_spines``."""
230
+ st = ax.style
231
+ edges = {
232
+ "top": (L, T, L + Wp, T), "bottom": (L, T + Hp, L + Wp, T + Hp),
233
+ "left": (L, T, L, T + Hp), "right": (L + Wp, T, L + Wp, T + Hp),
234
+ }
235
+ for side, (x0, y0, x1, y1) in edges.items():
236
+ spine = ax.spines[side]
237
+ if not spine.get_visible():
238
+ continue
239
+ color = spine._color if spine._color is not None else st.spine_color
240
+ width = spine._linewidth if spine._linewidth is not None else st.spine_width
241
+ fill = _rgba(color, spine._alpha) if spine._alpha is not None else _rgb(color)
242
+ draw.line([x0, y0, x1, y1], fill=fill,
243
+ width=max(1, int(round(width * S))))
244
+
245
+
246
+ def _raster_axes(ax, fig, W, H, S, draw, canvas, frame=0, animate_unit="main"):
247
+ st = ax.style
248
+ (xmin, xmax), (ymin, ymax) = ax._resolved_limits()
249
+ L, T, Wp, Hp = _effective_rect(ax, *_pixel_rect(ax, W, H), (xmin, xmax), (ymin, ymax))
250
+ xlim_t = (xmax, xmin) if ax._xinverted else (xmin, xmax)
251
+ ylim_t = (ymax, ymin) if ax._yinverted else (ymin, ymax)
252
+ tr = LinearTransform(xlim_t, ylim_t, (L, T, Wp, Hp),
253
+ xscale=ax._xscale, yscale=ax._yscale)
254
+
255
+ if not ax._visible:
256
+ return
257
+
258
+ if ax._is_colorbar:
259
+ _raster_colorbar(ax, tr, L, T, Wp, Hp, S, draw, canvas)
260
+ _raster_labels(ax, st, L, T, Wp, Hp, S, draw) # title only, mirrors the SVG backend
261
+ return
262
+
263
+ is_twin = ax._twin_of is not None
264
+ is_secondary = ax._secondary_of is not None
265
+ overlay = is_twin or is_secondary
266
+ if not overlay:
267
+ draw.rectangle([L, T, L + Wp, T + Hp], fill=_rgb(ax.get_facecolor()))
268
+
269
+ xticks = (ax._xticks if ax._xticks is not None else
270
+ (log_ticks(xmin, xmax) if ax._xscale == "log" else nice_ticks(xmin, xmax)))
271
+ yticks = (ax._yticks if ax._yticks is not None else
272
+ (log_ticks(ymin, ymax) if ax._yscale == "log" else nice_ticks(ymin, ymax)))
273
+
274
+ if ax._grid and not ax._axis_off and not overlay:
275
+ gc = _rgba(st.grid_color, ax._grid_alpha if ax._grid_alpha is not None
276
+ else st.grid_alpha)
277
+ gw = max(1, int(round(st.grid_width * S)))
278
+ for xt in xticks:
279
+ x = float(tr.x(xt))
280
+ if L <= x <= L + Wp:
281
+ draw.line([x, T, x, T + Hp], fill=gc, width=gw)
282
+ for yt in yticks:
283
+ y = float(tr.y(yt))
284
+ if T <= y <= T + Hp:
285
+ draw.line([L, y, L + Wp, y], fill=gc, width=gw)
286
+
287
+ # Artists go onto a scratch layer, and only the part of that layer inside
288
+ # the axes rect is composited back. This is the raster counterpart of the
289
+ # SVG backend's <clipPath>: without it the two backends disagree the moment
290
+ # any data falls outside the limits, and the PNG paints it across the rest
291
+ # of the figure -- over neighbouring subplots, labels and the legend.
292
+ _clip_artists(ax, tr, st, S, canvas, (L, T, Wp, Hp), frame, animate_unit)
293
+
294
+ if not ax._axis_off:
295
+ if is_twin:
296
+ _raster_twin_ticks(ax, st, tr, xticks, yticks, L, T, Wp, Hp, S, draw)
297
+ elif is_secondary:
298
+ xst = st.copy(**ax._tick_overrides["x"]) if ax._tick_overrides["x"] else st
299
+ yst = st.copy(**ax._tick_overrides["y"]) if ax._tick_overrides["y"] else st
300
+ is_x = ax._secondary_dim == "x"
301
+ _raster_ticks(ax, xst, yst, tr, xticks if is_x else [], yticks if not is_x else [],
302
+ L, T, Wp, Hp, S, draw,
303
+ xside=ax._xtick_side, yside=ax._ytick_side)
304
+ else:
305
+ xst = st.copy(**ax._tick_overrides["x"]) if ax._tick_overrides["x"] else st
306
+ yst = st.copy(**ax._tick_overrides["y"]) if ax._tick_overrides["y"] else st
307
+ _raster_ticks(ax, xst, yst, tr, xticks, yticks, L, T, Wp, Hp, S, draw,
308
+ xside=ax._xtick_side, yside=ax._ytick_side)
309
+ if ax._minor_ticks_on:
310
+ from .ticker import minor_ticks
311
+ mxst = (xst.copy(**ax._minor_tick_overrides["x"])
312
+ if ax._minor_tick_overrides["x"] else xst)
313
+ myst = (yst.copy(**ax._minor_tick_overrides["y"])
314
+ if ax._minor_tick_overrides["y"] else yst)
315
+ xminor = (ax._xticks_minor if ax._xticks_minor is not None
316
+ else minor_ticks(xticks, xmin, xmax, ax._xscale))
317
+ yminor = (ax._yticks_minor if ax._yticks_minor is not None
318
+ else minor_ticks(yticks, ymin, ymax, ax._yscale))
319
+ _raster_minor_ticks(mxst, myst, tr, xminor, yminor, L, T, Wp, Hp, S, draw,
320
+ xside=ax._xtick_side, yside=ax._ytick_side)
321
+ _raster_spines(ax, L, T, Wp, Hp, S, draw)
322
+ if not is_twin:
323
+ _raster_labels(ax, st, L, T, Wp, Hp, S, draw)
324
+ if ax._show_legend:
325
+ _raster_legend(ax, st, L, T, Wp, Hp, S, draw)
326
+
327
+
328
+ def _draw_prim(p, S, draw, canvas):
329
+ """Draw one backend-agnostic primitive onto the raster canvas."""
330
+ if isinstance(p, PImage):
331
+ if p.w <= 0 or p.h <= 0:
332
+ return
333
+ from PIL import Image as PILImage
334
+ im = PILImage.fromarray(p.rgba, "RGBA").resize(
335
+ (max(1, int(round(p.w))), max(1, int(round(p.h)))), PILImage.NEAREST)
336
+ canvas.alpha_composite(im, (int(round(p.x)), int(round(p.y))))
337
+ return
338
+ if isinstance(p, PMarkers):
339
+ finite = np.isfinite(p.points).all(axis=1)
340
+ edge = _rgb(p.edgecolor) if (p.edgecolor and p.edgewidth > 0) else None
341
+ ew = max(1, int(round(p.edgewidth))) if edge else 1
342
+ for (cx, cy), dm, col, ok in zip(p.points, p.diameters, p.colors, finite):
343
+ if ok:
344
+ rad = dm / 2.0
345
+ draw.ellipse([cx - rad, cy - rad, cx + rad, cy + rad],
346
+ fill=_rgba(col, p.alpha), outline=edge, width=ew)
347
+ return
348
+ if isinstance(p, PLine):
349
+ # Same stroke_opacity-dropped-on-the-floor bug as PPath below (axvline/
350
+ # axhline/axline share this prim).
351
+ col = _rgba(p.stroke, p.stroke_opacity) if p.stroke_opacity < 1 else _rgb(p.stroke)
352
+ _polyline(draw, np.array([p.p0, p.p1]), col,
353
+ max(1, int(round(p.stroke_width * S))), _DASH.get(p.linestyle))
354
+ elif isinstance(p, PRect):
355
+ pts = [(p.x, p.y), (p.x + p.w, p.y), (p.x + p.w, p.y + p.h), (p.x, p.y + p.h)]
356
+ _composite_polygon(canvas, pts, _rgba(p.fill, p.fill_opacity))
357
+ elif isinstance(p, PSegments):
358
+ # Same fix (hlines/vlines share this prim).
359
+ col = _rgba(p.stroke, p.stroke_opacity) if p.stroke_opacity < 1 else _rgb(p.stroke)
360
+ w = max(1, int(round(p.stroke_width * S)))
361
+ dash = _DASH.get(p.linestyle)
362
+ for a, b, c, d in p.segs:
363
+ _polyline(draw, np.array([[a, b], [c, d]]), col, w, dash)
364
+ elif isinstance(p, PPolyBatch):
365
+ outline = _rgb(p.edge) if p.edge else None
366
+ al = int(round(p.alpha * 255))
367
+ for verts, fc in zip(p.polys, p.fills):
368
+ pts = [tuple(v) for v in verts]
369
+ rgba = (_rgb(fc) if isinstance(fc, str)
370
+ else (int(fc[0]), int(fc[1]), int(fc[2]))) + (al,)
371
+ _composite_polygon(canvas, pts, rgba, outline=outline,
372
+ outline_width=p.edge_width * S)
373
+ elif isinstance(p, PPath):
374
+ if p.fill:
375
+ pts = [tuple(v) for sub in p.subpaths for v in sub if np.isfinite(v).all()]
376
+ if len(pts) >= 3:
377
+ # Regression: the outline drew at PIL's own default width (1px)
378
+ # regardless of stroke_width -- fill_between()/fill()'s own
379
+ # edgecolor rendered, but linewidth had no visible effect at
380
+ # all in PNG/PDF output, only in SVG.
381
+ outline = _rgb(p.stroke) if p.stroke else None
382
+ _composite_polygon(canvas, pts, _rgba(p.fill, p.fill_opacity),
383
+ outline=outline, outline_width=p.stroke_width * S)
384
+ else:
385
+ # Regression: stroke_opacity was carried on the prim (SVG already
386
+ # emits stroke-opacity from it) but never read here, so plot()
387
+ # and every line-based method built on it (step, ecdfplot, the
388
+ # psd/csd/cohere/spectrum family, xcorr/acorr) rendered fully
389
+ # opaque in PNG/PDF regardless of alpha=, agreeing with SVG only
390
+ # by accident when alpha happened to be 1.0.
391
+ col = (_rgba(p.stroke, p.stroke_opacity) if p.stroke_opacity < 1
392
+ else _rgb(p.stroke))
393
+ w = max(1, int(round(p.stroke_width * S)))
394
+ dash = _DASH.get(p.linestyle)
395
+ for sub in p.subpaths:
396
+ _polyline(draw, sub, col, w, dash)
397
+
398
+
399
+ def _clip_artists(ax, tr, st, S, canvas, rect, frame=0, animate_unit="main"):
400
+ """Draw ``ax``'s artists clipped to ``rect`` = (left, top, w, h) in pixels.
401
+
402
+ Pillow has no clip region, so the artists are drawn onto a transparent layer
403
+ the size of the canvas and only the rect is composited back. The layer is
404
+ cached on the canvas and cleared per axes rather than reallocated, because a
405
+ figure can carry hundreds of axes and a full-canvas RGBA allocation each
406
+ time dominates the render.
407
+ """
408
+ from PIL import Image as PILImage, ImageDraw
409
+
410
+ L, T, Wp, Hp = rect
411
+ box = (max(0, int(math.floor(L))), max(0, int(math.floor(T))),
412
+ min(canvas.size[0], int(math.ceil(L + Wp))),
413
+ min(canvas.size[1], int(math.ceil(T + Hp))))
414
+ if box[2] <= box[0] or box[3] <= box[1]:
415
+ return
416
+
417
+ layer = getattr(canvas, "_plotpress_layer", None)
418
+ if layer is None or layer.size != canvas.size:
419
+ layer = PILImage.new("RGBA", canvas.size, (0, 0, 0, 0))
420
+ canvas._plotpress_layer = layer
421
+ ldraw = ImageDraw.Draw(layer)
422
+
423
+ # Stable sort by zorder (ties keep call order), matching svg.py's draw order.
424
+ for artist in sorted(ax.artists, key=lambda a: a.zorder):
425
+ _raster_artist(artist, tr, st, S, ldraw, layer, rect, frame, animate_unit)
426
+
427
+ canvas.alpha_composite(layer.crop(box), (box[0], box[1]))
428
+ # Clear only what was used, so the next axes starts from transparent.
429
+ layer.paste((0, 0, 0, 0), box)
430
+
431
+
432
+ def _raster_artist(artist, tr, st, S, draw, canvas, clip, frame=0, animate_unit="main"):
433
+ prims = artist_to_prims(artist, tr, 0, 0, size_scale=st.dpi / 72.0 * S)
434
+ if prims is not None:
435
+ for p in prims:
436
+ _draw_prim(p, S, draw, canvas)
437
+ return
438
+ if isinstance(artist, FrameLine2D):
439
+ # Only the unit being animated steps through its frames; a FrameLine2D
440
+ # under any other slider unit stays on frame 0, same as static output.
441
+ # linestyle="none" -- nothing to draw, and unlike the SVG backend a
442
+ # static bitmap has no persistent element id a later frame needs to
443
+ # find, so it's safe to just skip the draw call outright.
444
+ if artist.linestyle == "none":
445
+ return
446
+ f = frame if artist.slider_unit == animate_unit else 0
447
+ f = min(f, artist.n_frames - 1)
448
+ x0, y0 = artist.frame_xy(f)
449
+ _polyline(draw, tr.xy(x0, y0), _rgb(artist.color),
450
+ max(1, int(round(artist.linewidth * S))),
451
+ _DASH.get(artist.linestyle))
452
+ elif isinstance(artist, FrameQuadMesh):
453
+ # Same animate_unit rule as FrameLine2D above -- substitute that
454
+ # frame's own fully-realized QuadMesh so this hits the ordinary
455
+ # QuadMesh path in artist_to_prims, image flip/orientation included.
456
+ f = frame if artist.slider_unit == animate_unit else 0
457
+ f = min(f, artist.n_frames - 1)
458
+ mesh_prims = artist_to_prims(artist.frame_mesh(f), tr, 0, 0,
459
+ size_scale=st.dpi / 72.0 * S)
460
+ for p in mesh_prims or []:
461
+ _draw_prim(p, S, draw, canvas)
462
+ elif isinstance(artist, Bars):
463
+ _bars(artist, tr, S, draw)
464
+ elif isinstance(artist, Stem):
465
+ _stem(artist, tr, st, S, draw)
466
+ elif isinstance(artist, ErrorBar):
467
+ _errorbar(artist, tr, st, S, draw)
468
+ elif isinstance(artist, EventPlot):
469
+ _eventplot(artist, tr, S, draw)
470
+ elif isinstance(artist, Quiver):
471
+ _quiver(artist, tr, S, draw)
472
+ elif isinstance(artist, Barbs):
473
+ _barbs(artist, tr, st, S, draw)
474
+ elif isinstance(artist, Contour):
475
+ for lvl, color, segs in artist.line_segments:
476
+ fill = (_rgba(color, artist.alpha) if artist.alpha < 1 else _rgb(color))
477
+ for a, b, c, e in segs:
478
+ draw.line([float(tr.x(a)), float(tr.y(b)),
479
+ float(tr.x(c)), float(tr.y(e))],
480
+ fill=fill, width=max(1, int(round(1.2 * S))))
481
+ elif isinstance(artist, Pie):
482
+ _pie(artist, tr, st, S, draw)
483
+ elif isinstance(artist, BoxPlot):
484
+ _boxplot(artist, tr, st, S, draw)
485
+ elif isinstance(artist, Violin):
486
+ _violin(artist, tr, draw)
487
+ elif isinstance(artist, Text):
488
+ from .svg import _axes_fraction_xy, _bbox_pad, text_box
489
+
490
+ if artist.axes_fraction:
491
+ x, y = _axes_fraction_xy(tr, artist.x, artist.y)
492
+ else:
493
+ x, y = float(tr.x(artist.x)), float(tr.y(artist.y))
494
+ if artist.bbox is not None:
495
+ # text_box measures in unscaled pixels (font metrics don't know
496
+ # about the raster scale factor); scale the padded box to canvas.
497
+ box = _bbox_pad(text_box(x / S, y / S, artist.text, artist.size,
498
+ artist.ha, artist.va, st,
499
+ bold=artist.bold, italic=artist.italic),
500
+ artist.bbox)
501
+ _raster_bbox(draw, [c * S for c in box], artist.bbox, S)
502
+ fill = _rgba(artist.color, artist.alpha) if artist.alpha < 1 else _rgb(artist.color)
503
+ _text(draw, x, y, artist.text,
504
+ fill, _font(artist.size * S, st.font_family, bold=artist.bold),
505
+ artist.ha, artist.va, artist.rotation,
506
+ _rgb(artist.outline) if artist.outline else None,
507
+ artist.size * 0.15 * S, italic=artist.italic)
508
+ elif isinstance(artist, Annotation):
509
+ from .svg import _axes_fraction_xy, _bbox_pad, leader_anchor, text_box
510
+
511
+ if artist.axes_fraction:
512
+ tx, ty = _axes_fraction_xy(tr, artist.xytext[0], artist.xytext[1])
513
+ else:
514
+ tx, ty = float(tr.x(artist.xytext[0])), float(tr.y(artist.xytext[1]))
515
+ # The box is measured in unscaled pixels, so scale it to this canvas.
516
+ box = text_box(tx / S, ty / S, artist.text, artist.size,
517
+ artist.ha, artist.va, st, bold=artist.bold, italic=artist.italic)
518
+ if artist.bbox is not None:
519
+ box = _bbox_pad(box, artist.bbox) # the leader below anchors to this, padded, edge
520
+ if artist.arrowprops is not None:
521
+ px, py = float(tr.x(artist.xy[0])), float(tr.y(artist.xy[1]))
522
+ col = (artist.arrowprops.get("color", artist.color)
523
+ if isinstance(artist.arrowprops, dict) else artist.color)
524
+ a = (artist.arrowprops.get("alpha", 1.0)
525
+ if isinstance(artist.arrowprops, dict) else 1.0)
526
+ # Same attachment rule as the SVG backend -- see svg.leader_anchor.
527
+ sx, sy = leader_anchor(box, (px / S, py / S))
528
+ _quiver_arrow(draw, sx * S, sy * S, px, py,
529
+ _rgba(col, a) if a < 1 else _rgb(col), S)
530
+ if artist.bbox is not None:
531
+ _raster_bbox(draw, [c * S for c in box], artist.bbox, S)
532
+ fill = (_rgba(artist.color, artist.alpha) if artist.alpha < 1
533
+ else _rgb(artist.color))
534
+ _text(draw, tx, ty, artist.text, fill,
535
+ _font(artist.size * S, st.font_family, bold=artist.bold),
536
+ artist.ha, artist.va, 0.0,
537
+ _rgb(artist.outline) if artist.outline else None,
538
+ artist.size * 0.15 * S, italic=artist.italic)
539
+ elif isinstance(artist, Table):
540
+ _raster_table(artist, tr, st, S, draw)
541
+
542
+
543
+ def _raster_table(t, tr, st, S, draw):
544
+ """``ax.table()`` -- mirrors svg._render_table's own cell layout exactly
545
+ (same header logic, same fill-color precedence) so the two backends
546
+ agree; see there for the shape of ``t.cell_colors``/``row_colors``/
547
+ ``col_colors``."""
548
+ from .svg import _axes_fraction_xy
549
+
550
+ x0, y0, w, h = t.bbox
551
+ left, bottom = _axes_fraction_xy(tr, x0, y0)
552
+ right, top = _axes_fraction_xy(tr, x0 + w, y0 + h)
553
+ rect_w, rect_h = right - left, bottom - top
554
+
555
+ has_col_header = t.col_labels is not None
556
+ has_row_header = t.row_labels is not None
557
+ body_rows = t.cell_text
558
+ n_data_rows = len(body_rows)
559
+ n_data_cols = len(body_rows[0]) if body_rows else (len(t.col_labels) if has_col_header else 0)
560
+ n_rows = n_data_rows + (1 if has_col_header else 0)
561
+ n_cols = n_data_cols + (1 if has_row_header else 0)
562
+ if n_rows == 0 or n_cols == 0:
563
+ return
564
+ cell_w, cell_h = rect_w / n_cols, rect_h / n_rows
565
+ fs = (t.fontsize if t.fontsize is not None else st.tick_label_size) * S
566
+ row0 = 1 if has_col_header else 0
567
+ col0 = 1 if has_row_header else 0
568
+ font = _font(fs, st.font_family, bold=False)
569
+ bold_font = _font(fs, st.font_family, bold=True)
570
+ edge = _rgb("#888888")
571
+
572
+ def cell_fill(r, c):
573
+ if has_col_header and r == 0 and c >= col0 and t.col_colors:
574
+ i = c - col0
575
+ if i < len(t.col_colors):
576
+ return t.col_colors[i]
577
+ if has_row_header and c == 0 and r >= row0 and t.row_colors:
578
+ i = r - row0
579
+ if i < len(t.row_colors):
580
+ return t.row_colors[i]
581
+ if r >= row0 and c >= col0 and t.cell_colors:
582
+ ri, ci = r - row0, c - col0
583
+ if ri < len(t.cell_colors) and ci < len(t.cell_colors[ri]):
584
+ return t.cell_colors[ri][ci]
585
+ return "#ffffff"
586
+
587
+ def cell_text(r, c):
588
+ if has_col_header and r == 0:
589
+ return "" if (c == 0 and has_row_header) else t.col_labels[c - col0]
590
+ if has_row_header and c == 0:
591
+ return t.row_labels[r - row0]
592
+ return body_rows[r - row0][c - col0]
593
+
594
+ for r in range(n_rows):
595
+ for c in range(n_cols):
596
+ cx0, cy0 = left + c * cell_w, top + r * cell_h
597
+ fill = cell_fill(r, c)
598
+ draw.rectangle(
599
+ [cx0, cy0, cx0 + cell_w, cy0 + cell_h],
600
+ fill=_rgba(fill, t.alpha) if t.alpha < 1 else _rgb(fill),
601
+ outline=edge, width=max(1, int(round(0.75 * S))))
602
+ text = cell_text(r, c)
603
+ if text:
604
+ draw.text((cx0 + cell_w / 2.0, cy0 + cell_h / 2.0), text,
605
+ fill=_rgb(st.text_color),
606
+ font=bold_font if (r < row0 or c < col0) else font,
607
+ anchor="mm")
608
+
609
+
610
+ # -- primitives -------------------------------------------------------------
611
+ def _polyline(draw, pts, color, width, dash=None):
612
+ mask = np.isfinite(pts).all(axis=1)
613
+ n = len(pts)
614
+ i = 0
615
+ while i < n:
616
+ if not mask[i]:
617
+ i += 1
618
+ continue
619
+ j = i
620
+ while j < n and mask[j]:
621
+ j += 1
622
+ seg = [tuple(p) for p in pts[i:j]]
623
+ if len(seg) >= 2:
624
+ if dash:
625
+ _dashed(draw, seg, color, width, dash)
626
+ else:
627
+ draw.line(seg, fill=color, width=width, joint="curve")
628
+ i = j
629
+
630
+
631
+ def _dashed(draw, seg, color, width, dash):
632
+ on = True
633
+ di = 0
634
+ remaining = dash[0]
635
+ for (x0, y0), (x1, y1) in zip(seg[:-1], seg[1:]):
636
+ seglen = math.hypot(x1 - x0, y1 - y0)
637
+ pos = 0.0
638
+ while pos < seglen:
639
+ step = min(remaining, seglen - pos)
640
+ t0, t1 = pos / seglen, (pos + step) / seglen
641
+ if on:
642
+ draw.line([x0 + (x1 - x0) * t0, y0 + (y1 - y0) * t0,
643
+ x0 + (x1 - x0) * t1, y0 + (y1 - y0) * t1],
644
+ fill=color, width=width)
645
+ pos += step
646
+ remaining -= step
647
+ if remaining <= 1e-6:
648
+ di = (di + 1) % len(dash)
649
+ remaining = dash[di]
650
+ on = not on
651
+
652
+
653
+ def _bars(bars, tr, S, draw):
654
+ for i in range(len(bars.pos)):
655
+ p, ln, th, ba = bars.pos[i], bars.length[i], bars.thickness[i], bars.base[i]
656
+ if bars.orientation == "vertical":
657
+ x0, x1 = float(tr.x(p - th / 2)), float(tr.x(p + th / 2))
658
+ y0, y1 = float(tr.y_base(ba)), float(tr.y_base(ba + ln))
659
+ else:
660
+ y0, y1 = float(tr.y(p - th / 2)), float(tr.y(p + th / 2))
661
+ x0, x1 = float(tr.x_base(ba)), float(tr.x_base(ba + ln))
662
+ box = [min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1)]
663
+ outline = _rgb(bars.edgecolor) if bars.edgecolor else None
664
+ draw.rectangle(box, fill=_rgba(bars.colors[i], bars.alpha), outline=outline,
665
+ width=max(1, int(round(bars.linewidth * S))) if outline else 1)
666
+
667
+
668
+ def _stem(stem, tr, st, S, draw):
669
+ if stem.x.size == 0:
670
+ return
671
+ y0 = float(tr.y_base(stem.baseline))
672
+ xb, yb = tr.x(stem.x), tr.y(stem.y)
673
+ for x, y in zip(xb, yb):
674
+ draw.line([float(x), y0, float(x), float(y)], fill=_rgb(stem.linecolor),
675
+ width=max(1, int(round(1.2 * S))))
676
+ draw.line([float(tr.x(stem.x.min())), y0, float(tr.x(stem.x.max())), y0],
677
+ fill=_rgb(st.spine_color), width=max(1, int(round(0.8 * S))))
678
+ r = st.marker_size / 2.0 * st.dpi / 72.0 * S
679
+ for x, y in zip(xb, yb):
680
+ draw.ellipse([x - r, y - r, x + r, y + r], fill=_rgb(stem.markercolor))
681
+
682
+
683
+ def _errorbar(eb, tr, st, S, draw):
684
+ xb, yb = tr.x(eb.x), tr.y(eb.y)
685
+ col = _rgb(eb.color)
686
+ ecol = _rgb(eb.ecolor)
687
+ ew = max(1, int(round(eb.elinewidth * S)))
688
+ cw = max(1, int(round(eb.capthick * S)))
689
+ if eb.linestyle and eb.linestyle != "none":
690
+ _polyline(draw, np.column_stack([xb, yb]), col,
691
+ max(1, int(round(eb.linewidth * S))))
692
+ cap = eb.capsize * S
693
+ if eb.yerr is not None:
694
+ ylo, yhi = tr.y_base(eb.y - eb.yerr), tr.y_base(eb.y + eb.yerr)
695
+ for x, a, b in zip(xb, ylo, yhi):
696
+ draw.line([x, a, x, b], fill=ecol, width=ew)
697
+ draw.line([x - cap, a, x + cap, a], fill=ecol, width=cw)
698
+ draw.line([x - cap, b, x + cap, b], fill=ecol, width=cw)
699
+ if eb.xerr is not None:
700
+ # Regression: this branch didn't exist at all -- errorbar(xerr=...)
701
+ # (and, composed on top of it, barh()'s own xerr) drew nothing in
702
+ # PNG/PDF output, only in SVG (see svg._render_errorbar, which
703
+ # already has both branches).
704
+ xlo, xhi = tr.x_base(eb.x - eb.xerr), tr.x_base(eb.x + eb.xerr)
705
+ for y, a, b in zip(yb, xlo, xhi):
706
+ draw.line([a, y, b, y], fill=ecol, width=ew)
707
+ draw.line([a, y - cap, a, y + cap], fill=ecol, width=cw)
708
+ draw.line([b, y - cap, b, y + cap], fill=ecol, width=cw)
709
+ r = eb.markersize / 2.0 * st.dpi / 72.0 * S
710
+ for x, y in zip(xb, yb):
711
+ if np.isfinite(x) and np.isfinite(y): # see svg._render_errorbar
712
+ draw.ellipse([x - r, y - r, x + r, y + r], fill=col)
713
+
714
+
715
+ def _eventplot(ev, tr, S, draw):
716
+ half = ev.linelength / 2.0
717
+ col = _rgba(ev.color, ev.alpha) if ev.alpha < 1 else _rgb(ev.color)
718
+ for row, off in zip(ev.rows, ev.offsets):
719
+ if ev.orientation == "horizontal":
720
+ y0, y1 = float(tr.y(off - half)), float(tr.y(off + half))
721
+ for e in row:
722
+ x = float(tr.x(e))
723
+ draw.line([x, y0, x, y1], fill=col, width=max(1, int(round(1.2 * S))))
724
+ else:
725
+ x0, x1 = float(tr.x(off - half)), float(tr.x(off + half))
726
+ for e in row:
727
+ y = float(tr.y(e))
728
+ draw.line([x0, y, x1, y], fill=col, width=max(1, int(round(1.2 * S))))
729
+
730
+
731
+ def _quiver(q, tr, S, draw):
732
+ tx, ty = q.tips()
733
+ x0, y0, x1, y1 = tr.x(q.X), tr.y(q.Y), tr.x(tx), tr.y(ty)
734
+ col = _rgba(q.color, q.alpha) if q.alpha < 1 else _rgb(q.color)
735
+ hl = 5.0 * S
736
+ w = max(1, int(round(1.2 * S)))
737
+ for bx, by, ex, ey in zip(x0, y0, x1, y1):
738
+ draw.line([bx, by, ex, ey], fill=col, width=w)
739
+ ang = math.atan2(ey - by, ex - bx)
740
+ for da in (-math.radians(25), math.radians(25)):
741
+ draw.line([ex, ey, ex - hl * math.cos(ang + da), ey - hl * math.sin(ang + da)],
742
+ fill=col, width=w)
743
+
744
+
745
+ def _barbs(b, tr, st, S, draw):
746
+ """Mirrors svg._render_barbs -- same shared geometry (svg._barb_geometry/
747
+ _barb_angles), so the two backends draw identical barbs."""
748
+ from .svg import _barb_angles, _barb_geometry
749
+
750
+ L = b.length * st.dpi / 72.0 * S
751
+ cx, cy = tr.x(b.X), tr.y(b.Y)
752
+ mag, ang = _barb_angles(b, tr)
753
+ col = _rgba(b.color, b.alpha) if b.alpha < 1 else _rgb(b.color)
754
+ w = max(1, int(round(1.2 * S)))
755
+ r = 0.12 * L
756
+ for x, y, spd, a in zip(cx, cy, mag, ang):
757
+ lines, polys, calm = _barb_geometry(float(x), float(y), float(a), float(spd), L)
758
+ if calm:
759
+ draw.ellipse([x - r, y - r, x + r, y + r], outline=col, width=w)
760
+ continue
761
+ for x0, y0, x1, y1 in lines:
762
+ draw.line([x0, y0, x1, y1], fill=col, width=w)
763
+ for poly in polys:
764
+ draw.polygon(poly, fill=col)
765
+
766
+
767
+ def _pie(pie, tr, st, S, draw):
768
+ cx = tr.px_left + tr.px_w / 2.0
769
+ cy = tr.px_top + tr.px_h / 2.0
770
+ R = 0.42 * min(tr.px_w, tr.px_h) * pie.radius
771
+ box = [cx - R, cy - R, cx + R, cy + R]
772
+ font = _font(10 * S, st.font_family) # matches svg's fixed 10px
773
+ txt_fill = _rgb(st.text_color)
774
+ ang = math.radians(pie.startangle)
775
+ for i, frac in enumerate(pie.fracs):
776
+ a1 = ang - frac * 2 * math.pi
777
+ draw.pieslice(box, -math.degrees(ang), -math.degrees(a1),
778
+ fill=_rgba(pie.colors[i], pie.alpha), outline=(255, 255, 255),
779
+ width=max(1, int(round(1.5 * S))))
780
+ am = (ang + a1) / 2.0
781
+ if pie.labels is not None:
782
+ lx, ly = cx + 1.15 * R * math.cos(am), cy - 1.15 * R * math.sin(am)
783
+ ha = "left" if math.cos(am) >= 0 else "right"
784
+ _text(draw, lx, ly, str(pie.labels[i]), txt_fill, font, ha, "center")
785
+ pct = pie.pct_text(frac)
786
+ if pct is not None:
787
+ px, py = cx + 0.6 * R * math.cos(am), cy - 0.6 * R * math.sin(am)
788
+ _text(draw, px, py, pct, txt_fill, font, "center", "center")
789
+ ang = a1
790
+
791
+
792
+ def _boxplot(bp, tr, st, S, draw):
793
+ col = _rgba(bp.color, bp.alpha) if bp.alpha < 1 else _rgb(bp.color)
794
+ w = max(1, int(round(1.3 * S)))
795
+ wm = max(1, int(round(1.8 * S)))
796
+ r = st.marker_size / 2.0 * st.dpi / 72.0 * S
797
+ for pos, s in zip(bp.positions, bp.stats):
798
+ c0, c1 = pos - bp.width / 2, pos + bp.width / 2
799
+ if bp.orientation == "vertical":
800
+ x0, x1 = float(tr.x(c0)), float(tr.x(c1))
801
+ xc = float(tr.x(pos))
802
+ yq1, yq3, ym = float(tr.y(s["q1"])), float(tr.y(s["q3"])), float(tr.y(s["med"]))
803
+ ylo, yhi = float(tr.y(s["lo"])), float(tr.y(s["hi"]))
804
+ draw.rectangle([min(x0, x1), min(yq1, yq3), max(x0, x1), max(yq1, yq3)],
805
+ outline=col, width=w)
806
+ draw.line([x0, ym, x1, ym], fill=col, width=wm)
807
+ draw.line([xc, yq1, xc, ylo], fill=col, width=S)
808
+ draw.line([xc, yq3, xc, yhi], fill=col, width=S)
809
+ draw.line([x0, ylo, x1, ylo], fill=col, width=S)
810
+ draw.line([x0, yhi, x1, yhi], fill=col, width=S)
811
+ for fx in s["fliers"]:
812
+ fy = float(tr.y(fx))
813
+ draw.ellipse([xc - r, fy - r, xc + r, fy + r], outline=col, width=S)
814
+ else:
815
+ y0, y1 = float(tr.y(c0)), float(tr.y(c1))
816
+ yc = float(tr.y(pos))
817
+ xq1, xq3, xm = float(tr.x(s["q1"])), float(tr.x(s["q3"])), float(tr.x(s["med"]))
818
+ xlo, xhi = float(tr.x(s["lo"])), float(tr.x(s["hi"]))
819
+ draw.rectangle([min(xq1, xq3), min(y0, y1), max(xq1, xq3), max(y0, y1)],
820
+ outline=col, width=w)
821
+ draw.line([xm, y0, xm, y1], fill=col, width=wm)
822
+ draw.line([xq1, yc, xlo, yc], fill=col, width=S)
823
+ draw.line([xq3, yc, xhi, yc], fill=col, width=S)
824
+ draw.line([xlo, y0, xlo, y1], fill=col, width=S)
825
+ draw.line([xhi, y0, xhi, y1], fill=col, width=S)
826
+ for fx in s["fliers"]:
827
+ fxx = float(tr.x(fx))
828
+ draw.ellipse([fxx - r, yc - r, fxx + r, yc + r], outline=col, width=S)
829
+
830
+
831
+ def _violin(v, tr, draw):
832
+ for pos, grid, hw in zip(v.positions, v.grids, v.halfwidths):
833
+ if v.orientation == "vertical":
834
+ left = np.column_stack([tr.x(pos - hw), tr.y(grid)])
835
+ right = np.column_stack([tr.x(pos + hw)[::-1], tr.y(grid)[::-1]])
836
+ else:
837
+ left = np.column_stack([tr.x(grid), tr.y(pos - hw)])
838
+ right = np.column_stack([tr.x(grid)[::-1], tr.y(pos + hw)[::-1]])
839
+ poly = [tuple(p) for p in np.vstack([left, right])]
840
+ draw.polygon(poly, fill=_rgba(v.color, v.alpha), outline=_rgb(v.color))
841
+
842
+
843
+ def _raster_bbox(draw, box, bbox, S):
844
+ """The raster counterpart of ``svg._bbox_svg`` -- a rect (or rounded rect)
845
+ drawn behind a label. ``box`` is already padded and scaled to this canvas.
846
+ """
847
+ x0, y0, x1, y1 = box
848
+ fill = _rgba(bbox["facecolor"], bbox["alpha"])
849
+ edge = _rgb(bbox["edgecolor"]) if bbox["edgecolor"] not in (None, "none") else None
850
+ width = max(1, int(round(bbox["linewidth"] * S)))
851
+ if bbox["boxstyle"] == "round":
852
+ radius = min(8.0 * S, (x1 - x0) / 2.0, (y1 - y0) / 2.0)
853
+ draw.rounded_rectangle([x0, y0, x1, y1], radius=radius, fill=fill,
854
+ outline=edge, width=width)
855
+ else:
856
+ draw.rectangle([x0, y0, x1, y1], fill=fill, outline=edge, width=width)
857
+
858
+
859
+ #: Faux-italic slant, as a fraction of glyph height -- there is no italic font
860
+ #: *file* in the bundled/installed registry (see fonts/families.py), only a
861
+ #: regular/bold split, so italic is a horizontal shear applied to the rendered
862
+ #: glyphs rather than a different face. Matches roughly what real italic faces
863
+ #: lean (~8-12 degrees); SVG gets a real ``font-style="italic"`` instead,
864
+ #: since a browser resolves an actual italic system face for that.
865
+ _ITALIC_SHEAR = 0.20
866
+
867
+
868
+ def _text(draw, x, y, s, fill, font, ha="left", va="baseline", rotation=0.0,
869
+ outline=None, stroke=0.0, italic=False):
870
+ """Draw text, optionally with a contrasting halo (see svg._text_svg).
871
+
872
+ Pillow's ``stroke_width`` paints the rim under the glyph exactly as SVG's
873
+ ``paint-order="stroke"`` does, so the two backends agree. Multi-line ``s``
874
+ (containing ``\\n``) is Pillow's own job -- ``ImageDraw.text`` detects the
875
+ newline and switches to its multiline layout automatically, anchor and
876
+ all, so nothing extra is needed here for that case.
877
+ """
878
+ kw = {}
879
+ if outline and stroke >= 1.0:
880
+ kw = {"stroke_width": int(round(stroke)), "stroke_fill": outline}
881
+ anchor = _PIL_H.get(ha, "l") + _PIL_V.get(va, "s")
882
+ if italic and not rotation:
883
+ # No rotation to fight with, so this can (and should) honor ha/va
884
+ # exactly rather than falling back to the rotated path's "anchor is
885
+ # always the image center" approximation below.
886
+ from PIL import Image as PILImage, ImageDraw
887
+
888
+ abbox = draw.textbbox((0, 0), s, font=font, anchor=anchor)
889
+ w, h = abbox[2] - abbox[0], abbox[3] - abbox[1]
890
+ pad = 4 + int(round(stroke))
891
+ # The anchor point itself, in this padded canvas's local coordinates --
892
+ # drawing *at* that point with the same anchor is a tautology (that is
893
+ # what "anchor" means), so it is exactly the position drawn at below.
894
+ ax_local, ay_local = pad - abbox[0], pad - abbox[1]
895
+ tmp = PILImage.new("RGBA", (max(1, w + 2 * pad), max(1, h + 2 * pad)),
896
+ (0, 0, 0, 0))
897
+ ImageDraw.Draw(tmp).text((ax_local, ay_local), s, fill=fill, font=font,
898
+ anchor=anchor, **kw)
899
+ shear_pad = int(round(tmp.height * _ITALIC_SHEAR))
900
+ tmp = tmp.transform(
901
+ (tmp.width + shear_pad, tmp.height), PILImage.AFFINE,
902
+ (1, -_ITALIC_SHEAR, shear_pad, 0, 1, 0), resample=PILImage.BICUBIC)
903
+ # Track the anchor point through that same forward shear (the AFFINE
904
+ # data above is the inverse, output->input, map PIL actually applies).
905
+ ax_final = ax_local + _ITALIC_SHEAR * ay_local - shear_pad
906
+ draw._image.alpha_composite(tmp, (int(round(x - ax_final)), int(round(y - ay_local))))
907
+ return
908
+ if rotation:
909
+ from PIL import Image as PILImage, ImageDraw
910
+
911
+ bbox = draw.textbbox((0, 0), s, font=font)
912
+ w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
913
+ pad = 4 + int(round(stroke))
914
+ tmp = PILImage.new("RGBA", (max(1, w + 2 * pad), max(1, h + 2 * pad)),
915
+ (0, 0, 0, 0))
916
+ ImageDraw.Draw(tmp).text((pad - bbox[0], pad - bbox[1]), s, fill=fill,
917
+ font=font, **kw)
918
+ if italic:
919
+ # Rotated italic keeps the same "anchor is the image center"
920
+ # approximation the plain rotated case already uses below --
921
+ # tracking an anchor point through shear *and* an expand=True
922
+ # rotation exactly isn't worth the complexity for a combination
923
+ # this rare.
924
+ shear_pad = int(round(tmp.height * _ITALIC_SHEAR))
925
+ tmp = tmp.transform(
926
+ (tmp.width + shear_pad, tmp.height), PILImage.AFFINE,
927
+ (1, -_ITALIC_SHEAR, shear_pad, 0, 1, 0), resample=PILImage.BICUBIC)
928
+ tmp = tmp.rotate(rotation, expand=True) # PIL & matplotlib: CCW positive
929
+ draw._image.alpha_composite(tmp, (int(x - tmp.width / 2), int(y - tmp.height / 2)))
930
+ return
931
+ draw.text((x, y), s, fill=fill, font=font, anchor=anchor, **kw)
932
+
933
+
934
+ def _quiver_arrow(draw, x0, y0, x1, y1, col, S):
935
+ w = max(1, int(round(1.2 * S)))
936
+ draw.line([x0, y0, x1, y1], fill=col, width=w)
937
+ ang = math.atan2(y1 - y0, x1 - x0)
938
+ hl = 7.0 * S
939
+ for da in (-0.4, 0.4):
940
+ draw.line([x1, y1, x1 - hl * math.cos(ang + da), y1 - hl * math.sin(ang + da)],
941
+ fill=col, width=w)
942
+
943
+
944
+ # -- furniture --------------------------------------------------------------
945
+ def _raster_ticks(ax, xst, yst, tr, xticks, yticks, L, T, Wp, Hp, S, draw,
946
+ xside="bottom", yside="left"):
947
+ xts = xst.tick_size * S
948
+ xcol = _rgb(xst.spine_color)
949
+ xfs = xst.tick_label_size * S
950
+ xfont = _font(xfs, xst.font_family)
951
+ xtw = max(1, int(round(xst.tick_width * S)))
952
+ yts = yst.tick_size * S
953
+ ycol = _rgb(yst.spine_color)
954
+ yfs = yst.tick_label_size * S
955
+ yfont = _font(yfs, yst.font_family)
956
+ ytw = max(1, int(round(yst.tick_width * S)))
957
+ xlabels = _resolve_tick_labels(ax._xticklabels, xticks)
958
+ ylabels = _resolve_tick_labels(ax._yticklabels, yticks)
959
+ x_top = xside == "top"
960
+ x_axis = T if x_top else T + Hp
961
+ x_sign = -1 if x_top else 1
962
+ y_right = yside == "right"
963
+ y_axis = L + Wp if y_right else L
964
+ y_sign = 1 if y_right else -1
965
+ for xt, lab in zip(xticks, xlabels):
966
+ x = float(tr.x(xt))
967
+ draw.line([x, x_axis, x, x_axis + x_sign * xts], fill=xcol, width=xtw)
968
+ ly = x_axis + x_sign * (xts + 1)
969
+ draw.text((x, ly), lab, fill=_rgb(xst.text_color), font=xfont,
970
+ anchor=("md" if x_top else "ma"))
971
+ for yt, lab in zip(yticks, ylabels):
972
+ y = float(tr.y(yt))
973
+ draw.line([y_axis, y, y_axis + y_sign * yts, y], fill=ycol, width=ytw)
974
+ lx = y_axis + y_sign * (yts + 2)
975
+ draw.text((lx, y), lab, fill=_rgb(yst.text_color), font=yfont,
976
+ anchor=("lm" if y_right else "rm"))
977
+
978
+
979
+ def _raster_minor_ticks(xst, yst, tr, xticks, yticks, L, T, Wp, Hp, S, draw,
980
+ xside="bottom", yside="left"):
981
+ """Unlabeled minor tick marks -- the raster counterpart of the SVG one."""
982
+ xts = xst.tick_size * 0.6 * S
983
+ xcol = _rgb(xst.spine_color)
984
+ xtw = max(1, int(round(xst.tick_width * S)))
985
+ yts = yst.tick_size * 0.6 * S
986
+ ycol = _rgb(yst.spine_color)
987
+ ytw = max(1, int(round(yst.tick_width * S)))
988
+ x_top = xside == "top"
989
+ x_axis = T if x_top else T + Hp
990
+ x_sign = -1 if x_top else 1
991
+ y_right = yside == "right"
992
+ y_axis = L + Wp if y_right else L
993
+ y_sign = 1 if y_right else -1
994
+ for xt in xticks:
995
+ x = float(tr.x(xt))
996
+ draw.line([x, x_axis, x, x_axis + x_sign * xts], fill=xcol, width=xtw)
997
+ for yt in yticks:
998
+ y = float(tr.y(yt))
999
+ draw.line([y_axis, y, y_axis + y_sign * yts, y], fill=ycol, width=ytw)
1000
+
1001
+
1002
+ def _raster_twin_ticks(ax, st, tr, xticks, yticks, L, T, Wp, Hp, S, draw):
1003
+ col = _rgb(st.spine_color)
1004
+ ts = st.tick_size * S
1005
+ fs = st.tick_label_size * S
1006
+ font = _font(fs, st.font_family)
1007
+ tw = max(1, int(round(st.tick_width * S)))
1008
+ if ax._twin_shared == "x": # twinx: y-axis on the RIGHT
1009
+ xr = L + Wp
1010
+ for yt, lab in zip(yticks, _resolve_tick_labels(ax._yticklabels, yticks)):
1011
+ y = float(tr.y(yt))
1012
+ draw.line([xr, y, xr + ts, y], fill=col, width=tw)
1013
+ draw.text((xr + ts + 2, y), lab, fill=_rgb(st.text_color),
1014
+ font=font, anchor="lm")
1015
+ if ax._ylabel:
1016
+ lx = xr + ts + (_max_ytick_width(ax, st) + st.label_size + 4) * S
1017
+ _vtext(draw, ax._ylabel, lx, T + Hp / 2.0,
1018
+ _rgb(st.text_color), _font(st.label_size * S, st.font_family))
1019
+ else: # twiny: x-axis on the TOP
1020
+ for xt, lab in zip(xticks, _resolve_tick_labels(ax._xticklabels, xticks)):
1021
+ x = float(tr.x(xt))
1022
+ draw.line([x, T, x, T - ts], fill=col, width=tw)
1023
+ draw.text((x, T - ts - 1), lab, fill=_rgb(st.text_color),
1024
+ font=font, anchor="md")
1025
+ if ax._xlabel:
1026
+ draw.text((L + Wp / 2.0, T - ts - fs - st.label_size * S),
1027
+ ax._xlabel, fill=_rgb(st.text_color),
1028
+ font=_font(st.label_size * S, st.font_family), anchor="md")
1029
+
1030
+
1031
+ def _raster_labels(ax, st, L, T, Wp, Hp, S, draw):
1032
+ cx = L + Wp / 2.0
1033
+ ts, fs = st.tick_size, st.tick_label_size
1034
+ if ax._xlabel and not ax._axis_off:
1035
+ # Overrides (align_xlabels) are stamped in 1x figure-pixel space, like
1036
+ # the SVG backend's -- scale to this backend's supersampled space.
1037
+ if ax._xlabel_y_override is not None:
1038
+ y = ax._xlabel_y_override * S
1039
+ elif ax._xtick_side == "top":
1040
+ y = T - (ts + fs + st.label_size) * S
1041
+ else:
1042
+ y = T + Hp + (ts + fs + st.label_size + 4) * S
1043
+ draw.text((cx, y), ax._xlabel, fill=_rgb(st.text_color),
1044
+ font=_font(st.label_size * S, st.font_family), anchor="mm")
1045
+ if ax._ylabel and not ax._axis_off:
1046
+ # Mirror svg._render_labels exactly: clear the *measured* tick labels.
1047
+ # Substituting the tick font size for their width put this up to ~9px
1048
+ # from where the SVG draws it, jammed against the figure edge.
1049
+ if ax._ylabel_x_override is not None:
1050
+ lx = ax._ylabel_x_override * S
1051
+ elif ax._ytick_side == "right":
1052
+ lx = L + Wp + (ts + _max_ytick_width(ax, st) + st.label_size + 4) * S
1053
+ else:
1054
+ lx = L - (ts + _max_ytick_width(ax, st) + st.label_size + 4) * S
1055
+ _vtext(draw, ax._ylabel, lx, T + Hp / 2.0,
1056
+ _rgb(st.text_color), _font(st.label_size * S, st.font_family))
1057
+ if ax._title:
1058
+ # Pillow refuses a bottom/baseline anchor on multiline text, and the
1059
+ # title is the only label that uses one -- so a "\n" in a title raised
1060
+ # ValueError and took the whole PNG export with it, where every other
1061
+ # label merely broke the line. Stack the lines by hand instead. A
1062
+ # single-line title takes the same path and lands exactly where it did.
1063
+ from .svg import twiny_headroom
1064
+
1065
+ size = ax._title_size or st.title_size
1066
+ font = _font(size * S, st.font_family)
1067
+ line_h = size * 1.2 * S
1068
+ top = T - (8 + twiny_headroom(ax, st)) * S
1069
+ for i, line in enumerate(reversed(ax._title.split("\n"))):
1070
+ draw.text((cx, top - i * line_h), line,
1071
+ fill=_rgb(st.text_color), font=font, anchor="mb")
1072
+
1073
+
1074
+ def _vtext(draw, text, x, y, fill, font):
1075
+ """Draw vertical (rotated 90°) text centered at (x, y)."""
1076
+ from PIL import Image as PILImage, ImageDraw
1077
+
1078
+ bbox = draw.textbbox((0, 0), text, font=font)
1079
+ w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
1080
+ tmp = PILImage.new("RGBA", (max(1, w + 4), max(1, h + 4)), (0, 0, 0, 0))
1081
+ ImageDraw.Draw(tmp).text((2 - bbox[0], 2 - bbox[1]), text, fill=fill, font=font)
1082
+ tmp = tmp.rotate(90, expand=True)
1083
+ draw._image.alpha_composite(
1084
+ tmp, (int(x - tmp.width / 2), int(y - tmp.height / 2)))
1085
+
1086
+
1087
+ _LEGEND_ANCHORS = {
1088
+ "upper right": (1.0, 0.0), "upper left": (0.0, 0.0),
1089
+ "lower left": (0.0, 1.0), "lower right": (1.0, 1.0),
1090
+ "upper center": (0.5, 0.0), "lower center": (0.5, 1.0),
1091
+ "center left": (0.0, 0.5), "center right": (1.0, 0.5),
1092
+ "right": (1.0, 0.5), "center": (0.5, 0.5), "best": (1.0, 0.0),
1093
+ }
1094
+
1095
+
1096
+ def _raster_legend(ax, st, L, T, Wp, Hp, S, draw):
1097
+ # Regression: this recomputed entries/fontsize independently of
1098
+ # svg.py's _legend_layout() instead of reusing it, so legend(handles=,
1099
+ # fontsize=) rendered correctly in SVG but was silently ignored here --
1100
+ # every entry always came straight from ax.artists at the style's own
1101
+ # fixed size, in both backends, the same class of bug as the marker
1102
+ # color list, errorbar xerr, and polygon outline width fixes before it.
1103
+ source = ax._legend_handles if ax._legend_handles is not None else ax.artists
1104
+ # not in (None, "") rather than truthiness: label=0/0.0/False is a
1105
+ # legitimate label (matplotlib shows it as "0"), not an opt-out the way
1106
+ # None/"" is -- matches svg.py's own legend_entries()/_legend_layout().
1107
+ entries = [a for a in source if getattr(a, "label", None) not in (None, "")]
1108
+ if not entries:
1109
+ return
1110
+ fs = (ax._legend_fontsize if ax._legend_fontsize is not None else st.tick_label_size) * S
1111
+ font = _font(fs, st.font_family)
1112
+ title_font = _font(fs, st.font_family, bold=True) # SVG draws the title bold
1113
+ line_h = fs + 6 * S
1114
+ sample = 22 * S
1115
+ pad = 6 * S
1116
+ ncol = min(max(1, ax._legend_ncol), len(entries))
1117
+ nrows = (len(entries) + ncol - 1) // ncol
1118
+ # PIL's own draw.textlength()/draw.text() need an actual str -- unlike
1119
+ # svg.py's _esc(), which stringifies internally -- so a bare int/float
1120
+ # label (a common loop-variable accident) must be coerced here.
1121
+ tw = max(draw.textlength(str(a.label), font=font) for a in entries)
1122
+ col_w = sample + tw + pad * 2
1123
+ title = ax._legend_title
1124
+ title_h = line_h if title else 0
1125
+ box_w = col_w * ncol + pad
1126
+ if title:
1127
+ box_w = max(box_w, draw.textlength(title, font=title_font) + pad * 2)
1128
+ box_h = line_h * nrows + pad + title_h
1129
+
1130
+ fx, fy = _LEGEND_ANCHORS.get(ax._legend_loc, (1.0, 0.0))
1131
+ if ax._legend_bbox_to_anchor is not None:
1132
+ # Mirrors svg.py's own _legend_origin() -- see there for why this
1133
+ # is a corner-at-a-point placement, not the plain-loc inset below.
1134
+ ax_x, ax_y = ax._legend_bbox_to_anchor
1135
+ anchor_x = L + ax_x * Wp
1136
+ anchor_y = T + (1.0 - ax_y) * Hp
1137
+ bx, by = anchor_x - fx * box_w, anchor_y - fy * box_h
1138
+ else:
1139
+ bx = L + 6 * S + fx * max(0.0, Wp - box_w - 12 * S)
1140
+ by = T + 6 * S + fy * max(0.0, Hp - box_h - 12 * S)
1141
+ _raster_draw_legend(entries, st, S, draw, bx, by, box_w, box_h, ncol, col_w,
1142
+ line_h, sample, pad, title, title_h, font, title_font,
1143
+ ax._legend_framealpha)
1144
+
1145
+
1146
+ def _raster_figure_legend(fig, st, W, H, S, draw):
1147
+ """The PNG counterpart of svg._render_figure_legend.
1148
+
1149
+ Geometry comes from the *shared* layout in svg.py rather than from Pillow's
1150
+ own measurements, because figure.py already reserved a band of the canvas
1151
+ using those numbers -- measuring again here could size the box to something
1152
+ the reservation does not match.
1153
+ """
1154
+ from .svg import figure_legend_layout, figure_legend_origin
1155
+
1156
+ lay = figure_legend_layout(fig)
1157
+ if lay is None:
1158
+ return
1159
+ spec = fig._figure_legend
1160
+ pad_px = spec["pad"] * min(W / S, H / S) + 4
1161
+ bx, by = figure_legend_origin(spec, lay, W / S, H / S, pad_px)
1162
+ fs = lay["fs"] * S
1163
+ _raster_draw_legend(
1164
+ lay["entries"], st, S, draw, bx * S, by * S,
1165
+ lay["box_w"] * S, lay["box_h"] * S, lay["ncol"], lay["col_w"] * S,
1166
+ lay["line_h"] * S, lay["sample_w"] * S, lay["pad"] * S,
1167
+ lay["title"], lay["title_h"] * S,
1168
+ _font(fs, st.font_family), _font(fs, st.font_family, bold=True),
1169
+ lay["framealpha"])
1170
+
1171
+
1172
+ def _raster_draw_legend(entries, st, S, draw, bx, by, box_w, box_h, ncol, col_w,
1173
+ line_h, sample, pad, title, title_h, font, title_font,
1174
+ framealpha=0.85):
1175
+ """Paint a legend box whose geometry has already been decided.
1176
+
1177
+ Regression: this box was always fully opaque (255, 255, 255) regardless
1178
+ of the SVG backend's own fill-opacity="0.85" on the same box -- the two
1179
+ backends drew a different-looking legend for the same figure. Both now
1180
+ read the same framealpha (see Axes.legend/Figure.legend).
1181
+ """
1182
+ draw.rectangle([bx, by, bx + box_w, by + box_h], fill=_rgba("#ffffff", framealpha),
1183
+ outline=(204, 204, 204))
1184
+ if title:
1185
+ draw.text((bx + box_w / 2, by + pad), title, fill=_rgb(st.text_color),
1186
+ font=title_font, anchor="ma")
1187
+ for i, a in enumerate(entries):
1188
+ r, c = divmod(i, ncol)
1189
+ sx = bx + pad + c * col_w
1190
+ ry = by + pad + title_h + line_h * r + line_h / 2.0
1191
+ if isinstance(a, Bars):
1192
+ color = _rgb(a.colors[0] if a.colors else "#333333")
1193
+ else:
1194
+ color = _rgb(getattr(a, "color", None)
1195
+ or getattr(a, "linecolor", None) or "#333333")
1196
+ if isinstance(a, ScatterCollection):
1197
+ rr = 4 * S
1198
+ draw.ellipse([sx + sample / 2 - rr, ry - rr, sx + sample / 2 + rr, ry + rr], fill=color)
1199
+ elif isinstance(a, (Bars, FillBetween, Span, Polygon)):
1200
+ # SVG gives this swatch the artist's fill-opacity; composite the
1201
+ # same alpha over the box's white background so the two backends
1202
+ # agree on how a translucent fill reads in the legend.
1203
+ alpha = getattr(a, "alpha", 1.0) if isinstance(
1204
+ a, (FillBetween, Span, Polygon)) else 1.0
1205
+ swatch = tuple(int(round(c * alpha + 255 * (1.0 - alpha)))
1206
+ for c in color[:3])
1207
+ draw.rectangle([sx, ry - 5 * S, sx + sample, ry + 5 * S], fill=swatch)
1208
+ else:
1209
+ # Match svg.draw_legend: the swatch carries the artist's dash
1210
+ # pattern, so a dashed reference line is not drawn as a solid one.
1211
+ width = max(1, int(round(2 * S)))
1212
+ dash = _DASH.get(getattr(a, "linestyle", "-"))
1213
+ seg = [(sx, ry), (sx + sample, ry)]
1214
+ if dash:
1215
+ _dashed(draw, seg, color, width, tuple(d * S for d in dash))
1216
+ else:
1217
+ draw.line([sx, ry, sx + sample, ry], fill=color, width=width)
1218
+ draw.text((sx + sample + pad, ry), str(a.label), fill=_rgb(st.text_color),
1219
+ font=font, anchor="lm")
1220
+
1221
+
1222
+ def _raster_figtexts(fig, W, H, S, draw):
1223
+ st = fig.style
1224
+ if fig._suptitle:
1225
+ t = fig._suptitle
1226
+ size = (t.get("size") or st.title_size * 1.5) * S
1227
+ draw.text((W / 2, 6 * S), t["text"], fill=_rgb(st.text_color),
1228
+ font=_font(size, st.font_family, bold=True), anchor="ma")
1229
+ if fig._supxlabel:
1230
+ t = fig._supxlabel
1231
+ size = (t.get("size") or st.label_size * 1.2) * S
1232
+ draw.text((W / 2, H - 6 * S), t["text"], fill=_rgb(st.text_color),
1233
+ font=_font(size, st.font_family), anchor="md")
1234
+ if fig._supylabel:
1235
+ t = fig._supylabel
1236
+ size = (t.get("size") or st.label_size * 1.2) * S
1237
+ _vtext(draw, t["text"], 6 * S + size / 2, H / 2, _rgb(st.text_color), _font(size, st.font_family))
1238
+ for t in fig._fig_texts:
1239
+ from .svg import _bbox_pad, text_box
1240
+
1241
+ size = (t["size"] or st.font_size) * S
1242
+ x, y = t["x"] * W, (1.0 - t["y"]) * H
1243
+ anchor = _PIL_H.get(t["ha"], "l") + _PIL_V.get(t["va"], "s")
1244
+ bbox = t.get("bbox")
1245
+ if bbox is not None:
1246
+ box = _bbox_pad(text_box(x / S, y / S, t["s"], t["size"] or st.font_size,
1247
+ t["ha"], t["va"], st), bbox)
1248
+ _raster_bbox(draw, [c * S for c in box], bbox, S)
1249
+ alpha = t.get("alpha", 1.0)
1250
+ color = t["color"] or st.text_color
1251
+ fill = _rgba(color, alpha) if alpha < 1 else _rgb(color)
1252
+ draw.text((x, y), t["s"], fill=fill,
1253
+ font=_font(size, st.font_family), anchor=anchor)
1254
+
1255
+
1256
+ def _raster_groups(fig, W, H, S, draw):
1257
+ """The PNG counterpart of svg._render_groups."""
1258
+ st = fig.style
1259
+ for g in fig._groups:
1260
+ members = g["axes"] + _group_colorbars(g["axes"], fig)
1261
+ rects = [_pixel_rect(ax, W, H) for ax in members]
1262
+ extras = [_group_colorbar_extra(ax, st) if ax._is_colorbar
1263
+ else _group_axes_extra(ax, st) for ax in members]
1264
+ pad_l, pad_r, pad_t, pad_b = (v * S for v in g["pad"])
1265
+ x0 = min(r[0] - e[2] * S for r, e in zip(rects, extras)) - pad_l
1266
+ y0 = min(r[1] - e[0] * S for r, e in zip(rects, extras)) - pad_t
1267
+ x1 = max(r[0] + r[2] + e[3] * S for r, e in zip(rects, extras)) + pad_r
1268
+ y1 = max(r[1] + r[3] + e[1] * S for r, e in zip(rects, extras)) + pad_b
1269
+ pts = np.array([[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]])
1270
+ # linestyle="none" -- an invisible box (title only), same as every
1271
+ # other line-drawing method, not a solid border falling through
1272
+ # _DASH.get() unmatched.
1273
+ if g["linestyle"] != "none":
1274
+ dash = _DASH.get(g["linestyle"])
1275
+ dash_scaled = tuple(d * S for d in dash) if dash else None
1276
+ _polyline(draw, pts, _rgb(g["color"]),
1277
+ max(1, int(round(g["linewidth"] * S))), dash_scaled)
1278
+ size = (g["fontsize"] or fig.style.title_size) * S
1279
+ font = _font(size, fig.style.font_family, bold=True)
1280
+ color = _rgb(g["color"])
1281
+ pos = g["title_position"]
1282
+ if pos == "top":
1283
+ draw.text(((x0 + x1) / 2, y0 - 6 * S), g["title"], fill=color,
1284
+ font=font, anchor="md")
1285
+ elif pos == "bottom":
1286
+ draw.text(((x0 + x1) / 2, y1 + 6 * S), g["title"], fill=color,
1287
+ font=font, anchor="ma")
1288
+ elif pos == "left":
1289
+ draw.text((x0 - 6 * S, (y0 + y1) / 2), g["title"], fill=color,
1290
+ font=font, anchor="rm")
1291
+ else:
1292
+ draw.text((x1 + 6 * S, (y0 + y1) / 2), g["title"], fill=color,
1293
+ font=font, anchor="lm")
1294
+
1295
+
1296
+ def _raster_colorbar(ax, tr, L, T, Wp, Hp, S, draw, canvas):
1297
+ from PIL import Image as PILImage
1298
+
1299
+ src = ax._cbar_source
1300
+ lut = src.lut
1301
+ grad = np.flipud(lut).reshape(-1, 1, 3).astype(np.uint8)
1302
+ alpha = np.full((grad.shape[0], 1, 1), 255, np.uint8)
1303
+ rgba = np.concatenate([grad, alpha], axis=2)
1304
+ im = PILImage.fromarray(rgba, "RGBA").resize(
1305
+ (max(1, int(Wp)), max(1, int(Hp))), PILImage.BILINEAR)
1306
+ canvas.alpha_composite(im, (int(L), int(T)))
1307
+ draw.rectangle([L, T, L + Wp, T + Hp], outline=_rgb(ax.style.spine_color),
1308
+ width=max(1, int(round(ax.style.spine_width * S))))
1309
+ st = ax.style
1310
+ _, fracs, tlabels = colorbar_ticks(src.norm)
1311
+ font = _font(st.tick_label_size * S, st.font_family)
1312
+ for frac, lab in zip(fracs, tlabels):
1313
+ y = T + (1 - frac) * Hp
1314
+ draw.line([L + Wp, y, L + Wp + st.tick_size * S, y], fill=_rgb(st.spine_color), width=S)
1315
+ draw.text((L + Wp + st.tick_size * S + 2, y), lab, fill=_rgb(st.text_color),
1316
+ font=font, anchor="lm")