rgrid-python 4.5.3__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.
- grid_py/__init__.py +340 -0
- grid_py/_arrow.py +331 -0
- grid_py/_clippath.py +170 -0
- grid_py/_colour.py +815 -0
- grid_py/_coords.py +1534 -0
- grid_py/_curve.py +1668 -0
- grid_py/_display_list.py +507 -0
- grid_py/_draw.py +1397 -0
- grid_py/_edit.py +756 -0
- grid_py/_font_metrics.py +319 -0
- grid_py/_gpar.py +572 -0
- grid_py/_grab.py +501 -0
- grid_py/_grob.py +1377 -0
- grid_py/_group.py +798 -0
- grid_py/_highlevel.py +2176 -0
- grid_py/_just.py +361 -0
- grid_py/_layout.py +593 -0
- grid_py/_ls.py +895 -0
- grid_py/_mask.py +196 -0
- grid_py/_path.py +414 -0
- grid_py/_patterns.py +1049 -0
- grid_py/_primitives.py +2198 -0
- grid_py/_renderer_base.py +1184 -0
- grid_py/_scene_graph.py +248 -0
- grid_py/_size.py +1352 -0
- grid_py/_state.py +683 -0
- grid_py/_transforms.py +448 -0
- grid_py/_typeset.py +384 -0
- grid_py/_units.py +1924 -0
- grid_py/_utils.py +310 -0
- grid_py/_viewport.py +1649 -0
- grid_py/_vp_calc.py +970 -0
- grid_py/py.typed +0 -0
- grid_py/renderer.py +1762 -0
- grid_py/renderer_web.py +764 -0
- grid_py/resources/d3.v7.min.js +2 -0
- grid_py/resources/gridpy.css +80 -0
- grid_py/resources/gridpy.js +813 -0
- rgrid_python-4.5.3.dist-info/METADATA +489 -0
- rgrid_python-4.5.3.dist-info/RECORD +42 -0
- rgrid_python-4.5.3.dist-info/WHEEL +4 -0
- rgrid_python-4.5.3.dist-info/licenses/LICENSE +3 -0
grid_py/renderer.py
ADDED
|
@@ -0,0 +1,1762 @@
|
|
|
1
|
+
"""Cairo-based renderer for grid_py.
|
|
2
|
+
|
|
3
|
+
Replaces the former matplotlib rendering backend. All grob primitives
|
|
4
|
+
are drawn via pycairo, and output can be written as PNG, PDF, SVG, or
|
|
5
|
+
PostScript without any matplotlib dependency.
|
|
6
|
+
|
|
7
|
+
The coordinate convention matches R's grid: the unit square [0, 1] x [0, 1]
|
|
8
|
+
with the origin at the **bottom-left**. Cairo's native origin is top-left,
|
|
9
|
+
so a Y-flip is applied internally.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import io
|
|
15
|
+
import math
|
|
16
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
import cairo
|
|
22
|
+
except ImportError:
|
|
23
|
+
raise ImportError(
|
|
24
|
+
"pycairo is required for grid_py rendering. "
|
|
25
|
+
"Install it with: conda install -c conda-forge pycairo"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from ._colour import parse_r_colour as _parse_colour
|
|
29
|
+
from ._gpar import Gpar
|
|
30
|
+
from ._patterns import LinearGradient, RadialGradient, Pattern
|
|
31
|
+
from ._renderer_base import GridRenderer
|
|
32
|
+
|
|
33
|
+
__all__ = ["CairoRenderer"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# _parse_colour is imported from ._colour (shared R colour table)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Line-type mapping
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
_LTY_DASHES: Dict[str, Optional[Sequence[float]]] = {
|
|
44
|
+
"solid": None,
|
|
45
|
+
"dashed": [6.0, 4.0],
|
|
46
|
+
"dotted": [2.0, 2.0],
|
|
47
|
+
"dotdash": [2.0, 2.0, 6.0, 2.0],
|
|
48
|
+
"longdash": [10.0, 3.0],
|
|
49
|
+
"twodash": [5.0, 2.0, 10.0, 2.0],
|
|
50
|
+
"blank": [0.0, 100.0],
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# R allows ``lty`` to be an integer 0..6 (par docs): 0=blank, 1=solid,
|
|
54
|
+
# 2=dashed, 3=dotted, 4=dotdash, 5=longdash, 6=twodash. Map to the
|
|
55
|
+
# named equivalents so downstream dash lookup works for both forms.
|
|
56
|
+
_LTY_INT_TO_NAME: Dict[int, str] = {
|
|
57
|
+
0: "blank",
|
|
58
|
+
1: "solid",
|
|
59
|
+
2: "dashed",
|
|
60
|
+
3: "dotted",
|
|
61
|
+
4: "dotdash",
|
|
62
|
+
5: "longdash",
|
|
63
|
+
6: "twodash",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
_LINEEND_MAP = {
|
|
67
|
+
"round": cairo.LINE_CAP_ROUND,
|
|
68
|
+
"butt": cairo.LINE_CAP_BUTT,
|
|
69
|
+
"square": cairo.LINE_CAP_SQUARE,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
_LINEJOIN_MAP = {
|
|
73
|
+
"round": cairo.LINE_JOIN_ROUND,
|
|
74
|
+
"mitre": cairo.LINE_JOIN_MITER,
|
|
75
|
+
"miter": cairo.LINE_JOIN_MITER,
|
|
76
|
+
"bevel": cairo.LINE_JOIN_BEVEL,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# CairoRenderer
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
class CairoRenderer(GridRenderer):
|
|
85
|
+
"""Render grid grobs to a Cairo surface.
|
|
86
|
+
|
|
87
|
+
Parameters
|
|
88
|
+
----------
|
|
89
|
+
width : float
|
|
90
|
+
Device width in inches.
|
|
91
|
+
height : float
|
|
92
|
+
Device height in inches.
|
|
93
|
+
dpi : float
|
|
94
|
+
Dots per inch (default 150).
|
|
95
|
+
surface_type : str
|
|
96
|
+
``"image"`` (default, raster PNG), ``"pdf"``, ``"svg"``, ``"ps"``.
|
|
97
|
+
filename : str or None
|
|
98
|
+
Output file path. Required for ``"pdf"``, ``"svg"``, ``"ps"``;
|
|
99
|
+
ignored for ``"image"`` (use :meth:`write_to_png` or
|
|
100
|
+
:meth:`to_png_bytes` instead).
|
|
101
|
+
bg : str or tuple or None
|
|
102
|
+
Background colour (default ``"white"``).
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
width: float = 7.0,
|
|
108
|
+
height: float = 5.0,
|
|
109
|
+
dpi: float = 150.0,
|
|
110
|
+
surface_type: str = "image",
|
|
111
|
+
filename: Optional[str] = None,
|
|
112
|
+
bg: Any = "white",
|
|
113
|
+
) -> None:
|
|
114
|
+
self._surface_type = surface_type
|
|
115
|
+
self._width_px = int(width * dpi)
|
|
116
|
+
self._height_px = int(height * dpi)
|
|
117
|
+
|
|
118
|
+
# Compute device dimensions for the base class
|
|
119
|
+
if surface_type == "image":
|
|
120
|
+
dw = float(self._width_px)
|
|
121
|
+
dh = float(self._height_px)
|
|
122
|
+
else:
|
|
123
|
+
dw = width * 72.0
|
|
124
|
+
dh = height * 72.0
|
|
125
|
+
|
|
126
|
+
super().__init__(width, height, dpi, device_width=dw, device_height=dh)
|
|
127
|
+
|
|
128
|
+
# Points for vector surfaces (1 pt = 1/72 inch)
|
|
129
|
+
width_pt = width * 72.0
|
|
130
|
+
height_pt = height * 72.0
|
|
131
|
+
|
|
132
|
+
if surface_type == "image":
|
|
133
|
+
self._surface = cairo.ImageSurface(
|
|
134
|
+
cairo.FORMAT_ARGB32, self._width_px, self._height_px
|
|
135
|
+
)
|
|
136
|
+
elif surface_type == "pdf":
|
|
137
|
+
if filename is None:
|
|
138
|
+
raise ValueError("filename is required for PDF surface")
|
|
139
|
+
self._surface = cairo.PDFSurface(filename, width_pt, height_pt)
|
|
140
|
+
elif surface_type == "svg":
|
|
141
|
+
if filename is None:
|
|
142
|
+
raise ValueError("filename is required for SVG surface")
|
|
143
|
+
self._surface = cairo.SVGSurface(filename, width_pt, height_pt)
|
|
144
|
+
elif surface_type == "ps":
|
|
145
|
+
if filename is None:
|
|
146
|
+
raise ValueError("filename is required for PS surface")
|
|
147
|
+
self._surface = cairo.PSSurface(filename, width_pt, height_pt)
|
|
148
|
+
else:
|
|
149
|
+
raise ValueError(f"Unknown surface_type: {surface_type!r}")
|
|
150
|
+
|
|
151
|
+
self._ctx = cairo.Context(self._surface)
|
|
152
|
+
|
|
153
|
+
# Fill background
|
|
154
|
+
bg_rgba = _parse_colour(bg)
|
|
155
|
+
self._ctx.set_source_rgba(*bg_rgba)
|
|
156
|
+
self._ctx.paint()
|
|
157
|
+
|
|
158
|
+
# ---- abstract method implementations: state save/restore ----------------
|
|
159
|
+
|
|
160
|
+
def save_state(self) -> None:
|
|
161
|
+
self._ctx.save()
|
|
162
|
+
|
|
163
|
+
def restore_state(self) -> None:
|
|
164
|
+
self._ctx.restore()
|
|
165
|
+
|
|
166
|
+
# ---- abstract method implementations: clipping -------------------------
|
|
167
|
+
|
|
168
|
+
def _apply_clip_rect(self, x0: float, y0: float, w: float, h: float) -> None:
|
|
169
|
+
self._ctx.save()
|
|
170
|
+
self._ctx.rectangle(x0, y0, w, h)
|
|
171
|
+
self._ctx.clip()
|
|
172
|
+
|
|
173
|
+
def _restore_clip(self) -> None:
|
|
174
|
+
self._ctx.restore()
|
|
175
|
+
|
|
176
|
+
# ---- path collection mode (R 4.2+ fill/stroke grobs) -------------------
|
|
177
|
+
|
|
178
|
+
def begin_path_collect(self, rule: str = "winding") -> None:
|
|
179
|
+
"""Enter path-collecting mode.
|
|
180
|
+
|
|
181
|
+
While active, draw_* methods build the Cairo path without
|
|
182
|
+
filling or stroking. Call one of the ``end_path_*`` methods
|
|
183
|
+
to finalise.
|
|
184
|
+
|
|
185
|
+
Mirrors R's ``C_stroke``/``C_fill``/``C_fillStroke`` pattern
|
|
186
|
+
(``grid/src/path.c``).
|
|
187
|
+
"""
|
|
188
|
+
self._ctx.new_path()
|
|
189
|
+
self._path_collecting = True
|
|
190
|
+
self._ctx.set_fill_rule(
|
|
191
|
+
cairo.FILL_RULE_EVEN_ODD if rule == "evenodd"
|
|
192
|
+
else cairo.FILL_RULE_WINDING
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def end_path_stroke(self, gp: Optional[Any] = None) -> None:
|
|
196
|
+
"""End path collection with stroke only (no fill)."""
|
|
197
|
+
self._path_collecting = False
|
|
198
|
+
stroke = self._apply_stroke(gp)
|
|
199
|
+
if stroke[3] > 0:
|
|
200
|
+
self._ctx.stroke()
|
|
201
|
+
else:
|
|
202
|
+
self._ctx.new_path()
|
|
203
|
+
|
|
204
|
+
def end_path_fill(self, gp: Optional[Any] = None) -> None:
|
|
205
|
+
"""End path collection with fill only (no stroke)."""
|
|
206
|
+
self._path_collecting = False
|
|
207
|
+
bbox = self._ctx.path_extents()
|
|
208
|
+
self._apply_fill(gp, bbox=bbox)
|
|
209
|
+
self._ctx.new_path()
|
|
210
|
+
|
|
211
|
+
def end_path_fill_stroke(self, gp: Optional[Any] = None) -> None:
|
|
212
|
+
"""End path collection with fill then stroke."""
|
|
213
|
+
self._path_collecting = False
|
|
214
|
+
bbox = self._ctx.path_extents()
|
|
215
|
+
self._apply_fill(gp, bbox=bbox)
|
|
216
|
+
stroke = self._apply_stroke(gp)
|
|
217
|
+
if stroke[3] > 0:
|
|
218
|
+
self._ctx.stroke()
|
|
219
|
+
else:
|
|
220
|
+
self._ctx.new_path()
|
|
221
|
+
|
|
222
|
+
def render_mask(self, mask_grob: Any) -> Optional["cairo.ImageSurface"]:
|
|
223
|
+
"""Render a mask grob to an off-screen alpha surface.
|
|
224
|
+
|
|
225
|
+
Mirrors R's ``resolveMask.GridMask`` which draws the mask grob
|
|
226
|
+
and extracts the alpha channel for compositing.
|
|
227
|
+
|
|
228
|
+
Parameters
|
|
229
|
+
----------
|
|
230
|
+
mask_grob : grob
|
|
231
|
+
A grob to render as the mask.
|
|
232
|
+
|
|
233
|
+
Returns
|
|
234
|
+
-------
|
|
235
|
+
cairo.ImageSurface or None
|
|
236
|
+
An ARGB32 surface whose alpha channel is the mask, or
|
|
237
|
+
``None`` on failure.
|
|
238
|
+
"""
|
|
239
|
+
x0, y0, pw, ph = self.get_viewport_bounds()
|
|
240
|
+
dw = max(int(round(pw)), 1)
|
|
241
|
+
dh = max(int(round(ph)), 1)
|
|
242
|
+
|
|
243
|
+
mask_surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, dw, dh)
|
|
244
|
+
mask_renderer = CairoRenderer.__new__(CairoRenderer)
|
|
245
|
+
# Initialise base class state directly (bypass __init__ for perf)
|
|
246
|
+
mask_renderer.width_in = float(dw) / self.dpi
|
|
247
|
+
mask_renderer.height_in = float(dh) / self.dpi
|
|
248
|
+
mask_renderer.dpi = self.dpi
|
|
249
|
+
# Initialize the new transform stack for the mask renderer
|
|
250
|
+
from ._vp_calc import calc_root_transform
|
|
251
|
+
mask_w_in = float(dw) / self.dpi
|
|
252
|
+
mask_h_in = float(dh) / self.dpi
|
|
253
|
+
root_vtr = calc_root_transform(mask_w_in * 2.54, mask_h_in * 2.54)
|
|
254
|
+
mask_renderer._vp_transform_stack = [root_vtr]
|
|
255
|
+
mask_renderer._vp_obj_stack = [None]
|
|
256
|
+
mask_renderer._layout_stack = []
|
|
257
|
+
mask_renderer._layout_depth_stack = []
|
|
258
|
+
mask_renderer._clip_stack = []
|
|
259
|
+
mask_renderer._path_collecting = False
|
|
260
|
+
mask_renderer._pen_x = 0.0
|
|
261
|
+
mask_renderer._pen_y = 0.0
|
|
262
|
+
mask_renderer._device_width = float(dw)
|
|
263
|
+
mask_renderer._device_height = float(dh)
|
|
264
|
+
mask_renderer._device_width_cm = mask_w_in * 2.54
|
|
265
|
+
mask_renderer._device_height_cm = mask_h_in * 2.54
|
|
266
|
+
mask_renderer._dev_units_per_inch = self.dpi
|
|
267
|
+
# Cairo-specific state
|
|
268
|
+
mask_renderer._surface = mask_surface
|
|
269
|
+
mask_renderer._ctx = cairo.Context(mask_surface)
|
|
270
|
+
mask_renderer._surface_type = "image"
|
|
271
|
+
mask_renderer._width_px = dw
|
|
272
|
+
mask_renderer._height_px = dh
|
|
273
|
+
|
|
274
|
+
# Clear to transparent
|
|
275
|
+
mask_renderer._ctx.set_source_rgba(0, 0, 0, 0)
|
|
276
|
+
mask_renderer._ctx.paint()
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
from ._draw import grid_draw
|
|
280
|
+
from ._state import get_state
|
|
281
|
+
state = get_state()
|
|
282
|
+
orig_renderer = state._renderer
|
|
283
|
+
state._renderer = mask_renderer
|
|
284
|
+
try:
|
|
285
|
+
grid_draw(mask_grob, recording=False)
|
|
286
|
+
finally:
|
|
287
|
+
state._renderer = orig_renderer
|
|
288
|
+
except Exception:
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
return mask_surface
|
|
292
|
+
|
|
293
|
+
def apply_mask(
|
|
294
|
+
self,
|
|
295
|
+
mask_surface: "cairo.ImageSurface",
|
|
296
|
+
mask_type: str = "alpha",
|
|
297
|
+
) -> None:
|
|
298
|
+
"""Apply a pre-rendered mask surface to the current drawing.
|
|
299
|
+
|
|
300
|
+
For ``type="alpha"``, the mask's alpha channel is used directly.
|
|
301
|
+
For ``type="luminance"``, the mask's luminance (brightness) is
|
|
302
|
+
converted to alpha.
|
|
303
|
+
|
|
304
|
+
Parameters
|
|
305
|
+
----------
|
|
306
|
+
mask_surface : cairo.ImageSurface
|
|
307
|
+
The rendered mask surface.
|
|
308
|
+
mask_type : str
|
|
309
|
+
``"alpha"`` or ``"luminance"``.
|
|
310
|
+
"""
|
|
311
|
+
x0, y0, pw, ph = self.get_viewport_bounds()
|
|
312
|
+
|
|
313
|
+
if mask_type == "luminance":
|
|
314
|
+
# Convert luminance to alpha: iterate pixels and set alpha
|
|
315
|
+
# based on brightness. This is approximate; full implementation
|
|
316
|
+
# would need per-pixel manipulation.
|
|
317
|
+
pass # Cairo doesn't natively support luminance masks;
|
|
318
|
+
# the alpha channel is used as-is for now.
|
|
319
|
+
|
|
320
|
+
# Apply mask at the current viewport position
|
|
321
|
+
self._ctx.mask_surface(mask_surface, x0, y0)
|
|
322
|
+
|
|
323
|
+
# ---- gpar application --------------------------------------------------
|
|
324
|
+
|
|
325
|
+
def _apply_stroke(self, gp: Optional[Gpar]) -> Tuple[float, float, float, float]:
|
|
326
|
+
"""Set stroke colour, line width, dash, caps, joins from Gpar.
|
|
327
|
+
|
|
328
|
+
Returns the stroke RGBA so caller can decide whether to actually
|
|
329
|
+
stroke (transparent == skip).
|
|
330
|
+
"""
|
|
331
|
+
ctx = self._ctx
|
|
332
|
+
if gp is None:
|
|
333
|
+
ctx.set_source_rgba(0, 0, 0, 1)
|
|
334
|
+
ctx.set_line_width(1.0)
|
|
335
|
+
return (0.0, 0.0, 0.0, 1.0)
|
|
336
|
+
|
|
337
|
+
col = gp.get("col", None)
|
|
338
|
+
# R semantics:
|
|
339
|
+
# * col=NULL (unset) → inherit parent, default "black"
|
|
340
|
+
# * col=NA (explicit) → no stroke (transparent)
|
|
341
|
+
# In Python Gpar, None-scalar is dropped at construction, so
|
|
342
|
+
# ``gp.get("col") is None`` ≡ NULL. A sequence whose entries
|
|
343
|
+
# are None (coming from ggplot2 ``colour=NA`` data) must be
|
|
344
|
+
# treated as NA, matching R's ``gpar(col=NA)``.
|
|
345
|
+
_is_seq = hasattr(col, "__len__") and not isinstance(col, str)
|
|
346
|
+
if _is_seq:
|
|
347
|
+
col_val = col[0]
|
|
348
|
+
if col_val is None:
|
|
349
|
+
return (0.0, 0.0, 0.0, 0.0) # NA — skip stroke
|
|
350
|
+
else:
|
|
351
|
+
col_val = col
|
|
352
|
+
if col_val is None:
|
|
353
|
+
col_val = "black" # NULL — default
|
|
354
|
+
rgba = _parse_colour(col_val)
|
|
355
|
+
|
|
356
|
+
alpha = gp.get("alpha", None)
|
|
357
|
+
if alpha is not None:
|
|
358
|
+
a = float(alpha[0] if isinstance(alpha, (list, tuple)) else alpha)
|
|
359
|
+
rgba = (rgba[0], rgba[1], rgba[2], rgba[3] * a)
|
|
360
|
+
|
|
361
|
+
ctx.set_source_rgba(*rgba)
|
|
362
|
+
|
|
363
|
+
lwd = gp.get("lwd", None)
|
|
364
|
+
lw = float((lwd[0] if isinstance(lwd, (list, tuple)) else lwd) if lwd is not None else 1.0)
|
|
365
|
+
# R semantics: lwd=0 means invisible line
|
|
366
|
+
if lw <= 0:
|
|
367
|
+
return (0.0, 0.0, 0.0, 0.0)
|
|
368
|
+
# R grid semantics: ``lwd`` is always in **points** (1/72 inch)
|
|
369
|
+
# regardless of the current viewport's scale. Cairo's
|
|
370
|
+
# ``set_line_width`` takes a user-space distance, which the
|
|
371
|
+
# viewport CTM has scaled down to NPC-like units — so a value
|
|
372
|
+
# of 0.5 user-space becomes sub-pixel after ``scale(w, h)``.
|
|
373
|
+
# Convert ``lw`` from points → device pixels using the
|
|
374
|
+
# renderer's DPI, then back to user-space via
|
|
375
|
+
# ``device_to_user_distance`` so the stroke width stays at
|
|
376
|
+
# 0.5pt on the output device no matter how deep the
|
|
377
|
+
# viewport stack is (matches R grid's device-unit lwd).
|
|
378
|
+
dpi = getattr(self, "dpi", None) or getattr(self, "_dpi", 72.0) or 72.0
|
|
379
|
+
lw_px = lw * dpi / 72.0
|
|
380
|
+
try:
|
|
381
|
+
ux, uy = ctx.device_to_user_distance(lw_px, lw_px)
|
|
382
|
+
lw_user = max(abs(ux), abs(uy))
|
|
383
|
+
except Exception:
|
|
384
|
+
lw_user = lw
|
|
385
|
+
ctx.set_line_width(lw_user)
|
|
386
|
+
|
|
387
|
+
lty = gp.get("lty", None)
|
|
388
|
+
if lty is not None:
|
|
389
|
+
raw = lty[0] if isinstance(lty, (list, tuple)) else lty
|
|
390
|
+
# R integer code (0..6) → name
|
|
391
|
+
if isinstance(raw, (int, float, np.integer, np.floating)) and \
|
|
392
|
+
not isinstance(raw, bool):
|
|
393
|
+
raw = _LTY_INT_TO_NAME.get(int(raw), str(raw))
|
|
394
|
+
lty_val = str(raw)
|
|
395
|
+
dashes = _LTY_DASHES.get(lty_val)
|
|
396
|
+
if dashes is not None:
|
|
397
|
+
ctx.set_dash(dashes)
|
|
398
|
+
else:
|
|
399
|
+
ctx.set_dash([])
|
|
400
|
+
else:
|
|
401
|
+
ctx.set_dash([])
|
|
402
|
+
|
|
403
|
+
lineend = gp.get("lineend", None)
|
|
404
|
+
if lineend is not None:
|
|
405
|
+
le = str(lineend[0] if isinstance(lineend, (list, tuple)) else lineend)
|
|
406
|
+
ctx.set_line_cap(_LINEEND_MAP.get(le, cairo.LINE_CAP_BUTT))
|
|
407
|
+
else:
|
|
408
|
+
ctx.set_line_cap(cairo.LINE_CAP_BUTT)
|
|
409
|
+
|
|
410
|
+
linejoin = gp.get("linejoin", None)
|
|
411
|
+
if linejoin is not None:
|
|
412
|
+
lj = str(linejoin[0] if isinstance(linejoin, (list, tuple)) else linejoin)
|
|
413
|
+
ctx.set_line_join(_LINEJOIN_MAP.get(lj, cairo.LINE_JOIN_ROUND))
|
|
414
|
+
else:
|
|
415
|
+
ctx.set_line_join(cairo.LINE_JOIN_ROUND)
|
|
416
|
+
|
|
417
|
+
return rgba
|
|
418
|
+
|
|
419
|
+
def _fill_rgba(
|
|
420
|
+
self,
|
|
421
|
+
gp: Optional[Gpar],
|
|
422
|
+
bbox: Optional[Tuple[float, float, float, float]] = None,
|
|
423
|
+
) -> Union[Tuple[float, float, float, float], "cairo.Pattern"]:
|
|
424
|
+
"""Extract fill colour or gradient pattern from Gpar.
|
|
425
|
+
|
|
426
|
+
Returns either an RGBA tuple for solid colours, or a
|
|
427
|
+
``cairo.LinearGradient`` / ``cairo.RadialGradient`` pattern
|
|
428
|
+
for gradient fills.
|
|
429
|
+
|
|
430
|
+
Parameters
|
|
431
|
+
----------
|
|
432
|
+
gp : Gpar or None
|
|
433
|
+
Graphical parameters.
|
|
434
|
+
bbox : tuple or None
|
|
435
|
+
Shape bounding box ``(x, y, w, h)`` in NPC, passed to
|
|
436
|
+
``_setup_gradient`` for ``group=False`` resolution.
|
|
437
|
+
"""
|
|
438
|
+
if gp is None:
|
|
439
|
+
return (1.0, 1.0, 1.0, 1.0)
|
|
440
|
+
fill = gp.get("fill", None)
|
|
441
|
+
|
|
442
|
+
# Handle gradient objects (LinearGradient / RadialGradient)
|
|
443
|
+
if isinstance(fill, (LinearGradient, RadialGradient)):
|
|
444
|
+
return self._setup_gradient(fill, gp, bbox=bbox)
|
|
445
|
+
# Handle tiling pattern
|
|
446
|
+
if isinstance(fill, Pattern):
|
|
447
|
+
return self._setup_pattern(fill, gp, bbox=bbox)
|
|
448
|
+
|
|
449
|
+
# Distinguish RGBA colour tuples (r,g,b,a) from lists of colour values.
|
|
450
|
+
# An RGBA tuple has 3-4 numeric elements all in [0,1]; a colour list
|
|
451
|
+
# contains strings, None, or gradient/pattern objects.
|
|
452
|
+
if isinstance(fill, tuple) and len(fill) in (3, 4) and all(
|
|
453
|
+
isinstance(c, (int, float)) for c in fill
|
|
454
|
+
):
|
|
455
|
+
# Direct RGBA/RGB tuple — treat as a single colour
|
|
456
|
+
fill_val = fill
|
|
457
|
+
elif isinstance(fill, (list, tuple)):
|
|
458
|
+
fill_val = fill[0]
|
|
459
|
+
else:
|
|
460
|
+
fill_val = fill
|
|
461
|
+
|
|
462
|
+
# R semantics: fill=NA (None) means "no fill" → transparent
|
|
463
|
+
if fill_val is None:
|
|
464
|
+
return (0.0, 0.0, 0.0, 0.0)
|
|
465
|
+
|
|
466
|
+
# Check if a scalar fill_val is a gradient or pattern object
|
|
467
|
+
if isinstance(fill_val, (LinearGradient, RadialGradient)):
|
|
468
|
+
return self._setup_gradient(fill_val, gp, bbox=bbox)
|
|
469
|
+
if isinstance(fill_val, Pattern):
|
|
470
|
+
return self._setup_pattern(fill_val, gp, bbox=bbox)
|
|
471
|
+
|
|
472
|
+
rgba = _parse_colour(fill_val)
|
|
473
|
+
|
|
474
|
+
alpha = gp.get("alpha", None)
|
|
475
|
+
if alpha is not None:
|
|
476
|
+
a = float(alpha[0] if isinstance(alpha, (list, tuple)) else alpha)
|
|
477
|
+
rgba = (rgba[0], rgba[1], rgba[2], rgba[3] * a)
|
|
478
|
+
return rgba
|
|
479
|
+
|
|
480
|
+
def _setup_gradient(
|
|
481
|
+
self,
|
|
482
|
+
gradient: Union[LinearGradient, RadialGradient],
|
|
483
|
+
gp: Optional[Gpar] = None,
|
|
484
|
+
bbox: Optional[Tuple[float, float, float, float]] = None,
|
|
485
|
+
) -> "cairo.Pattern":
|
|
486
|
+
"""Convert a grid gradient object to a cairo Pattern.
|
|
487
|
+
|
|
488
|
+
When ``gradient.group`` is ``True`` (default), coordinates are
|
|
489
|
+
resolved relative to the current viewport. When ``False``,
|
|
490
|
+
coordinates are resolved relative to the shape's bounding box
|
|
491
|
+
given by *bbox* ``(x, y, w, h)`` in NPC.
|
|
492
|
+
|
|
493
|
+
Mirrors R's ``resolvePattern()`` in ``patterns.R:391-418``.
|
|
494
|
+
|
|
495
|
+
Parameters
|
|
496
|
+
----------
|
|
497
|
+
gradient : LinearGradient or RadialGradient
|
|
498
|
+
The gradient specification.
|
|
499
|
+
gp : Gpar or None
|
|
500
|
+
Graphical parameters (for alpha).
|
|
501
|
+
bbox : tuple of float or None
|
|
502
|
+
Shape bounding box ``(x, y, w, h)`` in NPC, used when
|
|
503
|
+
``gradient.group is False``.
|
|
504
|
+
|
|
505
|
+
Returns
|
|
506
|
+
-------
|
|
507
|
+
cairo.Pattern
|
|
508
|
+
A cairo linear or radial gradient pattern.
|
|
509
|
+
"""
|
|
510
|
+
# Get alpha from gpar
|
|
511
|
+
gp_alpha = 1.0
|
|
512
|
+
if gp is not None:
|
|
513
|
+
a = gp.get("alpha", None)
|
|
514
|
+
if a is not None:
|
|
515
|
+
gp_alpha = float(a[0] if isinstance(a, (list, tuple)) else a)
|
|
516
|
+
|
|
517
|
+
# Determine coordinate mapping: viewport (group=True) or bbox (group=False)
|
|
518
|
+
use_bbox = (not gradient.group) and (bbox is not None)
|
|
519
|
+
|
|
520
|
+
if isinstance(gradient, LinearGradient):
|
|
521
|
+
gx1 = float(gradient.x1.values[0])
|
|
522
|
+
gy1 = float(gradient.y1.values[0])
|
|
523
|
+
gx2 = float(gradient.x2.values[0])
|
|
524
|
+
gy2 = float(gradient.y2.values[0])
|
|
525
|
+
|
|
526
|
+
if use_bbox:
|
|
527
|
+
# Map gradient NPC coords to shape bbox
|
|
528
|
+
bx, by, bw, bh = bbox
|
|
529
|
+
x1_npc = bx + gx1 * bw
|
|
530
|
+
y1_npc = by + gy1 * bh
|
|
531
|
+
x2_npc = bx + gx2 * bw
|
|
532
|
+
y2_npc = by + gy2 * bh
|
|
533
|
+
else:
|
|
534
|
+
x1_npc, y1_npc = gx1, gy1
|
|
535
|
+
x2_npc, y2_npc = gx2, gy2
|
|
536
|
+
|
|
537
|
+
pattern = cairo.LinearGradient(
|
|
538
|
+
self._x(x1_npc), self._y(y1_npc),
|
|
539
|
+
self._x(x2_npc), self._y(y2_npc),
|
|
540
|
+
)
|
|
541
|
+
elif isinstance(gradient, RadialGradient):
|
|
542
|
+
gcx1 = float(gradient.cx1.values[0])
|
|
543
|
+
gcy1 = float(gradient.cy1.values[0])
|
|
544
|
+
gr1 = float(gradient.r1.values[0])
|
|
545
|
+
gcx2 = float(gradient.cx2.values[0])
|
|
546
|
+
gcy2 = float(gradient.cy2.values[0])
|
|
547
|
+
gr2 = float(gradient.r2.values[0])
|
|
548
|
+
|
|
549
|
+
if use_bbox:
|
|
550
|
+
bx, by, bw, bh = bbox
|
|
551
|
+
cx1_npc = bx + gcx1 * bw
|
|
552
|
+
cy1_npc = by + gcy1 * bh
|
|
553
|
+
cx2_npc = bx + gcx2 * bw
|
|
554
|
+
cy2_npc = by + gcy2 * bh
|
|
555
|
+
# Scale radius by bbox size
|
|
556
|
+
r1_npc = gr1 * min(bw, bh)
|
|
557
|
+
r2_npc = gr2 * min(bw, bh)
|
|
558
|
+
else:
|
|
559
|
+
cx1_npc, cy1_npc = gcx1, gcy1
|
|
560
|
+
cx2_npc, cy2_npc = gcx2, gcy2
|
|
561
|
+
r1_npc, r2_npc = gr1, gr2
|
|
562
|
+
|
|
563
|
+
r1_dev = (self._sx(r1_npc) + self._sy(r1_npc)) / 2.0
|
|
564
|
+
r2_dev = (self._sx(r2_npc) + self._sy(r2_npc)) / 2.0
|
|
565
|
+
|
|
566
|
+
pattern = cairo.RadialGradient(
|
|
567
|
+
self._x(cx1_npc), self._y(cy1_npc), r1_dev,
|
|
568
|
+
self._x(cx2_npc), self._y(cy2_npc), r2_dev,
|
|
569
|
+
)
|
|
570
|
+
else:
|
|
571
|
+
return (0.0, 0.0, 0.0, 0.0)
|
|
572
|
+
|
|
573
|
+
# Add colour stops
|
|
574
|
+
for colour_str, stop in zip(gradient.colours, gradient.stops):
|
|
575
|
+
r, g, b, a = _parse_colour(colour_str)
|
|
576
|
+
pattern.add_color_stop_rgba(stop, r, g, b, a * gp_alpha)
|
|
577
|
+
|
|
578
|
+
# Set extend mode
|
|
579
|
+
extend_map = {
|
|
580
|
+
"pad": cairo.EXTEND_PAD,
|
|
581
|
+
"repeat": cairo.EXTEND_REPEAT,
|
|
582
|
+
"reflect": cairo.EXTEND_REFLECT,
|
|
583
|
+
"none": cairo.EXTEND_NONE,
|
|
584
|
+
}
|
|
585
|
+
pattern.set_extend(extend_map.get(gradient.extend, cairo.EXTEND_PAD))
|
|
586
|
+
|
|
587
|
+
return pattern
|
|
588
|
+
|
|
589
|
+
def _setup_pattern(
|
|
590
|
+
self,
|
|
591
|
+
pat: "Pattern",
|
|
592
|
+
gp: Optional[Gpar] = None,
|
|
593
|
+
bbox: Optional[Tuple[float, float, float, float]] = None,
|
|
594
|
+
) -> "cairo.Pattern":
|
|
595
|
+
"""Convert a grid tiling Pattern to a cairo SurfacePattern.
|
|
596
|
+
|
|
597
|
+
Renders the pattern's grob to an off-screen Cairo surface, then
|
|
598
|
+
creates a ``cairo.SurfacePattern`` with the appropriate extend mode.
|
|
599
|
+
|
|
600
|
+
Mirrors R's ``resolvePattern.GridTilingPattern`` (patterns.R:420-429).
|
|
601
|
+
|
|
602
|
+
Parameters
|
|
603
|
+
----------
|
|
604
|
+
pat : Pattern
|
|
605
|
+
The tiling pattern specification.
|
|
606
|
+
gp : Gpar or None
|
|
607
|
+
Graphical parameters (for alpha).
|
|
608
|
+
bbox : tuple or None
|
|
609
|
+
Shape bounding box in NPC (for group=False resolution).
|
|
610
|
+
|
|
611
|
+
Returns
|
|
612
|
+
-------
|
|
613
|
+
cairo.SurfacePattern
|
|
614
|
+
A cairo surface pattern for tiling.
|
|
615
|
+
"""
|
|
616
|
+
# Resolve tile position and dimensions in NPC
|
|
617
|
+
px = float(pat.x.values[0])
|
|
618
|
+
py = float(pat.y.values[0])
|
|
619
|
+
pw = float(pat.width.values[0])
|
|
620
|
+
ph = float(pat.height.values[0])
|
|
621
|
+
|
|
622
|
+
if not pat.group and bbox is not None:
|
|
623
|
+
bx, by, bw, bh = bbox
|
|
624
|
+
px = bx + px * bw
|
|
625
|
+
py = by + py * bh
|
|
626
|
+
pw = pw * bw
|
|
627
|
+
ph = ph * bh
|
|
628
|
+
|
|
629
|
+
# Compute tile origin (left, bottom) using justification
|
|
630
|
+
tile_left = px - pat.hjust * pw
|
|
631
|
+
tile_bottom = py - pat.vjust * ph
|
|
632
|
+
|
|
633
|
+
# Convert to device coordinates
|
|
634
|
+
tile_dx = self._x(tile_left)
|
|
635
|
+
tile_dy = self._y(tile_bottom + ph) # Y-flip: top in device coords
|
|
636
|
+
tile_dw = max(int(round(self._sx(pw))), 1)
|
|
637
|
+
tile_dh = max(int(round(self._sy(ph))), 1)
|
|
638
|
+
|
|
639
|
+
# Render the pattern grob to an off-screen surface
|
|
640
|
+
tile_surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, tile_dw, tile_dh)
|
|
641
|
+
tile_ctx = tile_surface
|
|
642
|
+
|
|
643
|
+
# Create a temporary renderer for the tile
|
|
644
|
+
tile_renderer = CairoRenderer(
|
|
645
|
+
width=pw * 2.54, # convert NPC width to approximate inches
|
|
646
|
+
height=ph * 2.54,
|
|
647
|
+
dpi=self.dpi,
|
|
648
|
+
surface_type="image",
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
# Draw the grob into the tile using state swap
|
|
652
|
+
try:
|
|
653
|
+
from ._draw import grid_draw
|
|
654
|
+
from ._state import get_state
|
|
655
|
+
state = get_state()
|
|
656
|
+
orig_renderer = state._renderer
|
|
657
|
+
state._renderer = tile_renderer
|
|
658
|
+
# Reset viewport to root for clean drawing
|
|
659
|
+
tile_renderer.push_viewport(None)
|
|
660
|
+
try:
|
|
661
|
+
grid_draw(pat.grob, recording=False)
|
|
662
|
+
finally:
|
|
663
|
+
state._renderer = orig_renderer
|
|
664
|
+
except Exception:
|
|
665
|
+
# If grob rendering fails, return transparent
|
|
666
|
+
return (0.0, 0.0, 0.0, 0.0)
|
|
667
|
+
|
|
668
|
+
tile_surface = tile_renderer._surface
|
|
669
|
+
|
|
670
|
+
# Create a surface pattern from the rendered tile
|
|
671
|
+
surface_pattern = cairo.SurfacePattern(tile_surface)
|
|
672
|
+
|
|
673
|
+
# Set extend mode
|
|
674
|
+
extend_map = {
|
|
675
|
+
"pad": cairo.EXTEND_PAD,
|
|
676
|
+
"repeat": cairo.EXTEND_REPEAT,
|
|
677
|
+
"reflect": cairo.EXTEND_REFLECT,
|
|
678
|
+
"none": cairo.EXTEND_NONE,
|
|
679
|
+
}
|
|
680
|
+
surface_pattern.set_extend(extend_map.get(pat.extend, cairo.EXTEND_REPEAT))
|
|
681
|
+
|
|
682
|
+
# Set the pattern matrix to position the tile correctly
|
|
683
|
+
# The pattern needs to be translated to the tile's position in device space
|
|
684
|
+
matrix = cairo.Matrix()
|
|
685
|
+
matrix.translate(-tile_dx, -tile_dy)
|
|
686
|
+
surface_pattern.set_matrix(matrix)
|
|
687
|
+
|
|
688
|
+
return surface_pattern
|
|
689
|
+
|
|
690
|
+
def _apply_fill(
|
|
691
|
+
self,
|
|
692
|
+
gp: Optional[Gpar],
|
|
693
|
+
bbox: Optional[Tuple[float, float, float, float]] = None,
|
|
694
|
+
) -> bool:
|
|
695
|
+
"""Apply fill (solid colour or gradient) to the current path.
|
|
696
|
+
|
|
697
|
+
Parameters
|
|
698
|
+
----------
|
|
699
|
+
gp : Gpar or None
|
|
700
|
+
Graphical parameters.
|
|
701
|
+
bbox : tuple or None
|
|
702
|
+
Shape bounding box ``(x, y, w, h)`` in NPC for
|
|
703
|
+
``group=False`` gradient resolution.
|
|
704
|
+
|
|
705
|
+
Returns ``True`` if a fill was applied, ``False`` if transparent.
|
|
706
|
+
"""
|
|
707
|
+
ctx = self._ctx
|
|
708
|
+
fill = self._fill_rgba(gp, bbox=bbox)
|
|
709
|
+
if isinstance(fill, cairo.Pattern):
|
|
710
|
+
ctx.set_source(fill)
|
|
711
|
+
ctx.fill_preserve()
|
|
712
|
+
return True
|
|
713
|
+
elif isinstance(fill, tuple) and fill[3] > 0:
|
|
714
|
+
ctx.set_source_rgba(*fill)
|
|
715
|
+
ctx.fill_preserve()
|
|
716
|
+
return True
|
|
717
|
+
return False
|
|
718
|
+
|
|
719
|
+
def _set_font(self, gp: Optional[Gpar]) -> float:
|
|
720
|
+
"""Configure font on context from Gpar. Returns font size in device units.
|
|
721
|
+
|
|
722
|
+
Mirrors R's gpar.c:391-398 where ``gc->ps = fontsize * GSS_SCALE``
|
|
723
|
+
and the device uses ``gc->ps * gc->cex`` for the effective size.
|
|
724
|
+
"""
|
|
725
|
+
ctx = self._ctx
|
|
726
|
+
family = "sans-serif"
|
|
727
|
+
slant = cairo.FONT_SLANT_NORMAL
|
|
728
|
+
weight = cairo.FONT_WEIGHT_NORMAL
|
|
729
|
+
fontsize = 12.0 # points
|
|
730
|
+
cex_val = 1.0
|
|
731
|
+
|
|
732
|
+
if gp is not None:
|
|
733
|
+
ff = gp.get("fontfamily", None)
|
|
734
|
+
if ff is not None:
|
|
735
|
+
family = str(ff[0] if isinstance(ff, (list, tuple)) else ff)
|
|
736
|
+
|
|
737
|
+
fs = gp.get("fontsize", None)
|
|
738
|
+
if fs is not None:
|
|
739
|
+
fontsize = float(fs[0] if isinstance(fs, (list, tuple)) else fs)
|
|
740
|
+
|
|
741
|
+
cex = gp.get("cex", None)
|
|
742
|
+
if cex is not None:
|
|
743
|
+
cex_val = float(cex[0] if isinstance(cex, (list, tuple)) else cex)
|
|
744
|
+
|
|
745
|
+
face = gp.get("fontface", None)
|
|
746
|
+
if face is not None:
|
|
747
|
+
val = face[0] if isinstance(face, (list, tuple)) else face
|
|
748
|
+
if isinstance(val, str):
|
|
749
|
+
val = val.lower()
|
|
750
|
+
if val in (2, "bold"):
|
|
751
|
+
weight = cairo.FONT_WEIGHT_BOLD
|
|
752
|
+
elif val in (3, "italic", "oblique"):
|
|
753
|
+
slant = cairo.FONT_SLANT_ITALIC
|
|
754
|
+
elif val in (4, "bold.italic"):
|
|
755
|
+
weight = cairo.FONT_WEIGHT_BOLD
|
|
756
|
+
slant = cairo.FONT_SLANT_ITALIC
|
|
757
|
+
|
|
758
|
+
# R: gc->ps = fontsize * GSS_SCALE; effective = gc->ps * gc->cex
|
|
759
|
+
scale = self._get_scale()
|
|
760
|
+
fontsize = fontsize * scale * cex_val
|
|
761
|
+
|
|
762
|
+
ctx.select_font_face(family, slant, weight)
|
|
763
|
+
# Font size in device units (points for vector, pixels for raster).
|
|
764
|
+
# Cairo interprets set_font_size as "user-space units".
|
|
765
|
+
# For image surfaces we scale points → pixels.
|
|
766
|
+
if self._surface_type == "image":
|
|
767
|
+
device_fs = fontsize * self.dpi / 72.0
|
|
768
|
+
else:
|
|
769
|
+
device_fs = fontsize
|
|
770
|
+
ctx.set_font_size(device_fs)
|
|
771
|
+
return device_fs
|
|
772
|
+
|
|
773
|
+
# ---- drawing primitives ------------------------------------------------
|
|
774
|
+
|
|
775
|
+
def draw_rect(
|
|
776
|
+
self,
|
|
777
|
+
x: float,
|
|
778
|
+
y: float,
|
|
779
|
+
w: float,
|
|
780
|
+
h: float,
|
|
781
|
+
hjust: float = 0.5,
|
|
782
|
+
vjust: float = 0.5,
|
|
783
|
+
gp: Optional[Gpar] = None,
|
|
784
|
+
) -> None:
|
|
785
|
+
"""Draw a rectangle. x, y, w, h are in device coordinates."""
|
|
786
|
+
ctx = self._ctx
|
|
787
|
+
ctx.save()
|
|
788
|
+
|
|
789
|
+
# x,y is the anchor point; apply justification to get top-left
|
|
790
|
+
dx = x - w * hjust
|
|
791
|
+
dy = y - h * (1.0 - vjust) # device y increases downward
|
|
792
|
+
|
|
793
|
+
ctx.rectangle(dx, dy, w, h)
|
|
794
|
+
|
|
795
|
+
if self._path_collecting:
|
|
796
|
+
ctx.restore()
|
|
797
|
+
return
|
|
798
|
+
|
|
799
|
+
bbox = (dx, dy, w, h)
|
|
800
|
+
self._apply_fill(gp, bbox=bbox)
|
|
801
|
+
|
|
802
|
+
stroke = self._apply_stroke(gp)
|
|
803
|
+
if stroke[3] > 0:
|
|
804
|
+
ctx.stroke()
|
|
805
|
+
else:
|
|
806
|
+
ctx.new_path()
|
|
807
|
+
|
|
808
|
+
ctx.restore()
|
|
809
|
+
|
|
810
|
+
def draw_circle(
|
|
811
|
+
self,
|
|
812
|
+
x: float,
|
|
813
|
+
y: float,
|
|
814
|
+
r: float,
|
|
815
|
+
gp: Optional[Gpar] = None,
|
|
816
|
+
) -> None:
|
|
817
|
+
"""Draw a circle. x, y, r are in device coordinates."""
|
|
818
|
+
ctx = self._ctx
|
|
819
|
+
ctx.save()
|
|
820
|
+
|
|
821
|
+
ctx.arc(x, y, r, 0, 2 * math.pi)
|
|
822
|
+
|
|
823
|
+
if self._path_collecting:
|
|
824
|
+
ctx.restore()
|
|
825
|
+
return
|
|
826
|
+
|
|
827
|
+
bbox = (x - r, y - r, 2 * r, 2 * r)
|
|
828
|
+
self._apply_fill(gp, bbox=bbox)
|
|
829
|
+
|
|
830
|
+
stroke = self._apply_stroke(gp)
|
|
831
|
+
if stroke[3] > 0:
|
|
832
|
+
ctx.stroke()
|
|
833
|
+
else:
|
|
834
|
+
ctx.new_path()
|
|
835
|
+
|
|
836
|
+
ctx.restore()
|
|
837
|
+
|
|
838
|
+
def draw_line(
|
|
839
|
+
self,
|
|
840
|
+
x: np.ndarray,
|
|
841
|
+
y: np.ndarray,
|
|
842
|
+
gp: Optional[Gpar] = None,
|
|
843
|
+
) -> None:
|
|
844
|
+
"""Draw connected lines. x, y are in device coordinates."""
|
|
845
|
+
n = max(len(x), len(y))
|
|
846
|
+
if n < 2:
|
|
847
|
+
return
|
|
848
|
+
if len(x) < n:
|
|
849
|
+
x = np.resize(x, n)
|
|
850
|
+
if len(y) < n:
|
|
851
|
+
y = np.resize(y, n)
|
|
852
|
+
|
|
853
|
+
ctx = self._ctx
|
|
854
|
+
ctx.save()
|
|
855
|
+
|
|
856
|
+
ctx.move_to(x[0], y[0])
|
|
857
|
+
for i in range(1, n):
|
|
858
|
+
ctx.line_to(x[i], y[i])
|
|
859
|
+
|
|
860
|
+
if self._path_collecting:
|
|
861
|
+
ctx.restore()
|
|
862
|
+
return
|
|
863
|
+
|
|
864
|
+
stroke = self._apply_stroke(gp)
|
|
865
|
+
if stroke[3] > 0:
|
|
866
|
+
ctx.stroke()
|
|
867
|
+
else:
|
|
868
|
+
ctx.new_path()
|
|
869
|
+
ctx.restore()
|
|
870
|
+
|
|
871
|
+
def draw_polyline(
|
|
872
|
+
self,
|
|
873
|
+
x: np.ndarray,
|
|
874
|
+
y: np.ndarray,
|
|
875
|
+
id_: Optional[np.ndarray] = None,
|
|
876
|
+
gp: Optional[Gpar] = None,
|
|
877
|
+
) -> None:
|
|
878
|
+
ctx = self._ctx
|
|
879
|
+
ctx.save()
|
|
880
|
+
|
|
881
|
+
if id_ is None:
|
|
882
|
+
self.draw_line(x, y, gp)
|
|
883
|
+
ctx.restore()
|
|
884
|
+
return
|
|
885
|
+
|
|
886
|
+
for uid in np.unique(id_):
|
|
887
|
+
mask = id_ == uid
|
|
888
|
+
px = x[mask]
|
|
889
|
+
py = y[mask]
|
|
890
|
+
if len(px) < 2:
|
|
891
|
+
continue
|
|
892
|
+
ctx.move_to(px[0], py[0])
|
|
893
|
+
for i in range(1, len(px)):
|
|
894
|
+
ctx.line_to(px[i], py[i])
|
|
895
|
+
|
|
896
|
+
if self._path_collecting:
|
|
897
|
+
ctx.restore()
|
|
898
|
+
return
|
|
899
|
+
|
|
900
|
+
stroke = self._apply_stroke(gp)
|
|
901
|
+
if stroke[3] > 0:
|
|
902
|
+
ctx.stroke()
|
|
903
|
+
else:
|
|
904
|
+
ctx.new_path()
|
|
905
|
+
ctx.restore()
|
|
906
|
+
|
|
907
|
+
def draw_segments(
|
|
908
|
+
self,
|
|
909
|
+
x0: np.ndarray,
|
|
910
|
+
y0: np.ndarray,
|
|
911
|
+
x1: np.ndarray,
|
|
912
|
+
y1: np.ndarray,
|
|
913
|
+
gp: Optional[Gpar] = None,
|
|
914
|
+
) -> None:
|
|
915
|
+
ctx = self._ctx
|
|
916
|
+
ctx.save()
|
|
917
|
+
|
|
918
|
+
n = min(len(x0), len(y0), len(x1), len(y1))
|
|
919
|
+
for i in range(n):
|
|
920
|
+
ctx.move_to(x0[i], y0[i])
|
|
921
|
+
ctx.line_to(x1[i], y1[i])
|
|
922
|
+
|
|
923
|
+
if self._path_collecting:
|
|
924
|
+
ctx.restore()
|
|
925
|
+
return
|
|
926
|
+
|
|
927
|
+
stroke = self._apply_stroke(gp)
|
|
928
|
+
if stroke[3] > 0:
|
|
929
|
+
ctx.stroke()
|
|
930
|
+
else:
|
|
931
|
+
ctx.new_path()
|
|
932
|
+
ctx.restore()
|
|
933
|
+
|
|
934
|
+
def draw_polygon(
|
|
935
|
+
self,
|
|
936
|
+
x: np.ndarray,
|
|
937
|
+
y: np.ndarray,
|
|
938
|
+
gp: Optional[Gpar] = None,
|
|
939
|
+
) -> None:
|
|
940
|
+
if len(x) < 3:
|
|
941
|
+
return
|
|
942
|
+
ctx = self._ctx
|
|
943
|
+
ctx.save()
|
|
944
|
+
|
|
945
|
+
ctx.move_to(x[0], y[0])
|
|
946
|
+
for i in range(1, len(x)):
|
|
947
|
+
ctx.line_to(x[i], y[i])
|
|
948
|
+
ctx.close_path()
|
|
949
|
+
|
|
950
|
+
if self._path_collecting:
|
|
951
|
+
ctx.restore()
|
|
952
|
+
return
|
|
953
|
+
|
|
954
|
+
bbox = (float(np.min(x)), float(np.min(y)),
|
|
955
|
+
float(np.ptp(x)), float(np.ptp(y)))
|
|
956
|
+
self._apply_fill(gp, bbox=bbox)
|
|
957
|
+
|
|
958
|
+
stroke = self._apply_stroke(gp)
|
|
959
|
+
if stroke[3] > 0:
|
|
960
|
+
ctx.stroke()
|
|
961
|
+
else:
|
|
962
|
+
ctx.new_path()
|
|
963
|
+
ctx.restore()
|
|
964
|
+
|
|
965
|
+
def draw_path(
|
|
966
|
+
self,
|
|
967
|
+
x: np.ndarray,
|
|
968
|
+
y: np.ndarray,
|
|
969
|
+
path_id: np.ndarray,
|
|
970
|
+
rule: str = "winding",
|
|
971
|
+
gp: Optional[Gpar] = None,
|
|
972
|
+
) -> None:
|
|
973
|
+
ctx = self._ctx
|
|
974
|
+
ctx.save()
|
|
975
|
+
|
|
976
|
+
fill_rule = (
|
|
977
|
+
cairo.FILL_RULE_EVEN_ODD
|
|
978
|
+
if rule == "evenodd"
|
|
979
|
+
else cairo.FILL_RULE_WINDING
|
|
980
|
+
)
|
|
981
|
+
ctx.set_fill_rule(fill_rule)
|
|
982
|
+
|
|
983
|
+
for pid in np.unique(path_id):
|
|
984
|
+
mask = path_id == pid
|
|
985
|
+
px = x[mask]
|
|
986
|
+
py = y[mask]
|
|
987
|
+
if len(px) < 2:
|
|
988
|
+
continue
|
|
989
|
+
ctx.move_to(px[0], py[0])
|
|
990
|
+
for i in range(1, len(px)):
|
|
991
|
+
ctx.line_to(px[i], py[i])
|
|
992
|
+
ctx.close_path()
|
|
993
|
+
|
|
994
|
+
if self._path_collecting:
|
|
995
|
+
ctx.restore()
|
|
996
|
+
return
|
|
997
|
+
|
|
998
|
+
bbox = (float(np.min(x)), float(np.min(y)),
|
|
999
|
+
float(np.ptp(x)), float(np.ptp(y)))
|
|
1000
|
+
self._apply_fill(gp, bbox=bbox)
|
|
1001
|
+
|
|
1002
|
+
stroke = self._apply_stroke(gp)
|
|
1003
|
+
if stroke[3] > 0:
|
|
1004
|
+
ctx.stroke()
|
|
1005
|
+
else:
|
|
1006
|
+
ctx.new_path()
|
|
1007
|
+
ctx.restore()
|
|
1008
|
+
|
|
1009
|
+
def draw_text(
|
|
1010
|
+
self,
|
|
1011
|
+
x: float,
|
|
1012
|
+
y: float,
|
|
1013
|
+
label: str,
|
|
1014
|
+
rot: float = 0.0,
|
|
1015
|
+
hjust: float = 0.5,
|
|
1016
|
+
vjust: float = 0.5,
|
|
1017
|
+
gp: Optional[Gpar] = None,
|
|
1018
|
+
) -> None:
|
|
1019
|
+
ctx = self._ctx
|
|
1020
|
+
label_str = str(label)
|
|
1021
|
+
|
|
1022
|
+
# R's grid.text splits on \n and draws each line separately
|
|
1023
|
+
# with line spacing = 1.2 * font height (R grDevices default).
|
|
1024
|
+
lines = label_str.split("\n") if "\n" in label_str else None
|
|
1025
|
+
|
|
1026
|
+
if lines is None:
|
|
1027
|
+
# Single-line fast path (original logic)
|
|
1028
|
+
ctx.save()
|
|
1029
|
+
self._set_font(gp)
|
|
1030
|
+
self._apply_stroke(gp)
|
|
1031
|
+
|
|
1032
|
+
ext = ctx.text_extents(label_str)
|
|
1033
|
+
tw = ext.width
|
|
1034
|
+
th = ext.height
|
|
1035
|
+
|
|
1036
|
+
off_x = -tw * hjust
|
|
1037
|
+
off_y = th * vjust
|
|
1038
|
+
|
|
1039
|
+
if rot != 0.0:
|
|
1040
|
+
ctx.translate(x, y)
|
|
1041
|
+
ctx.rotate(-math.radians(rot))
|
|
1042
|
+
ctx.move_to(off_x, off_y)
|
|
1043
|
+
else:
|
|
1044
|
+
ctx.move_to(x + off_x, y + off_y)
|
|
1045
|
+
|
|
1046
|
+
if self._path_collecting:
|
|
1047
|
+
ctx.text_path(label_str)
|
|
1048
|
+
else:
|
|
1049
|
+
ctx.show_text(label_str)
|
|
1050
|
+
ctx.restore()
|
|
1051
|
+
else:
|
|
1052
|
+
# Multi-line: split on \n, draw each line with line spacing.
|
|
1053
|
+
# R's inter-line gap = lineheight × fontsize × 1.2 / 72 inches
|
|
1054
|
+
# (= ``cra[1] × ipr[1] / default_ps`` on the standard device).
|
|
1055
|
+
# In cairo device units this is ``device_fs × lineheight × 1.2``.
|
|
1056
|
+
ctx.save()
|
|
1057
|
+
device_fs = self._set_font(gp)
|
|
1058
|
+
self._apply_stroke(gp)
|
|
1059
|
+
|
|
1060
|
+
# Resolve lineheight from gp (R default = 1.2).
|
|
1061
|
+
lineheight = 1.2
|
|
1062
|
+
if gp is not None:
|
|
1063
|
+
lh = gp.get("lineheight", None)
|
|
1064
|
+
if lh is not None:
|
|
1065
|
+
lineheight = float(lh[0] if isinstance(lh, (list, tuple)) else lh)
|
|
1066
|
+
|
|
1067
|
+
# Measure each line and compute total block size.
|
|
1068
|
+
line_extents = [ctx.text_extents(ln) for ln in lines]
|
|
1069
|
+
single_h = ctx.text_extents("Mg").height # ink-based first-baseline offset
|
|
1070
|
+
line_spacing = device_fs * lineheight * 1.2 # R's inter-line gap
|
|
1071
|
+
n_lines = len(lines)
|
|
1072
|
+
|
|
1073
|
+
max_tw = max((e.width for e in line_extents), default=0)
|
|
1074
|
+
# Block height: ink of first line + (n - 1) × gap, mirroring R's
|
|
1075
|
+
# heightDetails.text (single-line = ink; extra lines add gap).
|
|
1076
|
+
total_h = single_h + (n_lines - 1) * line_spacing
|
|
1077
|
+
|
|
1078
|
+
# Block offset so that (hjust, vjust) refer to the whole block.
|
|
1079
|
+
block_off_x = -max_tw * hjust
|
|
1080
|
+
block_off_y = -total_h * (1 - vjust) + single_h
|
|
1081
|
+
|
|
1082
|
+
if rot != 0.0:
|
|
1083
|
+
ctx.translate(x, y)
|
|
1084
|
+
ctx.rotate(-math.radians(rot))
|
|
1085
|
+
else:
|
|
1086
|
+
ctx.translate(x, y)
|
|
1087
|
+
|
|
1088
|
+
for k, ln in enumerate(lines):
|
|
1089
|
+
lw = line_extents[k].width
|
|
1090
|
+
# Per-line horizontal alignment within the block
|
|
1091
|
+
lx = block_off_x + (max_tw - lw) * hjust
|
|
1092
|
+
ly = block_off_y + k * line_spacing
|
|
1093
|
+
ctx.move_to(lx, ly)
|
|
1094
|
+
if self._path_collecting:
|
|
1095
|
+
ctx.text_path(ln)
|
|
1096
|
+
else:
|
|
1097
|
+
ctx.show_text(ln)
|
|
1098
|
+
|
|
1099
|
+
ctx.restore()
|
|
1100
|
+
|
|
1101
|
+
# ------------------------------------------------------------------ #
|
|
1102
|
+
# pch shape path helpers (R pch 0-25) #
|
|
1103
|
+
# ------------------------------------------------------------------ #
|
|
1104
|
+
|
|
1105
|
+
@staticmethod
|
|
1106
|
+
def _pch_path_circle(ctx: Any, cx: float, cy: float, r: float) -> None:
|
|
1107
|
+
ctx.arc(cx, cy, r, 0, 2 * math.pi)
|
|
1108
|
+
|
|
1109
|
+
@staticmethod
|
|
1110
|
+
def _pch_path_square(ctx: Any, cx: float, cy: float, r: float) -> None:
|
|
1111
|
+
ctx.rectangle(cx - r, cy - r, 2 * r, 2 * r)
|
|
1112
|
+
|
|
1113
|
+
@staticmethod
|
|
1114
|
+
def _pch_path_triangle_up(ctx: Any, cx: float, cy: float, r: float) -> None:
|
|
1115
|
+
h = r * 1.2 # slightly taller for visual balance
|
|
1116
|
+
ctx.move_to(cx, cy - h)
|
|
1117
|
+
ctx.line_to(cx - h, cy + h * 0.7)
|
|
1118
|
+
ctx.line_to(cx + h, cy + h * 0.7)
|
|
1119
|
+
ctx.close_path()
|
|
1120
|
+
|
|
1121
|
+
@staticmethod
|
|
1122
|
+
def _pch_path_triangle_down(ctx: Any, cx: float, cy: float, r: float) -> None:
|
|
1123
|
+
h = r * 1.2
|
|
1124
|
+
ctx.move_to(cx, cy + h)
|
|
1125
|
+
ctx.line_to(cx - h, cy - h * 0.7)
|
|
1126
|
+
ctx.line_to(cx + h, cy - h * 0.7)
|
|
1127
|
+
ctx.close_path()
|
|
1128
|
+
|
|
1129
|
+
@staticmethod
|
|
1130
|
+
def _pch_path_diamond(ctx: Any, cx: float, cy: float, r: float) -> None:
|
|
1131
|
+
ctx.move_to(cx, cy - r)
|
|
1132
|
+
ctx.line_to(cx + r, cy)
|
|
1133
|
+
ctx.line_to(cx, cy + r)
|
|
1134
|
+
ctx.line_to(cx - r, cy)
|
|
1135
|
+
ctx.close_path()
|
|
1136
|
+
|
|
1137
|
+
@staticmethod
|
|
1138
|
+
def _pch_path_plus(ctx: Any, cx: float, cy: float, r: float) -> None:
|
|
1139
|
+
ctx.move_to(cx - r, cy)
|
|
1140
|
+
ctx.line_to(cx + r, cy)
|
|
1141
|
+
ctx.move_to(cx, cy - r)
|
|
1142
|
+
ctx.line_to(cx, cy + r)
|
|
1143
|
+
|
|
1144
|
+
@staticmethod
|
|
1145
|
+
def _pch_path_cross(ctx: Any, cx: float, cy: float, r: float) -> None:
|
|
1146
|
+
d = r * 0.707 # r / sqrt(2)
|
|
1147
|
+
ctx.move_to(cx - d, cy - d)
|
|
1148
|
+
ctx.line_to(cx + d, cy + d)
|
|
1149
|
+
ctx.move_to(cx - d, cy + d)
|
|
1150
|
+
ctx.line_to(cx + d, cy - d)
|
|
1151
|
+
|
|
1152
|
+
@staticmethod
|
|
1153
|
+
def _pch_path_asterisk(ctx: Any, cx: float, cy: float, r: float) -> None:
|
|
1154
|
+
"""6-armed asterisk (pch 8)."""
|
|
1155
|
+
for angle_deg in (0, 60, 120):
|
|
1156
|
+
a = math.radians(angle_deg)
|
|
1157
|
+
dx = r * math.cos(a)
|
|
1158
|
+
dy = r * math.sin(a)
|
|
1159
|
+
ctx.move_to(cx - dx, cy - dy)
|
|
1160
|
+
ctx.line_to(cx + dx, cy + dy)
|
|
1161
|
+
|
|
1162
|
+
@staticmethod
|
|
1163
|
+
def _pch_path_star(ctx: Any, cx: float, cy: float, r: float) -> None:
|
|
1164
|
+
"""5-pointed star outline (pch 11) – two overlaid triangles."""
|
|
1165
|
+
h = r * 1.2
|
|
1166
|
+
# up triangle
|
|
1167
|
+
ctx.move_to(cx, cy - h)
|
|
1168
|
+
ctx.line_to(cx - h, cy + h * 0.7)
|
|
1169
|
+
ctx.line_to(cx + h, cy + h * 0.7)
|
|
1170
|
+
ctx.close_path()
|
|
1171
|
+
# down triangle
|
|
1172
|
+
ctx.move_to(cx, cy + h)
|
|
1173
|
+
ctx.line_to(cx - h, cy - h * 0.7)
|
|
1174
|
+
ctx.line_to(cx + h, cy - h * 0.7)
|
|
1175
|
+
ctx.close_path()
|
|
1176
|
+
|
|
1177
|
+
def _draw_pch_shape(
|
|
1178
|
+
self,
|
|
1179
|
+
ctx: Any,
|
|
1180
|
+
pch_val: int,
|
|
1181
|
+
cx: float,
|
|
1182
|
+
cy: float,
|
|
1183
|
+
r: float,
|
|
1184
|
+
col_rgba: Tuple[float, float, float, float],
|
|
1185
|
+
fill_rgba: Tuple[float, float, float, float],
|
|
1186
|
+
lwd: float,
|
|
1187
|
+
) -> None:
|
|
1188
|
+
"""Draw a single R pch shape at (*cx*, *cy*) with radius *r*.
|
|
1189
|
+
|
|
1190
|
+
Follows R semantics for pch groups:
|
|
1191
|
+
- 0-14 : open / line-only shapes — stroke with *col*, no fill
|
|
1192
|
+
- 15-18: solid filled shapes — both stroke and fill use *col*
|
|
1193
|
+
- 19-20: filled circles — fill and stroke use *col*
|
|
1194
|
+
- 21-25: filled shapes with separate fill and col
|
|
1195
|
+
"""
|
|
1196
|
+
ctx.save()
|
|
1197
|
+
ctx.new_path() # clear any residual path from prior draws
|
|
1198
|
+
ctx.set_line_width(lwd)
|
|
1199
|
+
|
|
1200
|
+
if pch_val <= 14:
|
|
1201
|
+
# --- Group 0-14: stroke-only (use col for outline, no fill) ---
|
|
1202
|
+
if pch_val == 0: # square open
|
|
1203
|
+
self._pch_path_square(ctx, cx, cy, r)
|
|
1204
|
+
elif pch_val == 1: # circle open
|
|
1205
|
+
self._pch_path_circle(ctx, cx, cy, r)
|
|
1206
|
+
elif pch_val == 2: # triangle open
|
|
1207
|
+
self._pch_path_triangle_up(ctx, cx, cy, r)
|
|
1208
|
+
elif pch_val == 3: # plus
|
|
1209
|
+
self._pch_path_plus(ctx, cx, cy, r)
|
|
1210
|
+
elif pch_val == 4: # cross (×)
|
|
1211
|
+
self._pch_path_cross(ctx, cx, cy, r)
|
|
1212
|
+
elif pch_val == 5: # diamond open
|
|
1213
|
+
self._pch_path_diamond(ctx, cx, cy, r)
|
|
1214
|
+
elif pch_val == 6: # triangle down open
|
|
1215
|
+
self._pch_path_triangle_down(ctx, cx, cy, r)
|
|
1216
|
+
elif pch_val == 7: # square cross
|
|
1217
|
+
self._pch_path_square(ctx, cx, cy, r)
|
|
1218
|
+
self._pch_path_cross(ctx, cx, cy, r)
|
|
1219
|
+
elif pch_val == 8: # asterisk
|
|
1220
|
+
self._pch_path_asterisk(ctx, cx, cy, r)
|
|
1221
|
+
elif pch_val == 9: # diamond plus
|
|
1222
|
+
self._pch_path_diamond(ctx, cx, cy, r)
|
|
1223
|
+
self._pch_path_plus(ctx, cx, cy, r)
|
|
1224
|
+
elif pch_val == 10: # circle plus
|
|
1225
|
+
self._pch_path_circle(ctx, cx, cy, r)
|
|
1226
|
+
self._pch_path_plus(ctx, cx, cy, r)
|
|
1227
|
+
elif pch_val == 11: # star (two triangles)
|
|
1228
|
+
self._pch_path_star(ctx, cx, cy, r)
|
|
1229
|
+
elif pch_val == 12: # square plus
|
|
1230
|
+
self._pch_path_square(ctx, cx, cy, r)
|
|
1231
|
+
self._pch_path_plus(ctx, cx, cy, r)
|
|
1232
|
+
elif pch_val == 13: # circle cross
|
|
1233
|
+
self._pch_path_circle(ctx, cx, cy, r)
|
|
1234
|
+
self._pch_path_cross(ctx, cx, cy, r)
|
|
1235
|
+
elif pch_val == 14: # square triangle
|
|
1236
|
+
self._pch_path_square(ctx, cx, cy, r)
|
|
1237
|
+
self._pch_path_triangle_up(ctx, cx, cy, r)
|
|
1238
|
+
|
|
1239
|
+
if col_rgba[3] > 0:
|
|
1240
|
+
ctx.set_source_rgba(*col_rgba)
|
|
1241
|
+
ctx.stroke()
|
|
1242
|
+
else:
|
|
1243
|
+
ctx.new_path()
|
|
1244
|
+
|
|
1245
|
+
elif pch_val <= 20:
|
|
1246
|
+
# --- Group 15-20: solid filled — col used for both fill & stroke ---
|
|
1247
|
+
if pch_val == 15: # square
|
|
1248
|
+
self._pch_path_square(ctx, cx, cy, r)
|
|
1249
|
+
elif pch_val == 16: # circle small
|
|
1250
|
+
self._pch_path_circle(ctx, cx, cy, r * 0.75)
|
|
1251
|
+
elif pch_val == 17: # triangle
|
|
1252
|
+
self._pch_path_triangle_up(ctx, cx, cy, r)
|
|
1253
|
+
elif pch_val == 18: # diamond
|
|
1254
|
+
self._pch_path_diamond(ctx, cx, cy, r)
|
|
1255
|
+
elif pch_val == 19: # circle (default)
|
|
1256
|
+
self._pch_path_circle(ctx, cx, cy, r)
|
|
1257
|
+
elif pch_val == 20: # bullet (small)
|
|
1258
|
+
self._pch_path_circle(ctx, cx, cy, r * 0.6)
|
|
1259
|
+
|
|
1260
|
+
if col_rgba[3] > 0:
|
|
1261
|
+
ctx.set_source_rgba(*col_rgba)
|
|
1262
|
+
ctx.fill_preserve()
|
|
1263
|
+
ctx.set_source_rgba(*col_rgba)
|
|
1264
|
+
ctx.stroke()
|
|
1265
|
+
else:
|
|
1266
|
+
ctx.new_path()
|
|
1267
|
+
|
|
1268
|
+
else:
|
|
1269
|
+
# --- Group 21-25: separate fill and col (stroke) ---
|
|
1270
|
+
if pch_val == 21: # circle filled
|
|
1271
|
+
self._pch_path_circle(ctx, cx, cy, r)
|
|
1272
|
+
elif pch_val == 22: # square filled
|
|
1273
|
+
self._pch_path_square(ctx, cx, cy, r)
|
|
1274
|
+
elif pch_val == 23: # diamond filled
|
|
1275
|
+
self._pch_path_diamond(ctx, cx, cy, r)
|
|
1276
|
+
elif pch_val == 24: # triangle filled
|
|
1277
|
+
self._pch_path_triangle_up(ctx, cx, cy, r)
|
|
1278
|
+
elif pch_val == 25: # triangle down filled
|
|
1279
|
+
self._pch_path_triangle_down(ctx, cx, cy, r)
|
|
1280
|
+
else:
|
|
1281
|
+
# fallback: circle
|
|
1282
|
+
self._pch_path_circle(ctx, cx, cy, r)
|
|
1283
|
+
|
|
1284
|
+
if fill_rgba[3] > 0:
|
|
1285
|
+
ctx.set_source_rgba(*fill_rgba)
|
|
1286
|
+
ctx.fill_preserve()
|
|
1287
|
+
else:
|
|
1288
|
+
ctx.new_path()
|
|
1289
|
+
# re-draw path for stroke
|
|
1290
|
+
if pch_val == 21:
|
|
1291
|
+
self._pch_path_circle(ctx, cx, cy, r)
|
|
1292
|
+
elif pch_val == 22:
|
|
1293
|
+
self._pch_path_square(ctx, cx, cy, r)
|
|
1294
|
+
elif pch_val == 23:
|
|
1295
|
+
self._pch_path_diamond(ctx, cx, cy, r)
|
|
1296
|
+
elif pch_val == 24:
|
|
1297
|
+
self._pch_path_triangle_up(ctx, cx, cy, r)
|
|
1298
|
+
elif pch_val == 25:
|
|
1299
|
+
self._pch_path_triangle_down(ctx, cx, cy, r)
|
|
1300
|
+
else:
|
|
1301
|
+
self._pch_path_circle(ctx, cx, cy, r)
|
|
1302
|
+
|
|
1303
|
+
if col_rgba[3] > 0:
|
|
1304
|
+
ctx.set_source_rgba(*col_rgba)
|
|
1305
|
+
ctx.stroke()
|
|
1306
|
+
else:
|
|
1307
|
+
ctx.new_path()
|
|
1308
|
+
|
|
1309
|
+
ctx.restore()
|
|
1310
|
+
|
|
1311
|
+
def draw_points(
|
|
1312
|
+
self,
|
|
1313
|
+
x: np.ndarray,
|
|
1314
|
+
y: np.ndarray,
|
|
1315
|
+
size: float = 1.0,
|
|
1316
|
+
pch: Any = 19,
|
|
1317
|
+
gp: Optional[Gpar] = None,
|
|
1318
|
+
) -> None:
|
|
1319
|
+
"""Draw point markers with full R pch 0-25 support.
|
|
1320
|
+
|
|
1321
|
+
Parameters
|
|
1322
|
+
----------
|
|
1323
|
+
x, y : array-like
|
|
1324
|
+
Point coordinates in NPC.
|
|
1325
|
+
size : float
|
|
1326
|
+
Fallback symbol size (used when ``gp.fontsize`` is absent).
|
|
1327
|
+
pch : int, array-like of int
|
|
1328
|
+
Plotting character code(s). Scalar → same shape for all points;
|
|
1329
|
+
array → per-point shapes.
|
|
1330
|
+
gp : Gpar or None
|
|
1331
|
+
Graphical parameters (col, fill, fontsize, lwd, …).
|
|
1332
|
+
"""
|
|
1333
|
+
ctx = self._ctx
|
|
1334
|
+
ctx.save()
|
|
1335
|
+
|
|
1336
|
+
n = len(x)
|
|
1337
|
+
if n == 0:
|
|
1338
|
+
ctx.restore()
|
|
1339
|
+
return
|
|
1340
|
+
|
|
1341
|
+
# --- per-point pch array ---
|
|
1342
|
+
if isinstance(pch, (list, tuple, np.ndarray)):
|
|
1343
|
+
pch_arr = np.asarray(pch, dtype=int)
|
|
1344
|
+
else:
|
|
1345
|
+
pch_arr = np.full(n, int(pch), dtype=int)
|
|
1346
|
+
if len(pch_arr) < n:
|
|
1347
|
+
pch_arr = np.resize(pch_arr, n)
|
|
1348
|
+
|
|
1349
|
+
# --- per-point sizes from gpar.fontsize (R: cex * fontsize) ---
|
|
1350
|
+
fs = gp.get("fontsize", None) if gp else None
|
|
1351
|
+
if isinstance(fs, (list, tuple, np.ndarray)):
|
|
1352
|
+
size_arr = np.asarray(fs, dtype=float)
|
|
1353
|
+
elif fs is not None:
|
|
1354
|
+
size_arr = np.full(n, float(fs))
|
|
1355
|
+
else:
|
|
1356
|
+
size_arr = np.full(n, float(size))
|
|
1357
|
+
|
|
1358
|
+
# Radius conversion: fontsize (pt) → device pixels
|
|
1359
|
+
scale = self.dpi / 72.0 * 0.5 if self._surface_type == "image" else 0.5
|
|
1360
|
+
|
|
1361
|
+
# --- per-point colours (col) ---
|
|
1362
|
+
col_raw = gp.get("col", None) if gp else None
|
|
1363
|
+
if isinstance(col_raw, (list, tuple, np.ndarray)) and len(col_raw) >= n:
|
|
1364
|
+
col_list = [_parse_colour(c) for c in col_raw[:n]]
|
|
1365
|
+
elif col_raw is not None:
|
|
1366
|
+
c0 = _parse_colour(col_raw[0] if isinstance(col_raw, (list, tuple)) else col_raw)
|
|
1367
|
+
col_list = [c0] * n
|
|
1368
|
+
else:
|
|
1369
|
+
col_list = [(0.0, 0.0, 0.0, 1.0)] * n
|
|
1370
|
+
|
|
1371
|
+
# --- per-point fill colours ---
|
|
1372
|
+
fill_raw = gp.get("fill", None) if gp else None
|
|
1373
|
+
if isinstance(fill_raw, (list, tuple, np.ndarray)) and len(fill_raw) >= n:
|
|
1374
|
+
fill_list = [_parse_colour(c) for c in fill_raw[:n]]
|
|
1375
|
+
elif fill_raw is not None:
|
|
1376
|
+
f0 = _parse_colour(fill_raw[0] if isinstance(fill_raw, (list, tuple)) else fill_raw)
|
|
1377
|
+
fill_list = [f0] * n
|
|
1378
|
+
else:
|
|
1379
|
+
fill_list = [(0.0, 0.0, 0.0, 0.0)] * n
|
|
1380
|
+
|
|
1381
|
+
# --- per-point lwd ---
|
|
1382
|
+
lwd_raw = gp.get("lwd", None) if gp else None
|
|
1383
|
+
if isinstance(lwd_raw, (list, tuple, np.ndarray)):
|
|
1384
|
+
lwd_arr = np.asarray(lwd_raw, dtype=float)
|
|
1385
|
+
elif lwd_raw is not None:
|
|
1386
|
+
lwd_arr = np.full(n, float(lwd_raw))
|
|
1387
|
+
else:
|
|
1388
|
+
lwd_arr = np.full(n, 1.0)
|
|
1389
|
+
|
|
1390
|
+
for i in range(n):
|
|
1391
|
+
cx = x[i]
|
|
1392
|
+
cy = y[i]
|
|
1393
|
+
r = size_arr[i] * scale if i < len(size_arr) else size * scale
|
|
1394
|
+
lwd_i = float(lwd_arr[i % len(lwd_arr)])
|
|
1395
|
+
self._draw_pch_shape(
|
|
1396
|
+
ctx, int(pch_arr[i]), cx, cy, r,
|
|
1397
|
+
col_rgba=col_list[i],
|
|
1398
|
+
fill_rgba=fill_list[i],
|
|
1399
|
+
lwd=lwd_i,
|
|
1400
|
+
)
|
|
1401
|
+
|
|
1402
|
+
ctx.restore()
|
|
1403
|
+
|
|
1404
|
+
def draw_raster(
|
|
1405
|
+
self,
|
|
1406
|
+
image: Any,
|
|
1407
|
+
x: float,
|
|
1408
|
+
y: float,
|
|
1409
|
+
w: float,
|
|
1410
|
+
h: float,
|
|
1411
|
+
interpolate: bool = True,
|
|
1412
|
+
) -> None:
|
|
1413
|
+
ctx = self._ctx
|
|
1414
|
+
ctx.save()
|
|
1415
|
+
|
|
1416
|
+
img_array = np.asarray(image)
|
|
1417
|
+
|
|
1418
|
+
# Handle colour string arrays (e.g. from colourbar raster)
|
|
1419
|
+
# Convert colour strings to uint8 RGBA
|
|
1420
|
+
if img_array.dtype.kind in ("U", "S", "O"):
|
|
1421
|
+
h_img, w_img = img_array.shape[:2]
|
|
1422
|
+
rgba = np.zeros((h_img, w_img, 4), dtype=np.uint8)
|
|
1423
|
+
for r in range(h_img):
|
|
1424
|
+
for c in range(w_img):
|
|
1425
|
+
colour = img_array[r, c] if img_array.ndim >= 2 else img_array[r]
|
|
1426
|
+
cr, cg, cb, ca = _parse_colour(str(colour))
|
|
1427
|
+
rgba[r, c] = [int(cr * 255), int(cg * 255),
|
|
1428
|
+
int(cb * 255), int(ca * 255)]
|
|
1429
|
+
img_array = rgba
|
|
1430
|
+
else:
|
|
1431
|
+
img_array = img_array.astype(np.uint8)
|
|
1432
|
+
if img_array.ndim == 2:
|
|
1433
|
+
# Greyscale → RGBA
|
|
1434
|
+
rgba = np.stack([img_array] * 3 + [np.full_like(img_array, 255)], axis=-1)
|
|
1435
|
+
elif img_array.ndim == 3 and img_array.shape[2] == 3:
|
|
1436
|
+
# RGB → RGBA
|
|
1437
|
+
rgba = np.concatenate(
|
|
1438
|
+
[img_array, np.full((*img_array.shape[:2], 1), 255, dtype=np.uint8)],
|
|
1439
|
+
axis=2,
|
|
1440
|
+
)
|
|
1441
|
+
elif img_array.ndim == 3 and img_array.shape[2] == 4:
|
|
1442
|
+
rgba = img_array
|
|
1443
|
+
else:
|
|
1444
|
+
ctx.restore()
|
|
1445
|
+
return
|
|
1446
|
+
|
|
1447
|
+
# Cairo expects BGRA premultiplied in native byte order
|
|
1448
|
+
img_h, img_w = rgba.shape[:2]
|
|
1449
|
+
bgra = np.empty((img_h, img_w, 4), dtype=np.uint8)
|
|
1450
|
+
bgra[:, :, 0] = rgba[:, :, 2] # B
|
|
1451
|
+
bgra[:, :, 1] = rgba[:, :, 1] # G
|
|
1452
|
+
bgra[:, :, 2] = rgba[:, :, 0] # R
|
|
1453
|
+
bgra[:, :, 3] = rgba[:, :, 3] # A
|
|
1454
|
+
|
|
1455
|
+
stride = cairo.ImageSurface.format_stride_for_width(
|
|
1456
|
+
cairo.FORMAT_ARGB32, img_w
|
|
1457
|
+
)
|
|
1458
|
+
img_surface = cairo.ImageSurface.create_for_data(
|
|
1459
|
+
bytearray(bgra.tobytes()), cairo.FORMAT_ARGB32, img_w, img_h, stride
|
|
1460
|
+
)
|
|
1461
|
+
|
|
1462
|
+
# x, y, w, h are in device coords; y is top-left origin
|
|
1463
|
+
ctx.translate(x, y)
|
|
1464
|
+
ctx.scale(w / img_w, h / img_h)
|
|
1465
|
+
ctx.set_source_surface(img_surface, 0, 0)
|
|
1466
|
+
pattern = ctx.get_source()
|
|
1467
|
+
if interpolate:
|
|
1468
|
+
pattern.set_filter(cairo.FILTER_BILINEAR)
|
|
1469
|
+
else:
|
|
1470
|
+
pattern.set_filter(cairo.FILTER_NEAREST)
|
|
1471
|
+
# Cairo default pattern.extend is EXTEND_NONE, which under
|
|
1472
|
+
# BILINEAR filtering samples *outside* the image into
|
|
1473
|
+
# transparent pixels — producing a soft halo that extends
|
|
1474
|
+
# far beyond the declared raster bounds (observed as the
|
|
1475
|
+
# "fuzzy colorbar" in vertical gradient legends). EXTEND_PAD
|
|
1476
|
+
# clamps the outside samples to the edge pixel, matching R's
|
|
1477
|
+
# behaviour where rasterGrob paints exactly within its extent.
|
|
1478
|
+
pattern.set_extend(cairo.EXTEND_PAD)
|
|
1479
|
+
# Use rectangle+fill instead of paint() so the raster is
|
|
1480
|
+
# confined to its declared w x h; paint() fills the unbounded
|
|
1481
|
+
# clip region under the transformed pattern and the edge-pad
|
|
1482
|
+
# extension would otherwise tile the edge colour outwards.
|
|
1483
|
+
ctx.rectangle(0, 0, img_w, img_h)
|
|
1484
|
+
ctx.fill()
|
|
1485
|
+
|
|
1486
|
+
ctx.restore()
|
|
1487
|
+
|
|
1488
|
+
def draw_roundrect(
|
|
1489
|
+
self,
|
|
1490
|
+
x: float,
|
|
1491
|
+
y: float,
|
|
1492
|
+
w: float,
|
|
1493
|
+
h: float,
|
|
1494
|
+
r: float = 0.0,
|
|
1495
|
+
hjust: float = 0.5,
|
|
1496
|
+
vjust: float = 0.5,
|
|
1497
|
+
gp: Optional[Gpar] = None,
|
|
1498
|
+
) -> None:
|
|
1499
|
+
"""Draw a rounded rectangle. All coords in device units."""
|
|
1500
|
+
ctx = self._ctx
|
|
1501
|
+
ctx.save()
|
|
1502
|
+
|
|
1503
|
+
dx = x - w * hjust
|
|
1504
|
+
dy = y - h * (1.0 - vjust)
|
|
1505
|
+
dr = min(r, w / 2, h / 2)
|
|
1506
|
+
|
|
1507
|
+
if dr <= 0:
|
|
1508
|
+
ctx.rectangle(dx, dy, w, h)
|
|
1509
|
+
else:
|
|
1510
|
+
ctx.new_path()
|
|
1511
|
+
ctx.arc(dx + w - dr, dy + dr, dr, -math.pi / 2, 0)
|
|
1512
|
+
ctx.arc(dx + w - dr, dy + h - dr, dr, 0, math.pi / 2)
|
|
1513
|
+
ctx.arc(dx + dr, dy + h - dr, dr, math.pi / 2, math.pi)
|
|
1514
|
+
ctx.arc(dx + dr, dy + dr, dr, math.pi, 3 * math.pi / 2)
|
|
1515
|
+
ctx.close_path()
|
|
1516
|
+
|
|
1517
|
+
if self._path_collecting:
|
|
1518
|
+
ctx.restore()
|
|
1519
|
+
return
|
|
1520
|
+
|
|
1521
|
+
bbox = (dx, dy, w, h)
|
|
1522
|
+
self._apply_fill(gp, bbox=bbox)
|
|
1523
|
+
|
|
1524
|
+
stroke = self._apply_stroke(gp)
|
|
1525
|
+
if stroke[3] > 0:
|
|
1526
|
+
ctx.stroke()
|
|
1527
|
+
else:
|
|
1528
|
+
ctx.new_path()
|
|
1529
|
+
|
|
1530
|
+
ctx.restore()
|
|
1531
|
+
|
|
1532
|
+
# ---- pen-position drawing (move.to / line.to) --------------------------
|
|
1533
|
+
|
|
1534
|
+
def move_to(self, x: float, y: float) -> None:
|
|
1535
|
+
self._pen_x = x
|
|
1536
|
+
self._pen_y = y
|
|
1537
|
+
|
|
1538
|
+
def line_to(self, x: float, y: float, gp: Optional[Gpar] = None) -> None:
|
|
1539
|
+
"""Draw line from pen to (x,y). Coords in device units."""
|
|
1540
|
+
ctx = self._ctx
|
|
1541
|
+
ctx.save()
|
|
1542
|
+
stroke = self._apply_stroke(gp)
|
|
1543
|
+
x0 = getattr(self, "_pen_x", 0.0)
|
|
1544
|
+
y0 = getattr(self, "_pen_y", 0.0)
|
|
1545
|
+
ctx.move_to(x0, y0)
|
|
1546
|
+
ctx.line_to(x, y)
|
|
1547
|
+
if stroke[3] > 0:
|
|
1548
|
+
ctx.stroke()
|
|
1549
|
+
else:
|
|
1550
|
+
ctx.new_path()
|
|
1551
|
+
self._pen_x = x
|
|
1552
|
+
self._pen_y = y
|
|
1553
|
+
ctx.restore()
|
|
1554
|
+
|
|
1555
|
+
# ---- clipping ----------------------------------------------------------
|
|
1556
|
+
|
|
1557
|
+
def push_clip(self, x0: float, y0: float, x1: float, y1: float) -> None:
|
|
1558
|
+
"""Push clip rectangle. Coords in device units."""
|
|
1559
|
+
self._ctx.save()
|
|
1560
|
+
cx = min(x0, x1)
|
|
1561
|
+
cy = min(y0, y1)
|
|
1562
|
+
cw = abs(x1 - x0)
|
|
1563
|
+
ch = abs(y1 - y0)
|
|
1564
|
+
self._ctx.rectangle(cx, cy, cw, ch)
|
|
1565
|
+
self._ctx.clip()
|
|
1566
|
+
|
|
1567
|
+
def pop_clip(self) -> None:
|
|
1568
|
+
self._ctx.restore()
|
|
1569
|
+
|
|
1570
|
+
# ---- page / output -----------------------------------------------------
|
|
1571
|
+
|
|
1572
|
+
def new_page(self, bg: Any = "white") -> None:
|
|
1573
|
+
"""Clear the surface and start a fresh page."""
|
|
1574
|
+
if self._surface_type == "image":
|
|
1575
|
+
# Clear and repaint background
|
|
1576
|
+
self._ctx.set_operator(cairo.OPERATOR_SOURCE)
|
|
1577
|
+
bg_rgba = _parse_colour(bg)
|
|
1578
|
+
self._ctx.set_source_rgba(*bg_rgba)
|
|
1579
|
+
self._ctx.paint()
|
|
1580
|
+
self._ctx.set_operator(cairo.OPERATOR_OVER)
|
|
1581
|
+
else:
|
|
1582
|
+
# Vector surfaces: show_page starts a new page
|
|
1583
|
+
self._ctx.show_page()
|
|
1584
|
+
|
|
1585
|
+
def get_surface(self) -> Any:
|
|
1586
|
+
"""Return the underlying Cairo surface (for raster capture)."""
|
|
1587
|
+
return self._surface
|
|
1588
|
+
|
|
1589
|
+
def write_to_png(self, filename: str) -> None:
|
|
1590
|
+
"""Write the current surface to a PNG file."""
|
|
1591
|
+
self._surface.write_to_png(filename)
|
|
1592
|
+
|
|
1593
|
+
def to_png_bytes(self) -> bytes:
|
|
1594
|
+
"""Return the current surface as PNG bytes."""
|
|
1595
|
+
buf = io.BytesIO()
|
|
1596
|
+
self._surface.write_to_png(buf)
|
|
1597
|
+
buf.seek(0)
|
|
1598
|
+
return buf.read()
|
|
1599
|
+
|
|
1600
|
+
def finish(self) -> None:
|
|
1601
|
+
"""Finalise the surface (required for PDF/SVG/PS)."""
|
|
1602
|
+
self._surface.finish()
|
|
1603
|
+
|
|
1604
|
+
# ---- text metrics (for _size.py) ---------------------------------------
|
|
1605
|
+
|
|
1606
|
+
def text_extents(
|
|
1607
|
+
self, text: str, gp: Optional[Gpar] = None
|
|
1608
|
+
) -> Dict[str, float]:
|
|
1609
|
+
"""Measure text dimensions in inches.
|
|
1610
|
+
|
|
1611
|
+
Returns dict with ``ascent``, ``descent``, ``width`` in inches.
|
|
1612
|
+
"""
|
|
1613
|
+
ctx = self._ctx
|
|
1614
|
+
ctx.save()
|
|
1615
|
+
self._set_font(gp)
|
|
1616
|
+
|
|
1617
|
+
fe = ctx.font_extents()
|
|
1618
|
+
te = ctx.text_extents(text)
|
|
1619
|
+
|
|
1620
|
+
# Convert from device units back to inches
|
|
1621
|
+
if self._surface_type == "image":
|
|
1622
|
+
scale = 1.0 / self.dpi
|
|
1623
|
+
else:
|
|
1624
|
+
scale = 1.0 / 72.0
|
|
1625
|
+
|
|
1626
|
+
ascent = fe[0] * scale
|
|
1627
|
+
descent = fe[1] * scale
|
|
1628
|
+
width = te.x_advance * scale
|
|
1629
|
+
|
|
1630
|
+
ctx.restore()
|
|
1631
|
+
return {"ascent": ascent, "descent": descent, "width": width}
|
|
1632
|
+
|
|
1633
|
+
# ---- group compositing (R 4.1+ / group.R) -------------------------------
|
|
1634
|
+
|
|
1635
|
+
# Porter-Duff + PDF blend mode → Cairo operator mapping
|
|
1636
|
+
# R group.R:272-287 lists all valid operators.
|
|
1637
|
+
_CAIRO_OPERATOR_MAP = {
|
|
1638
|
+
"clear": cairo.OPERATOR_CLEAR,
|
|
1639
|
+
"source": cairo.OPERATOR_SOURCE,
|
|
1640
|
+
"over": cairo.OPERATOR_OVER,
|
|
1641
|
+
"in": cairo.OPERATOR_IN,
|
|
1642
|
+
"out": cairo.OPERATOR_OUT,
|
|
1643
|
+
"atop": cairo.OPERATOR_ATOP,
|
|
1644
|
+
"dest": cairo.OPERATOR_DEST,
|
|
1645
|
+
"dest.over": cairo.OPERATOR_DEST_OVER,
|
|
1646
|
+
"dest.in": cairo.OPERATOR_DEST_IN,
|
|
1647
|
+
"dest.out": cairo.OPERATOR_DEST_OUT,
|
|
1648
|
+
"dest.atop": cairo.OPERATOR_DEST_ATOP,
|
|
1649
|
+
"xor": cairo.OPERATOR_XOR,
|
|
1650
|
+
"add": cairo.OPERATOR_ADD,
|
|
1651
|
+
"saturate": cairo.OPERATOR_SATURATE,
|
|
1652
|
+
# PDF blend modes (pycairo ≥ 1.12)
|
|
1653
|
+
"multiply": getattr(cairo, "OPERATOR_MULTIPLY", cairo.OPERATOR_OVER),
|
|
1654
|
+
"screen": getattr(cairo, "OPERATOR_SCREEN", cairo.OPERATOR_OVER),
|
|
1655
|
+
"overlay": getattr(cairo, "OPERATOR_OVERLAY", cairo.OPERATOR_OVER),
|
|
1656
|
+
"darken": getattr(cairo, "OPERATOR_DARKEN", cairo.OPERATOR_OVER),
|
|
1657
|
+
"lighten": getattr(cairo, "OPERATOR_LIGHTEN", cairo.OPERATOR_OVER),
|
|
1658
|
+
"color.dodge": getattr(cairo, "OPERATOR_COLOR_DODGE", cairo.OPERATOR_OVER),
|
|
1659
|
+
"color.burn": getattr(cairo, "OPERATOR_COLOR_BURN", cairo.OPERATOR_OVER),
|
|
1660
|
+
"hard.light": getattr(cairo, "OPERATOR_HARD_LIGHT", cairo.OPERATOR_OVER),
|
|
1661
|
+
"soft.light": getattr(cairo, "OPERATOR_SOFT_LIGHT", cairo.OPERATOR_OVER),
|
|
1662
|
+
"difference": getattr(cairo, "OPERATOR_DIFFERENCE", cairo.OPERATOR_OVER),
|
|
1663
|
+
"exclusion": getattr(cairo, "OPERATOR_EXCLUSION", cairo.OPERATOR_OVER),
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
def define_group(
|
|
1667
|
+
self, src_fn: Any, op: str = "over", dst_fn: Any = None,
|
|
1668
|
+
) -> Any:
|
|
1669
|
+
"""Define a compositing group on the Cairo surface.
|
|
1670
|
+
|
|
1671
|
+
Port of R ``.defineGroup(src, op, dst)`` (group.R:263,302).
|
|
1672
|
+
Uses ``cairo.Context.push_group()`` / ``pop_group()`` to capture
|
|
1673
|
+
source and destination content, then composites them.
|
|
1674
|
+
|
|
1675
|
+
Parameters
|
|
1676
|
+
----------
|
|
1677
|
+
src_fn : callable
|
|
1678
|
+
Function that draws the source content.
|
|
1679
|
+
op : str
|
|
1680
|
+
Compositing operator name.
|
|
1681
|
+
dst_fn : callable or None
|
|
1682
|
+
Function that draws the destination content (None = transparent).
|
|
1683
|
+
|
|
1684
|
+
Returns
|
|
1685
|
+
-------
|
|
1686
|
+
cairo.Pattern or None
|
|
1687
|
+
The composited group as a pattern, or None on failure.
|
|
1688
|
+
"""
|
|
1689
|
+
ctx = self._ctx
|
|
1690
|
+
cairo_op = self._CAIRO_OPERATOR_MAP.get(op, cairo.OPERATOR_OVER)
|
|
1691
|
+
|
|
1692
|
+
try:
|
|
1693
|
+
# Draw destination first (if any)
|
|
1694
|
+
if dst_fn is not None:
|
|
1695
|
+
ctx.push_group()
|
|
1696
|
+
dst_fn()
|
|
1697
|
+
dst_pattern = ctx.pop_group()
|
|
1698
|
+
else:
|
|
1699
|
+
dst_pattern = None
|
|
1700
|
+
|
|
1701
|
+
# Draw source
|
|
1702
|
+
ctx.push_group()
|
|
1703
|
+
src_fn()
|
|
1704
|
+
src_pattern = ctx.pop_group()
|
|
1705
|
+
|
|
1706
|
+
# Composite: dst first, then src with operator
|
|
1707
|
+
ctx.push_group()
|
|
1708
|
+
|
|
1709
|
+
if dst_pattern is not None:
|
|
1710
|
+
ctx.set_source(dst_pattern)
|
|
1711
|
+
ctx.paint()
|
|
1712
|
+
|
|
1713
|
+
ctx.set_operator(cairo_op)
|
|
1714
|
+
ctx.set_source(src_pattern)
|
|
1715
|
+
ctx.paint()
|
|
1716
|
+
ctx.set_operator(cairo.OPERATOR_OVER)
|
|
1717
|
+
|
|
1718
|
+
result = ctx.pop_group()
|
|
1719
|
+
return result
|
|
1720
|
+
|
|
1721
|
+
except Exception:
|
|
1722
|
+
return None
|
|
1723
|
+
|
|
1724
|
+
def use_group(self, ref: Any, transform: Any = None) -> None:
|
|
1725
|
+
"""Draw a previously defined group, optionally with a transform.
|
|
1726
|
+
|
|
1727
|
+
Port of R ``.useGroup(ref, transform)`` (group.R:269,345).
|
|
1728
|
+
|
|
1729
|
+
Parameters
|
|
1730
|
+
----------
|
|
1731
|
+
ref : cairo.Pattern
|
|
1732
|
+
The group pattern returned by :meth:`define_group`.
|
|
1733
|
+
transform : ndarray or None
|
|
1734
|
+
A 3x3 affine transformation matrix. When ``None``, the
|
|
1735
|
+
group is drawn as-is.
|
|
1736
|
+
"""
|
|
1737
|
+
if ref is None:
|
|
1738
|
+
return
|
|
1739
|
+
|
|
1740
|
+
ctx = self._ctx
|
|
1741
|
+
ctx.save()
|
|
1742
|
+
|
|
1743
|
+
if transform is not None:
|
|
1744
|
+
# Apply the 3x3 affine transform.
|
|
1745
|
+
# R uses row-vector convention: point @ matrix
|
|
1746
|
+
# Cairo uses column-vector: matrix * point
|
|
1747
|
+
# So we need to transpose the matrix for Cairo.
|
|
1748
|
+
import numpy as np
|
|
1749
|
+
m = np.asarray(transform, dtype=float)
|
|
1750
|
+
# Cairo Matrix(xx, yx, xy, yy, x0, y0) = column-major
|
|
1751
|
+
# From row-vector convention: m[0,0]=xx, m[1,0]=yx, m[0,1]=xy,
|
|
1752
|
+
# m[1,1]=yy, m[2,0]=x0, m[2,1]=y0
|
|
1753
|
+
cairo_matrix = cairo.Matrix(
|
|
1754
|
+
m[0, 0], m[1, 0], # xx, yx
|
|
1755
|
+
m[0, 1], m[1, 1], # xy, yy
|
|
1756
|
+
m[2, 0], m[2, 1], # x0, y0
|
|
1757
|
+
)
|
|
1758
|
+
ctx.transform(cairo_matrix)
|
|
1759
|
+
|
|
1760
|
+
ctx.set_source(ref)
|
|
1761
|
+
ctx.paint()
|
|
1762
|
+
ctx.restore()
|