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/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()