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.
@@ -0,0 +1,1184 @@
1
+ """Abstract base class for all grid_py rendering backends.
2
+
3
+ Provides the shared coordinate system (viewport transform stack, unit
4
+ resolution to **inches**, layout computation, and inches-to-device
5
+ coordinate helpers) that every backend needs. Subclasses implement the
6
+ actual drawing primitives and output methods.
7
+
8
+ The coordinate convention matches R's grid: the unit square [0, 1] × [0, 1]
9
+ with the origin at the **bottom-left**. Device coordinates use a top-left
10
+ origin (Y-flip is applied internally by :meth:`_to_dev_x` / :meth:`_to_dev_y`).
11
+
12
+ Coordinate pipeline (matches R's grid/src/unit.c + viewport.c):
13
+ Unit → _resolve_to_inches() → inches within viewport
14
+ → trans(location, viewport_transform) → absolute inches on device
15
+ → _to_dev_x/_to_dev_y → device coordinates (pixels or points)
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import math
21
+ from abc import ABC, abstractmethod
22
+ from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
23
+
24
+ import numpy as np
25
+
26
+ from ._vp_calc import (
27
+ ViewportContext,
28
+ ViewportTransformResult,
29
+ calc_root_transform,
30
+ calc_viewport_transform,
31
+ identity,
32
+ location,
33
+ trans,
34
+ transform_x_to_inches,
35
+ transform_y_to_inches,
36
+ transform_width_to_inches,
37
+ transform_height_to_inches,
38
+ _transform_to_inches,
39
+ _INCHES_PER,
40
+ )
41
+
42
+ __all__ = ["GridRenderer"]
43
+
44
+
45
+ class GridRenderer(ABC):
46
+ """Abstract base for all grid_py rendering backends.
47
+
48
+ Parameters
49
+ ----------
50
+ width : float
51
+ Device width in inches.
52
+ height : float
53
+ Device height in inches.
54
+ dpi : float
55
+ Dots per inch.
56
+ device_width : float or None
57
+ Root viewport width in device units. Defaults to ``width * dpi``
58
+ (appropriate for raster surfaces). Vector surfaces should pass
59
+ ``width * 72.0``.
60
+ device_height : float or None
61
+ Root viewport height in device units.
62
+ """
63
+
64
+ def __init__(
65
+ self,
66
+ width: float = 7.0,
67
+ height: float = 5.0,
68
+ dpi: float = 150.0,
69
+ device_width: Optional[float] = None,
70
+ device_height: Optional[float] = None,
71
+ ) -> None:
72
+ self.width_in: float = width
73
+ self.height_in: float = height
74
+ self.dpi: float = dpi
75
+
76
+ dw = float(device_width) if device_width is not None else width * dpi
77
+ dh = float(device_height) if device_height is not None else height * dpi
78
+ self._device_width: float = dw
79
+ self._device_height: float = dh
80
+
81
+ # Device dimensions in CM (used by calcViewportTransform)
82
+ self._device_width_cm: float = width * 2.54
83
+ self._device_height_cm: float = height * 2.54
84
+
85
+ # Scale factor: device units per inch
86
+ # For raster surfaces: dpi. For vector surfaces (PDF/SVG): 72.
87
+ self._dev_units_per_inch: float = dw / width if width > 0 else dpi
88
+
89
+ # Viewport transform stack. Each entry is a ViewportTransformResult
90
+ # containing width_cm, height_cm, rotation_angle, 3×3 transform matrix,
91
+ # and ViewportContext (xscale/yscale).
92
+ # The root entry represents the device itself.
93
+ root_vtr = calc_root_transform(self._device_width_cm, self._device_height_cm)
94
+ self._vp_transform_stack: List[ViewportTransformResult] = [root_vtr]
95
+
96
+ # Keep a parallel list of viewport objects for attribute access
97
+ self._vp_obj_stack: List[Any] = [None]
98
+
99
+ self._layout_stack: List[dict] = []
100
+ self._layout_depth_stack: List[int] = []
101
+ self._clip_stack: List[bool] = []
102
+ self._path_collecting: bool = False
103
+
104
+ # Pen position for move.to / line.to (in device coords now)
105
+ self._pen_x: float = 0.0
106
+ self._pen_y: float = 0.0
107
+
108
+ # Grob metadata (tooltip data attachment for web renderers)
109
+ self._current_grob_metadata: Optional[dict] = None
110
+
111
+ # ---- Backward compatibility: old _vp_stack API ----
112
+ # Some external code may still access _vp_stack. We provide a
113
+ # property that synthesises the old (x0, y0, pw, ph, vp_obj) tuples
114
+ # from the new transform stack.
115
+
116
+ @property
117
+ def _vp_stack(self) -> List[Tuple[float, float, float, float, Any]]:
118
+ """Backward-compatible viewport stack (device-unit tuples).
119
+
120
+ Synthesised from the new transform stack. Each entry is
121
+ ``(x0, y0, pw, ph, vp_obj)`` where (x0, y0) is the bottom-left
122
+ corner in device units and (pw, ph) are dimensions in device units.
123
+ """
124
+ result = []
125
+ for i, vtr in enumerate(self._vp_transform_stack):
126
+ vp_obj = self._vp_obj_stack[i] if i < len(self._vp_obj_stack) else None
127
+ # Bottom-left corner in inches (origin of viewport)
128
+ bl = trans(location(0.0, 0.0), vtr.transform)
129
+ w_in = vtr.width_cm / 2.54
130
+ h_in = vtr.height_cm / 2.54
131
+ x0 = bl[0] * self._dev_units_per_inch
132
+ y0_bottom = bl[1] * self._dev_units_per_inch
133
+ pw = w_in * self._dev_units_per_inch
134
+ ph = h_in * self._dev_units_per_inch
135
+ # Convert to top-left origin for device coords
136
+ y0_device = self._device_height - y0_bottom - ph
137
+ result.append((x0, y0_device, pw, ph, vp_obj))
138
+ return result
139
+
140
+ # ===================================================================== #
141
+ # Grob metadata (data attachment for interactive features) #
142
+ # ===================================================================== #
143
+
144
+ def set_grob_metadata(self, metadata: Optional[dict]) -> None:
145
+ self._current_grob_metadata = metadata
146
+
147
+ def clear_grob_metadata(self) -> None:
148
+ self._current_grob_metadata = None
149
+
150
+ # ===================================================================== #
151
+ # Public viewport-bounds API #
152
+ # ===================================================================== #
153
+
154
+ def get_viewport_bounds(self) -> Tuple[float, float, float, float]:
155
+ """Return ``(x0, y0, pw, ph)`` of the current viewport in device units.
156
+
157
+ Uses the backward-compatible synthesised bounds.
158
+ """
159
+ stack = self._vp_stack
160
+ e = stack[-1]
161
+ return (e[0], e[1], e[2], e[3])
162
+
163
+ def get_viewport_object(self) -> Any:
164
+ """Return the Viewport object of the current viewport, or ``None``."""
165
+ return self._vp_obj_stack[-1] if self._vp_obj_stack else None
166
+
167
+ def get_current_vtr(self) -> ViewportTransformResult:
168
+ """Return the current viewport's transform result."""
169
+ return self._vp_transform_stack[-1]
170
+
171
+ # ===================================================================== #
172
+ # Gpar extraction helpers #
173
+ # ===================================================================== #
174
+
175
+ def _gpar_font_params(self, gp: Optional[Any] = None) -> Tuple[float, float, float]:
176
+ """Extract (fontsize, cex, lineheight) from gpar for unit resolution."""
177
+ fontsize = 12.0
178
+ cex = 1.0
179
+ lineheight = 1.2
180
+ if gp is not None:
181
+ fs = gp.get("fontsize", None)
182
+ if fs is not None:
183
+ fontsize = float(fs[0] if isinstance(fs, (list, tuple)) else fs)
184
+ cx = gp.get("cex", None)
185
+ if cx is not None:
186
+ cex = float(cx[0] if isinstance(cx, (list, tuple)) else cx)
187
+ lh = gp.get("lineheight", None)
188
+ if lh is not None:
189
+ lineheight = float(lh[0] if isinstance(lh, (list, tuple)) else lh)
190
+ return fontsize, cex, lineheight
191
+
192
+ # ===================================================================== #
193
+ # Viewport management (shared across all backends) #
194
+ # ===================================================================== #
195
+
196
+ def push_viewport(self, vp: Any) -> None:
197
+ """Push a viewport, computing its 3×3 transform via calcViewportTransform.
198
+
199
+ Handles three viewport types:
200
+ 1. Layout viewport (has ``_layout``) -- stores grid, same transform
201
+ 2. Child viewport with ``layout_pos_row/col`` -- uses parent grid
202
+ 3. Simple viewport with x/y/width/height -- full transform calc
203
+ """
204
+ from ._units import Unit
205
+
206
+ parent_vtr = self._vp_transform_stack[-1]
207
+
208
+ layout = getattr(vp, "_layout", None)
209
+ layout_pos_row = getattr(vp, "_layout_pos_row", None)
210
+ layout_pos_col = getattr(vp, "_layout_pos_col", None)
211
+
212
+ # --- Case 2 (check first): Layout-positioned child ---
213
+ # Must be checked BEFORE Case 1: a viewport can have BOTH
214
+ # layout_pos (its position in the parent's layout) AND its own
215
+ # layout (for its children). In R, layout_pos determines the
216
+ # viewport's own size/position first, then the layout applies
217
+ # within that region.
218
+ if layout_pos_row is not None and layout_pos_col is not None:
219
+ if self._layout_stack:
220
+ grid = self._layout_stack[-1]
221
+ col_starts = grid["col_starts"]
222
+ col_widths = grid["col_widths"]
223
+ row_starts = grid["row_starts"]
224
+ row_heights = grid["row_heights"]
225
+
226
+ if isinstance(layout_pos_row, (list, tuple)):
227
+ t, b = int(layout_pos_row[0]) - 1, int(layout_pos_row[1]) - 1
228
+ else:
229
+ t = b = int(layout_pos_row) - 1
230
+ if isinstance(layout_pos_col, (list, tuple)):
231
+ l, r = int(layout_pos_col[0]) - 1, int(layout_pos_col[1]) - 1
232
+ else:
233
+ l = r = int(layout_pos_col) - 1
234
+
235
+ cell_x0_dev = col_starts[l] if l < len(col_starts) else 0
236
+ cell_y0_dev = row_starts[t] if t < len(row_starts) else 0
237
+ cell_w_dev = sum(col_widths[l:r + 1]) if r < len(col_widths) else 0
238
+ cell_h_dev = sum(row_heights[t:b + 1]) if b < len(row_heights) else 0
239
+
240
+ # Convert device units to inches for the transform
241
+ cell_w_in = cell_w_dev / self._dev_units_per_inch
242
+ cell_h_in = cell_h_dev / self._dev_units_per_inch
243
+
244
+ # The cell's bottom-left in the parent's coordinate system
245
+ # Layout grid uses device coords with top-left origin;
246
+ # we need to convert to the parent's inches system.
247
+ parent_h_in = parent_vtr.height_cm / 2.54
248
+
249
+ # Cell position in parent's NPC then inches
250
+ parent_w_dev = parent_vtr.width_cm / 2.54 * self._dev_units_per_inch
251
+ parent_h_dev = parent_vtr.height_cm / 2.54 * self._dev_units_per_inch
252
+ cell_x_in = cell_x0_dev / self._dev_units_per_inch
253
+ # Device y is top-down; convert to bottom-up inches
254
+ cell_y_in = parent_h_in - (cell_y0_dev + cell_h_dev) / self._dev_units_per_inch
255
+
256
+ # Build a simple translation transform for the cell
257
+ from ._vp_calc import translation, multiply
258
+ cell_translation = translation(cell_x_in, cell_y_in)
259
+ cell_transform = multiply(cell_translation, parent_vtr.transform)
260
+
261
+ xscale = getattr(vp, "_xscale", [0.0, 1.0])
262
+ yscale = getattr(vp, "_yscale", [0.0, 1.0])
263
+ vtr = ViewportTransformResult(
264
+ width_cm=cell_w_in * 2.54,
265
+ height_cm=cell_h_in * 2.54,
266
+ rotation_angle=parent_vtr.rotation_angle,
267
+ transform=cell_transform,
268
+ vpc=ViewportContext(
269
+ xscale=(float(xscale[0]), float(xscale[1])),
270
+ yscale=(float(yscale[0]), float(yscale[1])),
271
+ ),
272
+ )
273
+ self._vp_transform_stack.append(vtr)
274
+ self._vp_obj_stack.append(vp)
275
+ self._do_apply_clip_vtr(vp, vtr)
276
+
277
+ # If this layout-positioned viewport ALSO has its own
278
+ # layout, compute the grid within the cell's bounds so
279
+ # that its children can use layout_pos_row/col.
280
+ if layout is not None:
281
+ w_dev = cell_w_dev
282
+ h_dev = cell_h_dev
283
+ respect = getattr(layout, "respect", False)
284
+ grid_info = self._compute_grid(
285
+ layout, w_dev, h_dev, respect=bool(respect))
286
+ self._layout_stack.append(grid_info)
287
+ self._layout_depth_stack.append(
288
+ len(self._vp_transform_stack))
289
+ return
290
+
291
+ # --- Case 1: Layout viewport (no layout_pos) ---
292
+ if layout is not None:
293
+ # Layout viewport uses same bounds as parent but stores grid info.
294
+ # Compute grid in device units for layout children.
295
+ w_dev = parent_vtr.width_cm / 2.54 * self._dev_units_per_inch
296
+ h_dev = parent_vtr.height_cm / 2.54 * self._dev_units_per_inch
297
+ respect = getattr(layout, "respect", False)
298
+ grid_info = self._compute_grid(layout, w_dev, h_dev, respect=bool(respect))
299
+
300
+ # The layout viewport itself has the same transform as parent
301
+ # but we create a new VTR with the vp's xscale/yscale
302
+ xscale = getattr(vp, "_xscale", [0.0, 1.0])
303
+ yscale = getattr(vp, "_yscale", [0.0, 1.0])
304
+ vtr = ViewportTransformResult(
305
+ width_cm=parent_vtr.width_cm,
306
+ height_cm=parent_vtr.height_cm,
307
+ rotation_angle=parent_vtr.rotation_angle,
308
+ transform=parent_vtr.transform.copy(),
309
+ vpc=ViewportContext(
310
+ xscale=(float(xscale[0]), float(xscale[1])),
311
+ yscale=(float(yscale[0]), float(yscale[1])),
312
+ ),
313
+ )
314
+ self._vp_transform_stack.append(vtr)
315
+ self._vp_obj_stack.append(vp)
316
+ self._layout_stack.append(grid_info)
317
+ self._clip_stack.append(False)
318
+ self._layout_depth_stack.append(len(self._vp_transform_stack))
319
+ return
320
+
321
+ # --- Case 3: Simple viewport with x/y/width/height ---
322
+ # Use calc_viewport_transform (port of R's calcViewportTransform)
323
+ fontsize, cex, lineheight = self._gpar_font_params(None)
324
+
325
+ vtr = calc_viewport_transform(
326
+ vp,
327
+ parent_vtr.transform,
328
+ parent_vtr.width_cm,
329
+ parent_vtr.height_cm,
330
+ parent_vtr.rotation_angle,
331
+ parent_vtr.vpc,
332
+ gc_fontsize=fontsize,
333
+ gc_cex=cex,
334
+ gc_lineheight=lineheight,
335
+ str_metric_fn=self._str_metric_fn,
336
+ grob_metric_fn=self._grob_metric_fn,
337
+ )
338
+ self._vp_transform_stack.append(vtr)
339
+ self._vp_obj_stack.append(vp)
340
+ self._do_apply_clip_vtr(vp, vtr)
341
+
342
+ def _str_metric_fn(self, text: str, gp: Any) -> Dict[str, float]:
343
+ """String metric callback for unit resolution."""
344
+ return self.text_extents(text, gp=gp)
345
+
346
+ def _do_apply_clip_vtr(self, vp: Any, vtr: ViewportTransformResult) -> None:
347
+ """Apply clipping for a viewport using its transform."""
348
+ clip = getattr(vp, "_clip", None)
349
+ if clip is True or clip == "on":
350
+ # Compute clip rect in device coords from the viewport bounds
351
+ bl = trans(location(0.0, 0.0), vtr.transform)
352
+ w_in = vtr.width_cm / 2.54
353
+ h_in = vtr.height_cm / 2.54
354
+ x0 = bl[0] * self._dev_units_per_inch
355
+ y0_bottom = bl[1] * self._dev_units_per_inch
356
+ pw = w_in * self._dev_units_per_inch
357
+ ph = h_in * self._dev_units_per_inch
358
+ # Convert to device top-left origin
359
+ y0_device = self._device_height - y0_bottom - ph
360
+ self._apply_clip_rect(x0, y0_device, pw, ph)
361
+ self._clip_stack.append(True)
362
+ else:
363
+ self._clip_stack.append(False)
364
+
365
+ def pop_viewport(self) -> None:
366
+ """Pop the current viewport and restore clipping/layout state."""
367
+ if len(self._vp_transform_stack) > 1:
368
+ depth_stack = self._layout_depth_stack
369
+ if depth_stack and depth_stack[-1] == len(self._vp_transform_stack):
370
+ depth_stack.pop()
371
+ if self._layout_stack:
372
+ self._layout_stack.pop()
373
+ self._vp_transform_stack.pop()
374
+ self._vp_obj_stack.pop()
375
+ if self._clip_stack:
376
+ had_clip = self._clip_stack.pop()
377
+ if had_clip:
378
+ self._restore_clip()
379
+
380
+ def pop_viewport_to_root(self) -> None:
381
+ """Pop all viewports back to the root (device-level) entry."""
382
+ while len(self._vp_transform_stack) > 1:
383
+ self.pop_viewport()
384
+
385
+ # ===================================================================== #
386
+ # Layout computation (shared) #
387
+ # ===================================================================== #
388
+
389
+ def _compute_grid(
390
+ self, layout: Any, parent_w: float, parent_h: float,
391
+ respect: bool = False,
392
+ ) -> dict:
393
+ """Compute row/column positions for a GridLayout within the parent."""
394
+ from ._layout import _calc_layout_sizes, GridLayout
395
+
396
+ if isinstance(layout, GridLayout):
397
+ col_widths, row_heights = _calc_layout_sizes(
398
+ layout, parent_w, parent_h, self.dpi,
399
+ )
400
+ else:
401
+ nrow = getattr(layout, "nrow", 1)
402
+ ncol = getattr(layout, "ncol", 1)
403
+ col_widths = self._resolve_sizes(
404
+ getattr(layout, "widths", None), ncol, parent_w, axis="x",
405
+ )
406
+ row_heights = self._resolve_sizes(
407
+ getattr(layout, "heights", None), nrow, parent_h, axis="y",
408
+ )
409
+
410
+ ncol = len(col_widths)
411
+ nrow = len(row_heights)
412
+ col_starts = [sum(col_widths[:i]) for i in range(ncol)]
413
+ row_starts = [sum(row_heights[:i]) for i in range(nrow)]
414
+
415
+ return {
416
+ "col_starts": col_starts, "col_widths": col_widths,
417
+ "row_starts": row_starts, "row_heights": row_heights,
418
+ }
419
+
420
+ def _resolve_sizes(self, unit_obj: Any, n: int, total: float,
421
+ axis: str = "x") -> list:
422
+ """Resolve a Unit vector to device sizes, distributing null units."""
423
+ if unit_obj is None:
424
+ return [total / n] * n
425
+
426
+ from ._units import Unit
427
+ if not isinstance(unit_obj, Unit):
428
+ return [total / n] * n
429
+
430
+ vals = unit_obj._values
431
+ types = (
432
+ unit_obj._units
433
+ if hasattr(unit_obj, "_units")
434
+ else getattr(unit_obj, "_types", ["null"] * len(vals))
435
+ )
436
+
437
+ abs_sizes: Dict[int, float] = {}
438
+ abs_total = 0.0
439
+ null_total = 0.0
440
+
441
+ for i, (v, t) in enumerate(zip(vals, types)):
442
+ if t == "npc":
443
+ px = float(v) * total
444
+ abs_sizes[i] = px
445
+ abs_total += px
446
+ elif t in _INCHES_PER:
447
+ px = float(v) * _INCHES_PER[t] * self._dev_units_per_inch
448
+ abs_sizes[i] = px
449
+ abs_total += px
450
+ elif t == "null":
451
+ null_total += float(v)
452
+ elif t in ("sum", "min", "max", "lines", "char", "snpc",
453
+ "strwidth", "strheight", "strascent", "strdescent",
454
+ "grobwidth", "grobheight"):
455
+ # Context-dependent or compound units: resolve to inches
456
+ # via the full pipeline, then convert to device pixels.
457
+ elem = Unit(float(v), t,
458
+ data=unit_obj._data[i] if unit_obj._data else None)
459
+ inches = self._resolve_to_inches(elem, axis, True)
460
+ px = inches * self._dev_units_per_inch
461
+ abs_sizes[i] = px
462
+ abs_total += px
463
+ else:
464
+ # Unknown type — treat as null
465
+ null_total += float(v)
466
+
467
+ remaining = max(total - abs_total, 0.0)
468
+ if null_total == 0:
469
+ null_total = 1.0
470
+
471
+ sizes = []
472
+ for i, (v, t) in enumerate(zip(vals, types)):
473
+ if i in abs_sizes:
474
+ sizes.append(abs_sizes[i])
475
+ else:
476
+ sizes.append(float(v) / null_total * remaining)
477
+ return sizes
478
+
479
+ # ===================================================================== #
480
+ # Unit resolution: to INCHES (port of unit.c:transform) #
481
+ # ===================================================================== #
482
+
483
+ def _get_scale(self) -> float:
484
+ """Return the current GSS_SCALE zoom factor (default 1.0)."""
485
+ try:
486
+ from ._state import get_state
487
+ return get_state()._scale
488
+ except Exception:
489
+ return 1.0
490
+
491
+ # ===================================================================== #
492
+ # evaluateGrobUnit -- port of R unit.c:325-590 #
493
+ # ===================================================================== #
494
+
495
+ def _evaluate_grob_unit(
496
+ self,
497
+ grob: Any,
498
+ unit_type: str,
499
+ value: float = 1.0,
500
+ ) -> Optional[float]:
501
+ """Evaluate a grobwidth/grobheight/etc. unit, returning inches.
502
+
503
+ Port of R's ``evaluateGrobUnit()`` (unit.c:325-590).
504
+ Performs the full cycle:
505
+ 1. Save state (gpar, current grob, DL recording)
506
+ 2. If *grob* is a gPath (string), resolve to actual grob
507
+ 3. ``preDraw(grob)`` — pushes grob's vp/gp
508
+ 4. ``widthDetails(grob)``/``heightDetails(grob)`` — get result Unit
509
+ 5. Convert result Unit to inches *within grob's viewport context*
510
+ 6. ``postDraw(grob)`` — pops grob's vp
511
+ 7. Restore state
512
+
513
+ Parameters
514
+ ----------
515
+ grob : Grob or str
516
+ The grob (or gPath name) to measure.
517
+ unit_type : str
518
+ One of ``"grobwidth"``, ``"grobheight"``, ``"grobascent"``,
519
+ ``"grobdescent"``, ``"grobx"``, ``"groby"``.
520
+ value : float
521
+ The numeric value of the unit (angle for grobx/groby).
522
+
523
+ Returns
524
+ -------
525
+ float or None
526
+ Size in inches, or None on failure.
527
+ """
528
+ import copy
529
+ from ._state import get_state
530
+ from ._grob import Grob, GTree
531
+ from ._path import GPath
532
+ from ._size import (
533
+ width_details, height_details,
534
+ ascent_details, descent_details,
535
+ )
536
+
537
+ state = get_state()
538
+
539
+ # --- Resolve gPath to actual grob (R unit.c:405-431) ---
540
+ if isinstance(grob, (str, GPath)):
541
+ grob = self._find_grob_for_metric(grob, state)
542
+ if grob is None:
543
+ return 0.0
544
+
545
+ if not isinstance(grob, Grob):
546
+ return 0.0
547
+
548
+ # --- Save state (R unit.c:355-377) ---
549
+ saved_dl_on = state._dl_on
550
+ state.set_display_list_on(False)
551
+ saved_gpar = copy.copy(state.get_gpar())
552
+ saved_current_grob = getattr(state, "_current_grob", None)
553
+
554
+ try:
555
+ # --- preDraw(grob) (R unit.c:434-435) ---
556
+ # This may push viewports and set gpar
557
+ from ._draw import _push_vp_gp, _pop_grob_vp
558
+ grob = grob.make_context()
559
+ if isinstance(grob, GTree):
560
+ state._current_grob = grob
561
+ _push_vp_gp(grob)
562
+ grob.pre_draw_details()
563
+
564
+ # --- After preDraw, re-establish viewport context ---
565
+ # (R unit.c:451-456)
566
+ vtr = self._vp_transform_stack[-1]
567
+ gp = state.get_gpar()
568
+ fontsize, cex, lineheight = self._gpar_font_params(gp)
569
+
570
+ if unit_type in ("grobx", "groby"):
571
+ # Compute the x/y coordinate on the grob's bounding box at
572
+ # the requested angle (encoded in ``value``; 0=east, 90=north,
573
+ # 180=west, 270=south). Mirrors ``xDetails.text`` /
574
+ # ``yDetails.text`` in R grid (primitives.R:1406-1428).
575
+ result = self._grob_xy_inches_at_theta(
576
+ grob, unit_type, float(value), gp,
577
+ )
578
+ else:
579
+ if unit_type == "grobwidth":
580
+ result_unit = width_details(grob)
581
+ elif unit_type == "grobheight":
582
+ result_unit = height_details(grob)
583
+ elif unit_type == "grobascent":
584
+ result_unit = ascent_details(grob)
585
+ elif unit_type == "grobdescent":
586
+ result_unit = descent_details(grob)
587
+ else:
588
+ result_unit = None
589
+
590
+ if result_unit is None:
591
+ result = 0.0
592
+ else:
593
+ from ._units import Unit
594
+ if not isinstance(result_unit, Unit):
595
+ result = 0.0
596
+ elif (len(result_unit) == 1
597
+ and result_unit._units[0] == "null"):
598
+ # "null" units evaluate to 0 (R unit.c:530-531)
599
+ result = 0.0
600
+ else:
601
+ if unit_type in ("grobwidth",):
602
+ result = self._resolve_to_inches(
603
+ result_unit, "x", True, gp)
604
+ elif unit_type in ("grobheight", "grobascent",
605
+ "grobdescent"):
606
+ result = self._resolve_to_inches(
607
+ result_unit, "y", True, gp)
608
+ else:
609
+ result = 0.0
610
+
611
+ # --- postDraw(grob) (R unit.c:556-557) ---
612
+ grob.post_draw_details()
613
+ if grob.vp is not None:
614
+ _pop_grob_vp(grob.vp)
615
+
616
+ except Exception:
617
+ result = 0.0
618
+ finally:
619
+ # --- Restore state (R unit.c:561-562) ---
620
+ state.replace_gpar(saved_gpar)
621
+ state._current_grob = saved_current_grob
622
+ state.set_display_list_on(saved_dl_on)
623
+
624
+ return result
625
+
626
+ def _grob_xy_inches_at_theta(
627
+ self,
628
+ grob: Any,
629
+ unit_type: str,
630
+ theta_deg: float,
631
+ gp: Optional[Any] = None,
632
+ ) -> float:
633
+ """Return the inches x- or y-coordinate at angle ``theta_deg`` on a
634
+ grob's bounding box.
635
+
636
+ Used to resolve ``grobx`` / ``groby`` units (e.g. those produced by
637
+ ``grob_x(text_grob, "west")``). The angle convention: 0 = east,
638
+ 90 = north, 180 = west, 270 = south.
639
+
640
+ Only the axis-aligned rectangle defined by width/height + hjust/vjust
641
+ is considered; rotated text is approximated by its upright box (good
642
+ enough for the common ``rot=0`` path that dominates ggrepel output).
643
+ """
644
+ import math
645
+ from ._units import Unit
646
+ from ._size import width_details, height_details
647
+
648
+ # Grob anchor (grob.x, grob.y) — default to center of viewport if absent.
649
+ x_unit = getattr(grob, "x", None)
650
+ y_unit = getattr(grob, "y", None)
651
+ if x_unit is None:
652
+ x_unit = Unit(0.5, "npc")
653
+ if y_unit is None:
654
+ y_unit = Unit(0.5, "npc")
655
+ try:
656
+ x_inches = self._resolve_to_inches(x_unit, "x", False, gp)
657
+ except Exception:
658
+ x_inches = 0.0
659
+ try:
660
+ y_inches = self._resolve_to_inches(y_unit, "y", False, gp)
661
+ except Exception:
662
+ y_inches = 0.0
663
+
664
+ # Width / height of the grob's bounding box, in inches.
665
+ def _details_inches(fn, axis: str) -> float:
666
+ try:
667
+ u = fn(grob)
668
+ except Exception:
669
+ return 0.0
670
+ if u is None:
671
+ return 0.0
672
+ if not isinstance(u, Unit):
673
+ return 0.0
674
+ if len(u) == 1 and u._units[0] == "null":
675
+ return 0.0
676
+ try:
677
+ return float(self._resolve_to_inches(u, axis, True, gp))
678
+ except Exception:
679
+ return 0.0
680
+
681
+ w_in = _details_inches(width_details, "x")
682
+ h_in = _details_inches(height_details, "y")
683
+
684
+ # hjust / vjust control which corner of the box is anchored at (x, y).
685
+ def _just_to_float(v: Any, default: float) -> float:
686
+ if v is None:
687
+ return default
688
+ if isinstance(v, (int, float)):
689
+ return float(v)
690
+ _H = {"left": 0.0, "right": 1.0, "centre": 0.5, "center": 0.5}
691
+ _V = {"bottom": 0.0, "top": 1.0, "centre": 0.5, "center": 0.5}
692
+ s = str(v).lower()
693
+ return _H.get(s, _V.get(s, default))
694
+
695
+ hjust = _just_to_float(getattr(grob, "hjust", 0.5), 0.5)
696
+ vjust = _just_to_float(getattr(grob, "vjust", 0.5), 0.5)
697
+
698
+ # Centre of the bounding box in inches.
699
+ cx = x_inches + (0.5 - hjust) * w_in
700
+ cy = y_inches + (0.5 - vjust) * h_in
701
+
702
+ # Point on the box at direction theta (from centre). Ray hits the
703
+ # nearest axis-aligned edge.
704
+ rad = math.radians(theta_deg)
705
+ cos_t = math.cos(rad)
706
+ sin_t = math.sin(rad)
707
+ dx = w_in / 2.0
708
+ dy = h_in / 2.0
709
+ eps = 1e-12
710
+ if abs(cos_t) < eps:
711
+ t = dy / max(abs(sin_t), eps)
712
+ elif abs(sin_t) < eps:
713
+ t = dx / max(abs(cos_t), eps)
714
+ else:
715
+ t = min(dx / abs(cos_t), dy / abs(sin_t))
716
+
717
+ px = cx + t * cos_t
718
+ py = cy + t * sin_t
719
+ return float(px if unit_type == "grobx" else py)
720
+
721
+ def _find_grob_for_metric(self, grob_ref: Any, state: Any) -> Any:
722
+ """Resolve a gPath/string to an actual grob for metric evaluation.
723
+
724
+ Port of R unit.c:405-431: if current grob is NULL, search the
725
+ display list; otherwise search the current grob's children.
726
+ """
727
+ from ._grob import Grob, GTree
728
+ from ._path import GPath
729
+ from ._display_list import DLDrawGrob
730
+
731
+ name = str(grob_ref)
732
+
733
+ # Check current grob's children first (R unit.c:420-425)
734
+ current_grob = getattr(state, "_current_grob", None)
735
+ if current_grob is not None and isinstance(current_grob, GTree):
736
+ child = current_grob._children.get(name)
737
+ if child is not None:
738
+ return child
739
+
740
+ # Search display list (R unit.c:413-418)
741
+ dl = state.get_display_list()
742
+ for item in dl:
743
+ if isinstance(item, DLDrawGrob) and item.grob is not None:
744
+ if getattr(item.grob, "name", None) == name:
745
+ return item.grob
746
+ # Search inside GTrees
747
+ if isinstance(item.grob, GTree):
748
+ child = item.grob._children.get(name)
749
+ if child is not None:
750
+ return child
751
+
752
+ return None
753
+
754
+ def _grob_metric_fn(self, grob: Any, unit_type: str, value: float) -> Optional[float]:
755
+ """Callback for _transform_to_inches grob_metric_fn parameter.
756
+
757
+ Delegates to _evaluate_grob_unit which does the full
758
+ preDraw/widthDetails/postDraw cycle.
759
+ """
760
+ return self._evaluate_grob_unit(grob, unit_type, value)
761
+
762
+ # ===================================================================== #
763
+ # Unit → inches resolution (core pipeline) #
764
+ # ===================================================================== #
765
+
766
+ def _resolve_to_inches(
767
+ self,
768
+ unit_obj: Any,
769
+ axis: str,
770
+ is_dim: bool,
771
+ gp: Optional[Any] = None,
772
+ ) -> float:
773
+ """Resolve a single :class:`Unit` value to inches.
774
+
775
+ Port of R's unit.c transformXtoINCHES / transformYtoINCHES.
776
+ Uses the current viewport's transform context (widthCM, heightCM,
777
+ ViewportContext) for the conversion.
778
+ """
779
+ from ._units import Unit
780
+
781
+ if not isinstance(unit_obj, Unit):
782
+ return float(unit_obj)
783
+
784
+ vtr = self._vp_transform_stack[-1]
785
+ fontsize, cex, lineheight = self._gpar_font_params(gp)
786
+
787
+ return _transform_to_inches(
788
+ unit_obj, 0, vtr.vpc,
789
+ fontsize, cex, lineheight,
790
+ this_cm=vtr.width_cm if axis == "x" else vtr.height_cm,
791
+ other_cm=vtr.height_cm if axis == "x" else vtr.width_cm,
792
+ axis=axis, is_dim=is_dim,
793
+ str_metric_fn=self._str_metric_fn,
794
+ grob_metric_fn=self._grob_metric_fn,
795
+ scale=self._get_scale(),
796
+ )
797
+
798
+ def _resolve_to_inches_idx(
799
+ self,
800
+ unit_obj: Any,
801
+ index: int,
802
+ axis: str,
803
+ is_dim: bool,
804
+ gp: Optional[Any] = None,
805
+ ) -> float:
806
+ """Resolve element *index* of a Unit to inches."""
807
+ from ._units import Unit
808
+ if not isinstance(unit_obj, Unit):
809
+ return float(unit_obj)
810
+
811
+ vtr = self._vp_transform_stack[-1]
812
+ fontsize, cex, lineheight = self._gpar_font_params(gp)
813
+
814
+ return _transform_to_inches(
815
+ unit_obj, index, vtr.vpc,
816
+ fontsize, cex, lineheight,
817
+ this_cm=vtr.width_cm if axis == "x" else vtr.height_cm,
818
+ other_cm=vtr.height_cm if axis == "x" else vtr.width_cm,
819
+ axis=axis, is_dim=is_dim,
820
+ str_metric_fn=self._str_metric_fn,
821
+ grob_metric_fn=self._grob_metric_fn,
822
+ scale=self._get_scale(),
823
+ )
824
+
825
+ # ===================================================================== #
826
+ # Inches → device coordinate conversion #
827
+ # ===================================================================== #
828
+
829
+ def inches_to_dev_x(self, x_inches: float) -> float:
830
+ """Convert absolute x in inches to device x coordinate."""
831
+ return x_inches * self._dev_units_per_inch
832
+
833
+ def inches_to_dev_y(self, y_inches: float) -> float:
834
+ """Convert absolute y in inches to device y coordinate.
835
+
836
+ Applies Y-flip: in grid, y=0 is bottom; in device, y=0 is top.
837
+ """
838
+ return self._device_height - y_inches * self._dev_units_per_inch
839
+
840
+ def inches_to_dev_w(self, w_inches: float) -> float:
841
+ """Convert width in inches to device width."""
842
+ return w_inches * self._dev_units_per_inch
843
+
844
+ def inches_to_dev_h(self, h_inches: float) -> float:
845
+ """Convert height in inches to device height."""
846
+ return h_inches * self._dev_units_per_inch
847
+
848
+ def transform_loc_to_device(
849
+ self, x_inches: float, y_inches: float,
850
+ ) -> Tuple[float, float]:
851
+ """Transform a location from viewport inches to device coordinates.
852
+
853
+ Port of R's transformLocn() + toDeviceX/Y():
854
+ 1. Apply the current viewport's 3×3 transform to get absolute inches
855
+ 2. Convert absolute inches to device coordinates
856
+ """
857
+ vtr = self._vp_transform_stack[-1]
858
+ loc = location(x_inches, y_inches)
859
+ abs_loc = trans(loc, vtr.transform)
860
+ dev_x = self.inches_to_dev_x(abs_loc[0])
861
+ dev_y = self.inches_to_dev_y(abs_loc[1])
862
+ return dev_x, dev_y
863
+
864
+ def transform_dim_to_device(
865
+ self, w_inches: float, h_inches: float,
866
+ ) -> Tuple[float, float]:
867
+ """Transform dimensions from viewport inches to device units.
868
+
869
+ For dimensions (widths/heights), we apply only the scaling/rotation
870
+ part of the transform (no translation). For now, without rotation
871
+ we simply convert inches to device units. When rotation is present,
872
+ the dimension scaling depends on the rotation angle.
873
+ """
874
+ vtr = self._vp_transform_stack[-1]
875
+ angle = vtr.rotation_angle
876
+ if abs(angle % 360) < 1e-10:
877
+ # No rotation: simple scaling
878
+ return (w_inches * self._dev_units_per_inch,
879
+ h_inches * self._dev_units_per_inch)
880
+ else:
881
+ # With rotation, the effective device dimensions change.
882
+ # For a rotated viewport, widths and heights in viewport-local
883
+ # inches map to device units through the rotation.
884
+ rad = math.radians(angle)
885
+ cos_a = abs(math.cos(rad))
886
+ sin_a = abs(math.sin(rad))
887
+ dev_w = (w_inches * cos_a + h_inches * sin_a) * self._dev_units_per_inch
888
+ dev_h = (h_inches * cos_a + w_inches * sin_a) * self._dev_units_per_inch
889
+ return dev_w, dev_h
890
+
891
+ # ===================================================================== #
892
+ # Public convenience: resolve + transform (Unit → device coords) #
893
+ # ===================================================================== #
894
+
895
+ def resolve_x(self, val: Any, gp: Optional[Any] = None) -> float:
896
+ """Resolve *val* to a device x-coordinate."""
897
+ inches = self._resolve_to_inches(val, axis="x", is_dim=False, gp=gp)
898
+ dev_x, _ = self.transform_loc_to_device(inches, 0.0)
899
+ return dev_x
900
+
901
+ def resolve_y(self, val: Any, gp: Optional[Any] = None) -> float:
902
+ """Resolve *val* to a device y-coordinate."""
903
+ inches = self._resolve_to_inches(val, axis="y", is_dim=False, gp=gp)
904
+ _, dev_y = self.transform_loc_to_device(0.0, inches)
905
+ return dev_y
906
+
907
+ def resolve_w(self, val: Any, gp: Optional[Any] = None) -> float:
908
+ """Resolve *val* to a device width."""
909
+ inches = self._resolve_to_inches(val, axis="x", is_dim=True, gp=gp)
910
+ return self.inches_to_dev_w(inches)
911
+
912
+ def resolve_h(self, val: Any, gp: Optional[Any] = None) -> float:
913
+ """Resolve *val* to a device height."""
914
+ inches = self._resolve_to_inches(val, axis="y", is_dim=True, gp=gp)
915
+ return self.inches_to_dev_h(inches)
916
+
917
+ def resolve_x_array(self, val: Any, gp: Optional[Any] = None) -> "np.ndarray":
918
+ """Resolve *val* to an array of device x-coordinates."""
919
+ from ._units import Unit
920
+ if isinstance(val, Unit):
921
+ out = np.empty(len(val), dtype=float)
922
+ for i in range(len(val)):
923
+ inches = self._resolve_to_inches_idx(val, i, "x", False, gp)
924
+ dev_x, _ = self.transform_loc_to_device(inches, 0.0)
925
+ out[i] = dev_x
926
+ return out
927
+ if isinstance(val, (list, tuple)):
928
+ return np.asarray([self.resolve_x(v, gp) for v in val], dtype=float)
929
+ return np.atleast_1d(np.asarray(val, dtype=float))
930
+
931
+ def resolve_y_array(self, val: Any, gp: Optional[Any] = None) -> "np.ndarray":
932
+ """Resolve *val* to an array of device y-coordinates."""
933
+ from ._units import Unit
934
+ if isinstance(val, Unit):
935
+ out = np.empty(len(val), dtype=float)
936
+ for i in range(len(val)):
937
+ inches = self._resolve_to_inches_idx(val, i, "y", False, gp)
938
+ _, dev_y = self.transform_loc_to_device(0.0, inches)
939
+ out[i] = dev_y
940
+ return out
941
+ if isinstance(val, (list, tuple)):
942
+ return np.asarray([self.resolve_y(v, gp) for v in val], dtype=float)
943
+ return np.atleast_1d(np.asarray(val, dtype=float))
944
+
945
+ def resolve_w_array(self, val: Any, gp: Optional[Any] = None) -> "np.ndarray":
946
+ """Resolve *val* to an array of device widths."""
947
+ from ._units import Unit
948
+ if isinstance(val, Unit):
949
+ out = np.empty(len(val), dtype=float)
950
+ for i in range(len(val)):
951
+ inches = self._resolve_to_inches_idx(val, i, "x", True, gp)
952
+ out[i] = self.inches_to_dev_w(inches)
953
+ return out
954
+ if isinstance(val, (list, tuple)):
955
+ return np.asarray([self.resolve_w(v, gp) for v in val], dtype=float)
956
+ return np.atleast_1d(np.asarray(val, dtype=float))
957
+
958
+ def resolve_h_array(self, val: Any, gp: Optional[Any] = None) -> "np.ndarray":
959
+ """Resolve *val* to an array of device heights."""
960
+ from ._units import Unit
961
+ if isinstance(val, Unit):
962
+ out = np.empty(len(val), dtype=float)
963
+ for i in range(len(val)):
964
+ inches = self._resolve_to_inches_idx(val, i, "y", True, gp)
965
+ out[i] = self.inches_to_dev_h(inches)
966
+ return out
967
+ if isinstance(val, (list, tuple)):
968
+ return np.asarray([self.resolve_h(v, gp) for v in val], dtype=float)
969
+ return np.atleast_1d(np.asarray(val, dtype=float))
970
+
971
+ # ===================================================================== #
972
+ # Backward-compatible NPC resolution (for code not yet migrated) #
973
+ # ===================================================================== #
974
+
975
+ def _resolve_to_npc(
976
+ self, unit_obj: Any, axis: str, is_dim: bool, gp: Optional[Any] = None,
977
+ ) -> float:
978
+ """Backward-compatible NPC resolution.
979
+
980
+ Converts to inches first (new pipeline), then normalises to NPC
981
+ by dividing by the viewport size in inches.
982
+ """
983
+ inches = self._resolve_to_inches(unit_obj, axis, is_dim, gp)
984
+ vtr = self._vp_transform_stack[-1]
985
+ vp_inches = (vtr.width_cm if axis == "x" else vtr.height_cm) / 2.54
986
+ if vp_inches == 0:
987
+ return 0.0
988
+ return inches / vp_inches
989
+
990
+ def resolve_to_npc(
991
+ self, unit_obj: Any, axis: str = "x",
992
+ is_dim: bool = False, gp: Optional[Any] = None,
993
+ ) -> float:
994
+ """Public backward-compatible NPC resolution."""
995
+ return self._resolve_to_npc(unit_obj, axis=axis, is_dim=is_dim, gp=gp)
996
+
997
+ # ===================================================================== #
998
+ # Coordinate helpers: NPC → device (backward compatibility) #
999
+ # ===================================================================== #
1000
+ # These are still used by CairoRenderer draw_* methods that receive
1001
+ # NPC values. After full migration they can be removed.
1002
+
1003
+ def _x(self, npc: float) -> float:
1004
+ """Convert NPC x -> device x (within current viewport).
1005
+
1006
+ DEPRECATED: use resolve_x() or transform_loc_to_device() instead.
1007
+ """
1008
+ vtr = self._vp_transform_stack[-1]
1009
+ x_inches = npc * vtr.width_cm / 2.54
1010
+ dev_x, _ = self.transform_loc_to_device(x_inches, 0.0)
1011
+ return dev_x
1012
+
1013
+ def _y(self, npc: float) -> float:
1014
+ """Convert NPC y -> device y (Y-flip).
1015
+
1016
+ DEPRECATED: use resolve_y() or transform_loc_to_device() instead.
1017
+ """
1018
+ vtr = self._vp_transform_stack[-1]
1019
+ y_inches = npc * vtr.height_cm / 2.54
1020
+ _, dev_y = self.transform_loc_to_device(0.0, y_inches)
1021
+ return dev_y
1022
+
1023
+ def _sx(self, npc: float) -> float:
1024
+ """Scale a width from NPC to device units.
1025
+
1026
+ DEPRECATED: use resolve_w() instead.
1027
+ """
1028
+ vtr = self._vp_transform_stack[-1]
1029
+ w_inches = npc * vtr.width_cm / 2.54
1030
+ return self.inches_to_dev_w(w_inches)
1031
+
1032
+ def _sy(self, npc: float) -> float:
1033
+ """Scale a height from NPC to device units.
1034
+
1035
+ DEPRECATED: use resolve_h() instead.
1036
+ """
1037
+ vtr = self._vp_transform_stack[-1]
1038
+ h_inches = npc * vtr.height_cm / 2.54
1039
+ return self.inches_to_dev_h(h_inches)
1040
+
1041
+ # ===================================================================== #
1042
+ # Abstract methods: backend-specific clipping #
1043
+ # ===================================================================== #
1044
+
1045
+ @abstractmethod
1046
+ def _apply_clip_rect(self, x0: float, y0: float, w: float, h: float) -> None:
1047
+ ...
1048
+
1049
+ @abstractmethod
1050
+ def _restore_clip(self) -> None:
1051
+ ...
1052
+
1053
+ # ===================================================================== #
1054
+ # Abstract methods: graphics state save/restore #
1055
+ # ===================================================================== #
1056
+
1057
+ @abstractmethod
1058
+ def save_state(self) -> None: ...
1059
+
1060
+ @abstractmethod
1061
+ def restore_state(self) -> None: ...
1062
+
1063
+ # ===================================================================== #
1064
+ # Abstract methods: path collection (fill/stroke grobs) #
1065
+ # ===================================================================== #
1066
+
1067
+ @abstractmethod
1068
+ def begin_path_collect(self, rule: str = "winding") -> None: ...
1069
+
1070
+ @abstractmethod
1071
+ def end_path_stroke(self, gp: Optional[Any] = None) -> None: ...
1072
+
1073
+ @abstractmethod
1074
+ def end_path_fill(self, gp: Optional[Any] = None) -> None: ...
1075
+
1076
+ @abstractmethod
1077
+ def end_path_fill_stroke(self, gp: Optional[Any] = None) -> None: ...
1078
+
1079
+ # ===================================================================== #
1080
+ # Abstract methods: drawing primitives #
1081
+ # ===================================================================== #
1082
+ # All coordinates are now in DEVICE units (pixels for raster, points
1083
+ # for vector). The resolve_* methods handle the full pipeline:
1084
+ # Unit → inches → transform → device.
1085
+
1086
+ @abstractmethod
1087
+ def draw_rect(self, x: float, y: float, w: float, h: float,
1088
+ hjust: float = 0.5, vjust: float = 0.5,
1089
+ gp: Optional[Any] = None) -> None: ...
1090
+
1091
+ @abstractmethod
1092
+ def draw_circle(self, x: float, y: float, r: float,
1093
+ gp: Optional[Any] = None) -> None: ...
1094
+
1095
+ @abstractmethod
1096
+ def draw_line(self, x: "np.ndarray", y: "np.ndarray",
1097
+ gp: Optional[Any] = None) -> None: ...
1098
+
1099
+ @abstractmethod
1100
+ def draw_polyline(self, x: "np.ndarray", y: "np.ndarray",
1101
+ id_: Optional["np.ndarray"] = None,
1102
+ gp: Optional[Any] = None) -> None: ...
1103
+
1104
+ @abstractmethod
1105
+ def draw_segments(self, x0: "np.ndarray", y0: "np.ndarray",
1106
+ x1: "np.ndarray", y1: "np.ndarray",
1107
+ gp: Optional[Any] = None) -> None: ...
1108
+
1109
+ @abstractmethod
1110
+ def draw_polygon(self, x: "np.ndarray", y: "np.ndarray",
1111
+ gp: Optional[Any] = None) -> None: ...
1112
+
1113
+ @abstractmethod
1114
+ def draw_path(self, x: "np.ndarray", y: "np.ndarray",
1115
+ path_id: "np.ndarray", rule: str = "winding",
1116
+ gp: Optional[Any] = None) -> None: ...
1117
+
1118
+ @abstractmethod
1119
+ def draw_text(self, x: float, y: float, label: str,
1120
+ rot: float = 0.0, hjust: float = 0.5, vjust: float = 0.5,
1121
+ gp: Optional[Any] = None) -> None: ...
1122
+
1123
+ @abstractmethod
1124
+ def draw_points(self, x: "np.ndarray", y: "np.ndarray",
1125
+ size: float = 1.0, pch: Any = 19,
1126
+ gp: Optional[Any] = None) -> None: ...
1127
+
1128
+ @abstractmethod
1129
+ def draw_raster(self, image: Any, x: float, y: float,
1130
+ w: float, h: float,
1131
+ interpolate: bool = True) -> None: ...
1132
+
1133
+ @abstractmethod
1134
+ def draw_roundrect(self, x: float, y: float, w: float, h: float,
1135
+ r: float = 0.0, hjust: float = 0.5, vjust: float = 0.5,
1136
+ gp: Optional[Any] = None) -> None: ...
1137
+
1138
+ @abstractmethod
1139
+ def move_to(self, x: float, y: float) -> None: ...
1140
+
1141
+ @abstractmethod
1142
+ def line_to(self, x: float, y: float,
1143
+ gp: Optional[Any] = None) -> None: ...
1144
+
1145
+ # ===================================================================== #
1146
+ # Abstract methods: clipping (explicit push/pop) #
1147
+ # ===================================================================== #
1148
+
1149
+ @abstractmethod
1150
+ def push_clip(self, x0: float, y0: float, x1: float, y1: float) -> None: ...
1151
+
1152
+ @abstractmethod
1153
+ def pop_clip(self) -> None: ...
1154
+
1155
+ # ===================================================================== #
1156
+ # Abstract methods: text metrics #
1157
+ # ===================================================================== #
1158
+
1159
+ @abstractmethod
1160
+ def text_extents(self, text: str,
1161
+ gp: Optional[Any] = None) -> Dict[str, float]:
1162
+ """Return ``{'ascent', 'descent', 'width'}`` in inches."""
1163
+ ...
1164
+
1165
+ # ===================================================================== #
1166
+ # Abstract methods: masking #
1167
+ # ===================================================================== #
1168
+
1169
+ @abstractmethod
1170
+ def render_mask(self, mask_grob: Any) -> Any: ...
1171
+
1172
+ @abstractmethod
1173
+ def apply_mask(self, mask_surface: Any,
1174
+ mask_type: str = "alpha") -> None: ...
1175
+
1176
+ # ===================================================================== #
1177
+ # Abstract methods: output / surface management #
1178
+ # ===================================================================== #
1179
+
1180
+ @abstractmethod
1181
+ def new_page(self, bg: Any = "white") -> None: ...
1182
+
1183
+ @abstractmethod
1184
+ def finish(self) -> None: ...