rgrid-python 4.5.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- grid_py/__init__.py +340 -0
- grid_py/_arrow.py +331 -0
- grid_py/_clippath.py +170 -0
- grid_py/_colour.py +815 -0
- grid_py/_coords.py +1534 -0
- grid_py/_curve.py +1668 -0
- grid_py/_display_list.py +507 -0
- grid_py/_draw.py +1397 -0
- grid_py/_edit.py +756 -0
- grid_py/_font_metrics.py +319 -0
- grid_py/_gpar.py +572 -0
- grid_py/_grab.py +501 -0
- grid_py/_grob.py +1377 -0
- grid_py/_group.py +798 -0
- grid_py/_highlevel.py +2176 -0
- grid_py/_just.py +361 -0
- grid_py/_layout.py +593 -0
- grid_py/_ls.py +895 -0
- grid_py/_mask.py +196 -0
- grid_py/_path.py +414 -0
- grid_py/_patterns.py +1049 -0
- grid_py/_primitives.py +2198 -0
- grid_py/_renderer_base.py +1184 -0
- grid_py/_scene_graph.py +248 -0
- grid_py/_size.py +1352 -0
- grid_py/_state.py +683 -0
- grid_py/_transforms.py +448 -0
- grid_py/_typeset.py +384 -0
- grid_py/_units.py +1924 -0
- grid_py/_utils.py +310 -0
- grid_py/_viewport.py +1649 -0
- grid_py/_vp_calc.py +970 -0
- grid_py/py.typed +0 -0
- grid_py/renderer.py +1762 -0
- grid_py/renderer_web.py +764 -0
- grid_py/resources/d3.v7.min.js +2 -0
- grid_py/resources/gridpy.css +80 -0
- grid_py/resources/gridpy.js +813 -0
- rgrid_python-4.5.3.dist-info/METADATA +489 -0
- rgrid_python-4.5.3.dist-info/RECORD +42 -0
- rgrid_python-4.5.3.dist-info/WHEEL +4 -0
- rgrid_python-4.5.3.dist-info/licenses/LICENSE +3 -0
|
@@ -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: ...
|