rgrid-python 4.5.3.post3__py3-none-any.whl → 4.5.3.post4__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 +1 -1
- grid_py/_draw.py +6 -1
- grid_py/_gpar.py +44 -18
- grid_py/_grob.py +6 -3
- grid_py/_lty.py +197 -0
- grid_py/_patterns.py +11 -4
- grid_py/_primitives.py +18 -5
- grid_py/_renderer_base.py +92 -7
- grid_py/_viewport.py +28 -7
- grid_py/renderer.py +80 -42
- grid_py/renderer_web.py +31 -2
- grid_py/resources/gridpy.js +22 -25
- {rgrid_python-4.5.3.post3.dist-info → rgrid_python-4.5.3.post4.dist-info}/METADATA +1 -1
- {rgrid_python-4.5.3.post3.dist-info → rgrid_python-4.5.3.post4.dist-info}/RECORD +16 -15
- {rgrid_python-4.5.3.post3.dist-info → rgrid_python-4.5.3.post4.dist-info}/WHEEL +0 -0
- {rgrid_python-4.5.3.post3.dist-info → rgrid_python-4.5.3.post4.dist-info}/licenses/LICENSE +0 -0
grid_py/__init__.py
CHANGED
grid_py/_draw.py
CHANGED
|
@@ -215,7 +215,12 @@ def _draw_arrow_heads(
|
|
|
215
215
|
try:
|
|
216
216
|
l_w = float(renderer.resolve_w(length_unit, gp=gp))
|
|
217
217
|
l_h = float(renderer.resolve_h(length_unit, gp=gp))
|
|
218
|
-
except
|
|
218
|
+
except (ValueError, AttributeError, TypeError) as exc:
|
|
219
|
+
warnings.warn(
|
|
220
|
+
f"arrow length cannot be resolved ({exc}); arrow head skipped",
|
|
221
|
+
UserWarning,
|
|
222
|
+
stacklevel=2,
|
|
223
|
+
)
|
|
219
224
|
return
|
|
220
225
|
length_dev = min(l_w, l_h)
|
|
221
226
|
if not (length_dev > 0):
|
grid_py/_gpar.py
CHANGED
|
@@ -20,14 +20,12 @@ __all__ = ["Gpar", "gpar", "get_gpar"]
|
|
|
20
20
|
# Constants
|
|
21
21
|
# ---------------------------------------------------------------------------
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
"twodash",
|
|
30
|
-
}
|
|
23
|
+
# Derived from grid_py._lty so there is exactly one source of truth for
|
|
24
|
+
# the named-lty set. ``"blank"`` is added because R par/grid accepts it
|
|
25
|
+
# even though it has no hex equivalent (it short-circuits the stroke).
|
|
26
|
+
from ._lty import valid_named_lty as _valid_named_lty
|
|
27
|
+
|
|
28
|
+
_VALID_LTY: frozenset[str] = _valid_named_lty()
|
|
31
29
|
|
|
32
30
|
_VALID_LINEEND: set[str] = {"round", "butt", "square"}
|
|
33
31
|
|
|
@@ -77,9 +75,12 @@ def _as_list(value: Any) -> list:
|
|
|
77
75
|
return [value]
|
|
78
76
|
|
|
79
77
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
78
|
+
# NOTE: ``_is_hex_lty`` used to live here. It has been removed because
|
|
79
|
+
# hex-string validity is now decided by ``grid_py._lty.resolve_lty``,
|
|
80
|
+
# which is the single source of truth for both "is this lty valid?" and
|
|
81
|
+
# "what dash array does it expand to?". Keeping a separate validator
|
|
82
|
+
# here would create the same kind of dual-source landmine that
|
|
83
|
+
# motivated B7 in the first place.
|
|
83
84
|
|
|
84
85
|
|
|
85
86
|
def _resolve_fontface(value: Any) -> int:
|
|
@@ -254,14 +255,19 @@ class Gpar:
|
|
|
254
255
|
) from exc
|
|
255
256
|
|
|
256
257
|
elif name == "lty":
|
|
258
|
+
# Validation delegates to grid_py._lty so accept/reject
|
|
259
|
+
# decisions match the renderer-time resolver exactly.
|
|
260
|
+
# ``resolve_lty`` raises ValueError on bad input with a
|
|
261
|
+
# message already containing the offending value, so we
|
|
262
|
+
# let it propagate (no try/except — principle 4).
|
|
263
|
+
from ._lty import is_blank_lty, resolve_lty
|
|
257
264
|
for v in vals:
|
|
258
|
-
if
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
elif not isinstance(v, (int, float, np.integer, np.floating)):
|
|
265
|
+
if is_blank_lty(v):
|
|
266
|
+
continue
|
|
267
|
+
if isinstance(v, (str, int, float, np.integer, np.floating)) \
|
|
268
|
+
and not isinstance(v, bool):
|
|
269
|
+
resolve_lty(v) # raises ValueError if invalid
|
|
270
|
+
else:
|
|
265
271
|
raise TypeError(
|
|
266
272
|
f"'lty' must be str or numeric, got {type(v).__name__}"
|
|
267
273
|
)
|
|
@@ -462,6 +468,26 @@ class Gpar:
|
|
|
462
468
|
gp._params = merged
|
|
463
469
|
return gp
|
|
464
470
|
|
|
471
|
+
# -- mod (R: mod.gpar) -------------------------------------------------
|
|
472
|
+
|
|
473
|
+
def _mod(self, new_gp: "Gpar") -> "Gpar":
|
|
474
|
+
"""Edit-time merge (R ``mod.gpar`` — gpar.R:298-304).
|
|
475
|
+
|
|
476
|
+
Plain key overwrite — ``new_gp``'s keys replace ``self``'s keys,
|
|
477
|
+
unmentioned keys are kept unchanged. Unlike :meth:`_merge`
|
|
478
|
+
(which mirrors R ``set.gpar`` and multiplies cumulative
|
|
479
|
+
params like ``cex`` / ``alpha`` / ``lex``), this method is the
|
|
480
|
+
one used by ``editGrob`` and does NOT multiply — R's
|
|
481
|
+
``mod.gpar`` does a single ``gp[names(newgp)] <- newgp``.
|
|
482
|
+
"""
|
|
483
|
+
if not self._params:
|
|
484
|
+
return new_gp
|
|
485
|
+
merged = copy.deepcopy(self._params)
|
|
486
|
+
merged.update(copy.deepcopy(new_gp._params))
|
|
487
|
+
gp = object.__new__(Gpar)
|
|
488
|
+
gp._params = merged
|
|
489
|
+
return gp
|
|
490
|
+
|
|
465
491
|
# -- display -----------------------------------------------------------
|
|
466
492
|
|
|
467
493
|
def __repr__(self) -> str:
|
grid_py/_grob.py
CHANGED
|
@@ -1157,12 +1157,15 @@ def _edit_this_grob(grob: Grob, specs: dict[str, Any]) -> Grob:
|
|
|
1157
1157
|
if not key:
|
|
1158
1158
|
continue
|
|
1159
1159
|
if key == "gp":
|
|
1160
|
-
# Special handling
|
|
1160
|
+
# Special handling — R editGrob uses ``mod.gpar`` (gpar.R:298-304)
|
|
1161
|
+
# which does a plain key overwrite (``gp[names(newgp)] <- newgp``)
|
|
1162
|
+
# *without* the cumulative cex/alpha/lex multiplication that
|
|
1163
|
+
# viewport-push's ``set.gpar`` applies. ``Gpar._mod`` is the
|
|
1164
|
+
# mirror; ``Gpar._merge`` is the cumulative one.
|
|
1161
1165
|
if value is None:
|
|
1162
1166
|
grob._gp = None
|
|
1163
1167
|
elif grob._gp is not None:
|
|
1164
|
-
|
|
1165
|
-
grob._gp = grob._gp.merge(value) if hasattr(grob._gp, "merge") else value
|
|
1168
|
+
grob._gp = grob._gp._mod(value)
|
|
1166
1169
|
else:
|
|
1167
1170
|
grob._gp = value
|
|
1168
1171
|
elif key == "name":
|
grid_py/_lty.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Linetype resolution — R-faithful port of GE_LTYpar + CairoLineType.
|
|
2
|
+
|
|
3
|
+
This is the single source of truth for translating any R-accepted lty
|
|
4
|
+
specification (named string, R integer 0..6, hex string of length
|
|
5
|
+
{2,4,6,8}) into a Cairo / SVG ready dash array. Both the Cairo and the
|
|
6
|
+
Web renderers consume the same ``resolve_lty`` output, so any lty
|
|
7
|
+
behaviour fix lands in exactly one place.
|
|
8
|
+
|
|
9
|
+
R reference (R 4.5.3):
|
|
10
|
+
src/include/R_ext/GraphicsEngine.h:406-412 (LTY_* constants)
|
|
11
|
+
src/main/engine.c::GE_LTYpar (string -> 32-bit int)
|
|
12
|
+
src/library/grDevices/src/cairoFns.c::CairoLineType
|
|
13
|
+
(int -> dash array)
|
|
14
|
+
|
|
15
|
+
The cairo-dash scaling factor is *1.0*: ``set_dash([nibble * lwd, ...])``.
|
|
16
|
+
This was empirically verified by reverse-engineering R's cairo PNG output
|
|
17
|
+
(lty="44" at lwd in {1, 1.5, 2, 4, 6, 8} matches ``period = 2*nibble*lwd
|
|
18
|
+
+ 2*cap``, where cap ~= lwd for the default lineend="round"). A 0.75
|
|
19
|
+
factor that appeared in early drafts of this file was a memory error
|
|
20
|
+
(possibly conflated with a non-cairo R device) and would cause every
|
|
21
|
+
dash pattern to shrink by 25 %.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import Any, Optional, Sequence, Union
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"resolve_lty",
|
|
32
|
+
"is_blank_lty",
|
|
33
|
+
"valid_named_lty",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# R-gold constants (verbatim from R source, do not edit without R reference)
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
# GraphicsEngine.h:407-412 — every named lty is *also* expressible as a
|
|
42
|
+
# hex string, so resolve_lty has a single parser code path. Low nibble
|
|
43
|
+
# of the integer constant sits in the leftmost character of the hex
|
|
44
|
+
# string (R's GE_LTYpar packs bits low-to-high as it scans left-to-right).
|
|
45
|
+
# LTY_SOLID = 0 ""
|
|
46
|
+
# LTY_DASHED = 0x44 "44"
|
|
47
|
+
# LTY_DOTTED = 0x31 -> low nibble 1, high nibble 3 -> "13"
|
|
48
|
+
# LTY_DOTDASH = 0x3431 "1343"
|
|
49
|
+
# LTY_LONGDASH = 0x37 "73"
|
|
50
|
+
# LTY_TWODASH = 0x2622 "2262"
|
|
51
|
+
_LTY_NAMED_TO_HEX: dict[str, str] = {
|
|
52
|
+
"solid": "",
|
|
53
|
+
"dashed": "44",
|
|
54
|
+
"dotted": "13",
|
|
55
|
+
"dotdash": "1343",
|
|
56
|
+
"longdash": "73",
|
|
57
|
+
"twodash": "2262",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# R ?par integer codes (user-facing). Note that "user-facing 0" is BLANK,
|
|
61
|
+
# *not* the internal LTY_SOLID=0 constant: GE_LTYpar maps user 0 -> internal
|
|
62
|
+
# LTY_BLANK (-1) and user 1 -> internal LTY_SOLID (0).
|
|
63
|
+
_LTY_INT_TO_NAME: dict[int, str] = {
|
|
64
|
+
1: "solid",
|
|
65
|
+
2: "dashed",
|
|
66
|
+
3: "dotted",
|
|
67
|
+
4: "dotdash",
|
|
68
|
+
5: "longdash",
|
|
69
|
+
6: "twodash",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# R-gold (empirically verified): GE_LTYpar rejects any hex string whose
|
|
73
|
+
# length is not in {2, 4, 6, 8}. Single nibble "F" -> "invalid line
|
|
74
|
+
# type: must be length 2, 4, 6 or 8".
|
|
75
|
+
_LTY_VALID_HEX_LENS: frozenset[int] = frozenset({2, 4, 6, 8})
|
|
76
|
+
|
|
77
|
+
LtyInput = Union[str, int, float, None, list, tuple, np.ndarray]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# Public API
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
def valid_named_lty() -> frozenset[str]:
|
|
85
|
+
"""Set of R named lty values accepted by Gpar (includes ``"blank"``)."""
|
|
86
|
+
return frozenset(_LTY_NAMED_TO_HEX.keys()) | {"blank"}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def is_blank_lty(value: LtyInput) -> bool:
|
|
90
|
+
"""True iff *value* represents R LTY_BLANK (caller should skip stroke).
|
|
91
|
+
|
|
92
|
+
Mirrors R par convention:
|
|
93
|
+
* user-facing integer 0 -> blank
|
|
94
|
+
* "blank" (string) -> blank
|
|
95
|
+
* NA / None inside a Gpar list sentinel -> blank
|
|
96
|
+
* **but** scalar ``None`` (no lty supplied) -> not blank (= solid)
|
|
97
|
+
* **but** string ``"0"`` -> not blank (R rejects len-1 hex; see
|
|
98
|
+
resolve_lty); we follow R's quirk and only treat *integer* 0 as
|
|
99
|
+
blank, not string "0".
|
|
100
|
+
"""
|
|
101
|
+
if value is None:
|
|
102
|
+
return False
|
|
103
|
+
raw = value[0] if isinstance(value, (list, tuple, np.ndarray)) and len(value) else value
|
|
104
|
+
if raw is None:
|
|
105
|
+
return True
|
|
106
|
+
if isinstance(raw, (int, float, np.integer, np.floating)) and not isinstance(raw, bool):
|
|
107
|
+
return int(raw) == 0
|
|
108
|
+
return str(raw).lower() == "blank"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def resolve_lty(value: LtyInput, lwd: float = 1.0) -> Optional[list[float]]:
|
|
112
|
+
"""Resolve any R-accepted lty input into a Cairo-ready dash array.
|
|
113
|
+
|
|
114
|
+
Parameters
|
|
115
|
+
----------
|
|
116
|
+
value : str | int | float | None | sequence
|
|
117
|
+
- ``None`` -> solid
|
|
118
|
+
- One of ``valid_named_lty()`` -> the canonical hex pattern
|
|
119
|
+
- Hex string, length in {2,4,6,8} with chars [0-9A-Fa-f]
|
|
120
|
+
- Integer in 1..6 (R par convention; 0 is BLANK and must be
|
|
121
|
+
caught by ``is_blank_lty`` *before* calling this function)
|
|
122
|
+
- List/tuple/ndarray: first element is used (R recycling rule
|
|
123
|
+
is the caller's job)
|
|
124
|
+
|
|
125
|
+
Returns
|
|
126
|
+
-------
|
|
127
|
+
None or list[float]
|
|
128
|
+
- ``None`` for solid (no dashing)
|
|
129
|
+
- Otherwise the cairo ``set_dash`` array. Each element is
|
|
130
|
+
``hex_digit * lwd`` user-space units (or whatever space the
|
|
131
|
+
caller's ``lwd`` lives in — caller must keep lwd and dash in
|
|
132
|
+
the same coordinate system).
|
|
133
|
+
|
|
134
|
+
Raises
|
|
135
|
+
------
|
|
136
|
+
ValueError
|
|
137
|
+
On unrecognised input. Caller must invoke ``is_blank_lty``
|
|
138
|
+
first; passing a blank-representing value here raises.
|
|
139
|
+
"""
|
|
140
|
+
if value is None:
|
|
141
|
+
return None
|
|
142
|
+
raw = value[0] if isinstance(value, (list, tuple, np.ndarray)) and len(value) else value
|
|
143
|
+
if raw is None:
|
|
144
|
+
# Caller forgot to short-circuit via is_blank_lty. Fail loud
|
|
145
|
+
# (per principle 4 — do not silently convert blank to solid).
|
|
146
|
+
raise ValueError(
|
|
147
|
+
"blank lty (NA sentinel) must be handled by caller via is_blank_lty()"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# R integer path — par convention 1..6 (0 is BLANK, handled by caller).
|
|
151
|
+
if isinstance(raw, (int, float, np.integer, np.floating)) and not isinstance(raw, bool):
|
|
152
|
+
i = int(raw)
|
|
153
|
+
if i == 0:
|
|
154
|
+
raise ValueError(
|
|
155
|
+
"lty=0 is BLANK; caller must check is_blank_lty() before resolve_lty()"
|
|
156
|
+
)
|
|
157
|
+
if i not in _LTY_INT_TO_NAME:
|
|
158
|
+
raise ValueError(f"Invalid integer lty: {i!r} (must be in 1..6)")
|
|
159
|
+
raw = _LTY_INT_TO_NAME[i]
|
|
160
|
+
|
|
161
|
+
s = str(raw)
|
|
162
|
+
|
|
163
|
+
# Named lty -> canonical hex (single parser path below for both forms)
|
|
164
|
+
if s in _LTY_NAMED_TO_HEX:
|
|
165
|
+
s = _LTY_NAMED_TO_HEX[s]
|
|
166
|
+
|
|
167
|
+
if s == "":
|
|
168
|
+
return None # solid
|
|
169
|
+
|
|
170
|
+
# Hex string validation — R GE_LTYpar rules (empirically verified):
|
|
171
|
+
# non-hex char -> "invalid hex digit in 'color' or 'lty'"
|
|
172
|
+
# length not in 2/4/6/8 -> "invalid line type: must be length 2, 4, 6 or 8"
|
|
173
|
+
if not all(c in "0123456789abcdefABCDEF" for c in s):
|
|
174
|
+
raise ValueError(f"Invalid lty: {s!r} (invalid hex digit)")
|
|
175
|
+
if len(s) not in _LTY_VALID_HEX_LENS:
|
|
176
|
+
raise ValueError(
|
|
177
|
+
f"Invalid lty: {s!r} (length {len(s)} not in {{2,4,6,8}})"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
# Cairo dash expansion — equivalent to R cairoFns.c::CairoLineType:
|
|
181
|
+
# while (l < 8 && lty != 0) {
|
|
182
|
+
# dt = lty & 15;
|
|
183
|
+
# if (dt == 0) break; // zero nibble terminates pattern
|
|
184
|
+
# ls[l] = dt * lwd; // scale factor 1.0 (R-verified)
|
|
185
|
+
# lty = lty >> 4;
|
|
186
|
+
# l++;
|
|
187
|
+
# }
|
|
188
|
+
# R's length pre-check guarantees s is len 2/4/6/8, so the only place a
|
|
189
|
+
# zero nibble appears is at the *end* of a longer pattern (e.g. "4400"
|
|
190
|
+
# legitimately means [4, 4] then stop).
|
|
191
|
+
dashes: list[float] = []
|
|
192
|
+
for c in s:
|
|
193
|
+
d = int(c, 16)
|
|
194
|
+
if d == 0:
|
|
195
|
+
break
|
|
196
|
+
dashes.append(d * lwd)
|
|
197
|
+
return dashes if dashes else None
|
grid_py/_patterns.py
CHANGED
|
@@ -914,8 +914,13 @@ def _resolve_linear_gradient(grad: LinearGradient) -> ResolvedPattern:
|
|
|
914
914
|
"stops": grad.stops,
|
|
915
915
|
"extend": grad.extend,
|
|
916
916
|
}
|
|
917
|
-
except
|
|
918
|
-
#
|
|
917
|
+
except (ValueError, AttributeError, TypeError, IndexError):
|
|
918
|
+
# Documented deferred-resolution fallback: when the gradient's
|
|
919
|
+
# endpoint Units can't be resolved against the current
|
|
920
|
+
# renderer state (e.g. ``"npc"`` outside a viewport), stash the
|
|
921
|
+
# original gradient so the renderer can resolve it later. R's
|
|
922
|
+
# ``resolvePattern.GridLinearGradient`` (patterns.R:401-418)
|
|
923
|
+
# delegates the same way via lazy attr lookup.
|
|
919
924
|
ref = {"type": "linear_gradient", "pattern": grad}
|
|
920
925
|
|
|
921
926
|
return ResolvedPattern(grad, ref)
|
|
@@ -949,7 +954,8 @@ def _resolve_radial_gradient(grad: RadialGradient) -> ResolvedPattern:
|
|
|
949
954
|
"stops": grad.stops,
|
|
950
955
|
"extend": grad.extend,
|
|
951
956
|
}
|
|
952
|
-
except
|
|
957
|
+
except (ValueError, AttributeError, TypeError, IndexError):
|
|
958
|
+
# Deferred-resolution fallback — see ``_resolve_linear_gradient``.
|
|
953
959
|
ref = {"type": "radial_gradient", "pattern": grad}
|
|
954
960
|
|
|
955
961
|
return ResolvedPattern(grad, ref)
|
|
@@ -974,7 +980,8 @@ def _resolve_tiling_pattern(pat: Pattern) -> ResolvedPattern:
|
|
|
974
980
|
"width": float(wh["w"][0]), "height": float(wh["h"][0]),
|
|
975
981
|
"extend": pat.extend,
|
|
976
982
|
}
|
|
977
|
-
except
|
|
983
|
+
except (ValueError, AttributeError, TypeError, IndexError):
|
|
984
|
+
# Deferred-resolution fallback — see ``_resolve_linear_gradient``.
|
|
978
985
|
ref = {"type": "tiling_pattern", "pattern": pat}
|
|
979
986
|
|
|
980
987
|
return ResolvedPattern(pat, ref)
|
grid_py/_primitives.py
CHANGED
|
@@ -20,6 +20,7 @@ rendering. When ``draw=False`` the grob is simply returned.
|
|
|
20
20
|
|
|
21
21
|
from __future__ import annotations
|
|
22
22
|
|
|
23
|
+
import numpy as np
|
|
23
24
|
from typing import (
|
|
24
25
|
Any,
|
|
25
26
|
Callable,
|
|
@@ -723,9 +724,13 @@ def points_grob(
|
|
|
723
724
|
Parameters
|
|
724
725
|
----------
|
|
725
726
|
x : Unit, numeric, or None
|
|
726
|
-
Horizontal coordinates.
|
|
727
|
+
Horizontal coordinates. ``None`` mirrors R's
|
|
728
|
+
``pointsGrob`` default of ``stats::runif(10)`` — 10 random
|
|
729
|
+
points drawn from ``np.random.uniform(0, 1, 10)``. The numpy
|
|
730
|
+
global RNG state controls reproducibility; seed via
|
|
731
|
+
``np.random.seed(...)`` if you need deterministic output.
|
|
727
732
|
y : Unit, numeric, or None
|
|
728
|
-
Vertical coordinates.
|
|
733
|
+
Vertical coordinates. Same defaulting rule as *x*.
|
|
729
734
|
size : Unit, numeric, or None
|
|
730
735
|
Symbol size. Defaults to ``Unit(1, "char")`` when ``None``.
|
|
731
736
|
default_units : str
|
|
@@ -744,13 +749,21 @@ def points_grob(
|
|
|
744
749
|
-------
|
|
745
750
|
Grob
|
|
746
751
|
A grob with ``_grid_class="points"``.
|
|
752
|
+
|
|
753
|
+
Notes
|
|
754
|
+
-----
|
|
755
|
+
R parity (primitives.R:1562-1563): ``pointsGrob`` defaults are
|
|
756
|
+
``x = stats::runif(10), y = stats::runif(10)``. Every no-arg call
|
|
757
|
+
therefore produces a different scatter — this is a debug /
|
|
758
|
+
illustration default, not a deterministic API. Real usage should
|
|
759
|
+
pass explicit coordinates.
|
|
747
760
|
"""
|
|
748
761
|
if x is None:
|
|
749
|
-
x = Unit(0.
|
|
762
|
+
x = Unit(np.random.uniform(0.0, 1.0, 10), default_units)
|
|
750
763
|
else:
|
|
751
764
|
x = _ensure_unit(x, default_units)
|
|
752
765
|
if y is None:
|
|
753
|
-
y = Unit(0.
|
|
766
|
+
y = Unit(np.random.uniform(0.0, 1.0, 10), default_units)
|
|
754
767
|
else:
|
|
755
768
|
y = _ensure_unit(y, default_units)
|
|
756
769
|
if size is None:
|
|
@@ -767,7 +780,7 @@ def grid_points(
|
|
|
767
780
|
x: Any = None,
|
|
768
781
|
y: Any = None,
|
|
769
782
|
size: Optional[Any] = None,
|
|
770
|
-
default_units: str = "
|
|
783
|
+
default_units: str = "native",
|
|
771
784
|
pch: Union[int, str] = 1,
|
|
772
785
|
name: Optional[str] = None,
|
|
773
786
|
gp: Optional[Gpar] = None,
|
grid_py/_renderer_base.py
CHANGED
|
@@ -18,6 +18,7 @@ Coordinate pipeline (matches R's grid/src/unit.c + viewport.c):
|
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
20
|
import math
|
|
21
|
+
import warnings
|
|
21
22
|
from abc import ABC, abstractmethod
|
|
22
23
|
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
|
23
24
|
|
|
@@ -369,7 +370,24 @@ class GridRenderer(ABC):
|
|
|
369
370
|
return self.text_extents(text, gp=gp)
|
|
370
371
|
|
|
371
372
|
def _do_apply_clip_vtr(self, vp: Any, vtr: ViewportTransformResult) -> None:
|
|
372
|
-
"""Apply clipping for a viewport using its transform.
|
|
373
|
+
"""Apply clipping for a viewport using its transform.
|
|
374
|
+
|
|
375
|
+
Dispatches on the viewport's ``_clip`` value:
|
|
376
|
+
|
|
377
|
+
* ``True`` / ``"on"`` → rectangular clip to viewport bounds
|
|
378
|
+
(the long-standing default; ``_apply_clip_rect``)
|
|
379
|
+
* :class:`GridClipPath` → arbitrary clip path against the grob
|
|
380
|
+
stored in the GridClipPath (R 4.1+; ``_apply_clip_grob``)
|
|
381
|
+
* Anything else → no clip (push ``False`` so ``pop`` knows not
|
|
382
|
+
to restore)
|
|
383
|
+
|
|
384
|
+
``_clip_stack`` stores the clip kind so ``pop_viewport`` can
|
|
385
|
+
decide whether to call ``_restore_clip`` (which undoes both
|
|
386
|
+
rect and path clips uniformly via the backend's save/restore
|
|
387
|
+
pair).
|
|
388
|
+
"""
|
|
389
|
+
from ._clippath import GridClipPath
|
|
390
|
+
|
|
373
391
|
clip = getattr(vp, "_clip", None)
|
|
374
392
|
if clip is True or clip == "on":
|
|
375
393
|
# Compute clip rect in device coords from the viewport bounds
|
|
@@ -384,6 +402,13 @@ class GridRenderer(ABC):
|
|
|
384
402
|
y0_device = self._device_height - y0_bottom - ph
|
|
385
403
|
self._apply_clip_rect(x0, y0_device, pw, ph)
|
|
386
404
|
self._clip_stack.append(True)
|
|
405
|
+
elif isinstance(clip, GridClipPath):
|
|
406
|
+
# R viewport.R:86-96 — grob/path clip is honoured by
|
|
407
|
+
# rendering the grob's geometry into the renderer's
|
|
408
|
+
# current path then applying ``cairo_clip`` (or the web
|
|
409
|
+
# backend equivalent). Backend-specific.
|
|
410
|
+
self._apply_clip_grob(clip._grob, vtr)
|
|
411
|
+
self._clip_stack.append(clip)
|
|
387
412
|
else:
|
|
388
413
|
self._clip_stack.append(False)
|
|
389
414
|
|
|
@@ -538,7 +563,10 @@ class GridRenderer(ABC):
|
|
|
538
563
|
try:
|
|
539
564
|
from ._state import get_state
|
|
540
565
|
return get_state()._scale
|
|
541
|
-
except
|
|
566
|
+
except (ImportError, AttributeError):
|
|
567
|
+
# Init-order edge: state module may not be importable during
|
|
568
|
+
# very early renderer construction. 1.0 is the documented
|
|
569
|
+
# default (R grid GSS_SCALE).
|
|
542
570
|
return 1.0
|
|
543
571
|
|
|
544
572
|
# ===================================================================== #
|
|
@@ -728,7 +756,16 @@ class GridRenderer(ABC):
|
|
|
728
756
|
if grob.vp is not None:
|
|
729
757
|
_pop_grob_vp(grob.vp)
|
|
730
758
|
|
|
731
|
-
except
|
|
759
|
+
except (ValueError, AttributeError, TypeError, IndexError) as exc:
|
|
760
|
+
# Evaluating a grob unit (grobWidth/Height/Ascent/Descent of
|
|
761
|
+
# a user grob) requires the grob to have valid coords + a
|
|
762
|
+
# measurable ``*Details`` method. Bad inputs surface here as
|
|
763
|
+
# a warning rather than silently returning 0 (which would
|
|
764
|
+
# collapse the grob into a degenerate point).
|
|
765
|
+
warnings.warn(
|
|
766
|
+
f"grob unit evaluation failed; falling back to 0 inches: {exc}",
|
|
767
|
+
UserWarning, stacklevel=2,
|
|
768
|
+
)
|
|
732
769
|
result = 0.0
|
|
733
770
|
finally:
|
|
734
771
|
# --- Restore state (R unit.c:561-562) ---
|
|
@@ -770,18 +807,35 @@ class GridRenderer(ABC):
|
|
|
770
807
|
y_unit = Unit(0.5, "npc")
|
|
771
808
|
try:
|
|
772
809
|
x_inches = self._resolve_to_inches(x_unit, "x", False, gp)
|
|
773
|
-
except
|
|
810
|
+
except (ValueError, AttributeError, TypeError, IndexError) as exc:
|
|
811
|
+
warnings.warn(
|
|
812
|
+
f"grob x-coord could not be resolved; "
|
|
813
|
+
f"defaulting anchor to x=0 inches: {exc}",
|
|
814
|
+
UserWarning, stacklevel=2,
|
|
815
|
+
)
|
|
774
816
|
x_inches = 0.0
|
|
775
817
|
try:
|
|
776
818
|
y_inches = self._resolve_to_inches(y_unit, "y", False, gp)
|
|
777
|
-
except
|
|
819
|
+
except (ValueError, AttributeError, TypeError, IndexError) as exc:
|
|
820
|
+
warnings.warn(
|
|
821
|
+
f"grob y-coord could not be resolved; "
|
|
822
|
+
f"defaulting anchor to y=0 inches: {exc}",
|
|
823
|
+
UserWarning, stacklevel=2,
|
|
824
|
+
)
|
|
778
825
|
y_inches = 0.0
|
|
779
826
|
|
|
780
827
|
# Width / height of the grob's bounding box, in inches.
|
|
781
828
|
def _details_inches(fn, axis: str) -> float:
|
|
782
829
|
try:
|
|
783
830
|
u = fn(grob)
|
|
784
|
-
except
|
|
831
|
+
except (ValueError, AttributeError, TypeError) as exc:
|
|
832
|
+
# User grob's ``width_details`` / ``height_details`` raised;
|
|
833
|
+
# surface so the user knows their grob is mis-implementing
|
|
834
|
+
# the size protocol.
|
|
835
|
+
warnings.warn(
|
|
836
|
+
f"grob {fn.__name__} failed; using 0 inches: {exc}",
|
|
837
|
+
UserWarning, stacklevel=2,
|
|
838
|
+
)
|
|
785
839
|
return 0.0
|
|
786
840
|
if u is None:
|
|
787
841
|
return 0.0
|
|
@@ -791,7 +845,12 @@ class GridRenderer(ABC):
|
|
|
791
845
|
return 0.0
|
|
792
846
|
try:
|
|
793
847
|
return float(self._resolve_to_inches(u, axis, True, gp))
|
|
794
|
-
except
|
|
848
|
+
except (ValueError, AttributeError, TypeError, IndexError) as exc:
|
|
849
|
+
warnings.warn(
|
|
850
|
+
f"grob {axis}-dim unit could not be resolved; "
|
|
851
|
+
f"using 0 inches: {exc}",
|
|
852
|
+
UserWarning, stacklevel=2,
|
|
853
|
+
)
|
|
795
854
|
return 0.0
|
|
796
855
|
|
|
797
856
|
w_in = _details_inches(width_details, "x")
|
|
@@ -1166,6 +1225,32 @@ class GridRenderer(ABC):
|
|
|
1166
1225
|
def _restore_clip(self) -> None:
|
|
1167
1226
|
...
|
|
1168
1227
|
|
|
1228
|
+
def _apply_clip_grob(self, grob: Any, vtr: ViewportTransformResult) -> None:
|
|
1229
|
+
"""Apply a grob-based clip path (R 4.1+ ``viewport(clip = grob)``).
|
|
1230
|
+
|
|
1231
|
+
Default implementation raises ``NotImplementedError`` — backends
|
|
1232
|
+
that support arbitrary clip paths should override. The standard
|
|
1233
|
+
Cairo backend uses its existing ``_path_collecting`` mode to
|
|
1234
|
+
render the grob's geometry into the current path then calls
|
|
1235
|
+
``ctx.clip()``; ``_restore_clip`` (paired with the implicit
|
|
1236
|
+
``ctx.save()`` here) reverts.
|
|
1237
|
+
|
|
1238
|
+
Parameters
|
|
1239
|
+
----------
|
|
1240
|
+
grob : Grob
|
|
1241
|
+
The grob whose geometry defines the clip path. Typically a
|
|
1242
|
+
primitive (rect/circle/polygon/path); ``gTree`` is unioned.
|
|
1243
|
+
vtr : ViewportTransformResult
|
|
1244
|
+
The current viewport transform; backends that need to
|
|
1245
|
+
push a temporary viewport before rendering the clip grob
|
|
1246
|
+
should use this.
|
|
1247
|
+
"""
|
|
1248
|
+
raise NotImplementedError(
|
|
1249
|
+
f"{type(self).__name__} does not support grob/GridClipPath "
|
|
1250
|
+
"viewport clips. Use clip='on' / 'off' / 'inherit', or a "
|
|
1251
|
+
"renderer backend that implements ``_apply_clip_grob``."
|
|
1252
|
+
)
|
|
1253
|
+
|
|
1169
1254
|
# ===================================================================== #
|
|
1170
1255
|
# Abstract methods: graphics state save/restore #
|
|
1171
1256
|
# ===================================================================== #
|
grid_py/_viewport.py
CHANGED
|
@@ -113,20 +113,29 @@ _CLIP_MAP = {
|
|
|
113
113
|
def _valid_clip(clip: Any) -> Any:
|
|
114
114
|
"""Normalise *clip* to its internal representation.
|
|
115
115
|
|
|
116
|
+
R parity (viewport.R:86-97): ``viewport(clip = ...)`` accepts
|
|
117
|
+
string sentinels (``"on"``/``"off"``/``"inherit"``), booleans /
|
|
118
|
+
``NA``, **and** a grob / ``GridPath`` / ``GridClipPath`` for
|
|
119
|
+
arbitrary clip-path masking (R 4.1+ feature). Grobs and GridPaths
|
|
120
|
+
are coerced to :class:`~grid_py.GridClipPath` exactly as R wraps
|
|
121
|
+
them via ``createClipPath(as.path(grob))``.
|
|
122
|
+
|
|
116
123
|
Parameters
|
|
117
124
|
----------
|
|
118
|
-
clip : bool, str, or
|
|
119
|
-
``"on"``
|
|
120
|
-
Booleans and ``None`` pass through unchanged.
|
|
125
|
+
clip : bool, str, grob, GridPath, GridClipPath, or None
|
|
126
|
+
``"on"`` → ``True``, ``"off"`` → ``None``, ``"inherit"`` →
|
|
127
|
+
``False``. Booleans and ``None`` pass through unchanged. Grob /
|
|
128
|
+
GridPath / GridClipPath produce a :class:`GridClipPath` (the
|
|
129
|
+
renderer dispatches on this type at viewport-push time).
|
|
121
130
|
|
|
122
131
|
Returns
|
|
123
132
|
-------
|
|
124
|
-
bool or
|
|
133
|
+
bool, None, or GridClipPath
|
|
125
134
|
|
|
126
135
|
Raises
|
|
127
136
|
------
|
|
128
137
|
ValueError
|
|
129
|
-
If *clip* is
|
|
138
|
+
If *clip* is none of the accepted shapes.
|
|
130
139
|
"""
|
|
131
140
|
if isinstance(clip, bool) or clip is None:
|
|
132
141
|
return clip
|
|
@@ -135,13 +144,25 @@ def _valid_clip(clip: Any) -> Any:
|
|
|
135
144
|
if val is None and clip.lower() != "off":
|
|
136
145
|
raise ValueError(
|
|
137
146
|
f"invalid 'clip' value {clip!r}; "
|
|
138
|
-
"must be 'on', 'off', 'inherit', or a
|
|
147
|
+
"must be 'on', 'off', 'inherit', a boolean, or a grob / "
|
|
148
|
+
"GridPath / GridClipPath"
|
|
139
149
|
)
|
|
140
150
|
# "off" -> None is correct from the map
|
|
141
151
|
return _CLIP_MAP.get(clip.lower(), None)
|
|
152
|
+
# R 4.1+: grob / GridPath / GridClipPath as clip
|
|
153
|
+
from ._clippath import GridClipPath, as_clip_path
|
|
154
|
+
if isinstance(clip, GridClipPath):
|
|
155
|
+
return clip
|
|
156
|
+
from ._path import GridPath
|
|
157
|
+
if isinstance(clip, GridPath):
|
|
158
|
+
return as_clip_path(clip)
|
|
159
|
+
from ._grob import Grob
|
|
160
|
+
if isinstance(clip, Grob):
|
|
161
|
+
return as_clip_path(clip)
|
|
142
162
|
raise ValueError(
|
|
143
163
|
f"invalid 'clip' value {clip!r}; "
|
|
144
|
-
"must be 'on', 'off', 'inherit', or a
|
|
164
|
+
"must be 'on', 'off', 'inherit', a boolean, or a grob / "
|
|
165
|
+
"GridPath / GridClipPath"
|
|
145
166
|
)
|
|
146
167
|
|
|
147
168
|
|
grid_py/renderer.py
CHANGED
|
@@ -13,6 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
|
|
14
14
|
import io
|
|
15
15
|
import math
|
|
16
|
+
import warnings
|
|
16
17
|
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
|
17
18
|
|
|
18
19
|
import numpy as np
|
|
@@ -27,6 +28,7 @@ except ImportError:
|
|
|
27
28
|
|
|
28
29
|
from ._colour import parse_r_colour as _parse_colour
|
|
29
30
|
from ._gpar import Gpar
|
|
31
|
+
from ._lty import is_blank_lty, resolve_lty
|
|
30
32
|
from ._patterns import LinearGradient, RadialGradient, Pattern
|
|
31
33
|
from ._renderer_base import GridRenderer
|
|
32
34
|
|
|
@@ -40,28 +42,12 @@ __all__ = ["CairoRenderer"]
|
|
|
40
42
|
# Line-type mapping
|
|
41
43
|
# ---------------------------------------------------------------------------
|
|
42
44
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
"twodash": [5.0, 2.0, 10.0, 2.0],
|
|
50
|
-
"blank": [0.0, 100.0],
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
# R allows ``lty`` to be an integer 0..6 (par docs): 0=blank, 1=solid,
|
|
54
|
-
# 2=dashed, 3=dotted, 4=dotdash, 5=longdash, 6=twodash. Map to the
|
|
55
|
-
# named equivalents so downstream dash lookup works for both forms.
|
|
56
|
-
_LTY_INT_TO_NAME: Dict[int, str] = {
|
|
57
|
-
0: "blank",
|
|
58
|
-
1: "solid",
|
|
59
|
-
2: "dashed",
|
|
60
|
-
3: "dotted",
|
|
61
|
-
4: "dotdash",
|
|
62
|
-
5: "longdash",
|
|
63
|
-
6: "twodash",
|
|
64
|
-
}
|
|
45
|
+
# Linetype dash arrays come from grid_py._lty.resolve_lty (single source
|
|
46
|
+
# of truth, shared with the Web renderer). The legacy ``_LTY_DASHES``
|
|
47
|
+
# table and ``_LTY_INT_TO_NAME`` dict used to live here; they have been
|
|
48
|
+
# removed because their dash values were not R-faithful (e.g. "dashed"
|
|
49
|
+
# resolved to [6, 4] instead of R's [4, 4]) and they offered no path for
|
|
50
|
+
# hex-string lty. See grid_py/_lty.py for the R-verified replacement.
|
|
65
51
|
|
|
66
52
|
_LINEEND_MAP = {
|
|
67
53
|
"round": cairo.LINE_CAP_ROUND,
|
|
@@ -181,6 +167,33 @@ class CairoRenderer(GridRenderer):
|
|
|
181
167
|
self._ctx.rectangle(x0, y0, w, h)
|
|
182
168
|
self._ctx.clip()
|
|
183
169
|
|
|
170
|
+
def _apply_clip_grob(self, grob, vtr):
|
|
171
|
+
"""Apply a grob-based clip path (R 4.1+ ``viewport(clip=grob)``).
|
|
172
|
+
|
|
173
|
+
Re-uses the existing ``_path_collecting`` mode (originally for
|
|
174
|
+
R 4.2+ ``fillStroke`` grobs): primitives drop their stroke /
|
|
175
|
+
fill calls but still emit Cairo path commands. After the
|
|
176
|
+
grob's geometry is in the current path we call ``ctx.clip()``;
|
|
177
|
+
the matching ``ctx.save()`` here pairs with the standard
|
|
178
|
+
``_restore_clip`` (``ctx.restore()``) on viewport pop, so
|
|
179
|
+
non-rect clips share the same pop machinery as rect clips.
|
|
180
|
+
"""
|
|
181
|
+
ctx = self._ctx
|
|
182
|
+
ctx.save()
|
|
183
|
+
# Mirrors begin_path_collect but inline because we manage
|
|
184
|
+
# save/restore ourselves above.
|
|
185
|
+
ctx.new_path()
|
|
186
|
+
prior_mode = self._path_collecting
|
|
187
|
+
self._path_collecting = True
|
|
188
|
+
try:
|
|
189
|
+
# Use the standard draw pipeline so per-primitive coord /
|
|
190
|
+
# unit / gp handling stays in one place.
|
|
191
|
+
from ._draw import grid_draw
|
|
192
|
+
grid_draw(grob, recording=False)
|
|
193
|
+
finally:
|
|
194
|
+
self._path_collecting = prior_mode
|
|
195
|
+
ctx.clip()
|
|
196
|
+
|
|
184
197
|
def _restore_clip(self) -> None:
|
|
185
198
|
self._ctx.restore()
|
|
186
199
|
|
|
@@ -296,7 +309,14 @@ class CairoRenderer(GridRenderer):
|
|
|
296
309
|
grid_draw(mask_grob, recording=False)
|
|
297
310
|
finally:
|
|
298
311
|
state._renderer = orig_renderer
|
|
299
|
-
except Exception:
|
|
312
|
+
except Exception as exc:
|
|
313
|
+
# Mask grob is user code — any exception is possible. Surface
|
|
314
|
+
# it as a warning so the user knows their mask didn't render,
|
|
315
|
+
# but don't crash the whole plot.
|
|
316
|
+
warnings.warn(
|
|
317
|
+
f"mask grob render failed; mask skipped: {exc}",
|
|
318
|
+
UserWarning, stacklevel=2,
|
|
319
|
+
)
|
|
300
320
|
return None
|
|
301
321
|
|
|
302
322
|
return mask_surface
|
|
@@ -358,7 +378,11 @@ class CairoRenderer(GridRenderer):
|
|
|
358
378
|
try:
|
|
359
379
|
ux, uy = self._ctx.device_to_user_distance(lw_dev, lw_dev)
|
|
360
380
|
return max(abs(ux), abs(uy))
|
|
361
|
-
except
|
|
381
|
+
except cairo.Error:
|
|
382
|
+
# Cairo can refuse the conversion when the user-space matrix
|
|
383
|
+
# is singular (e.g. zero-area viewport). Fall back to the
|
|
384
|
+
# device value — this is graceful degradation, not a bug
|
|
385
|
+
# to surface.
|
|
362
386
|
return lw_dev
|
|
363
387
|
|
|
364
388
|
def _apply_stroke(self, gp: Optional[Gpar]) -> Tuple[float, float, float, float]:
|
|
@@ -406,23 +430,27 @@ class CairoRenderer(GridRenderer):
|
|
|
406
430
|
# R semantics: lwd=0 means invisible line
|
|
407
431
|
if lw <= 0:
|
|
408
432
|
return (0.0, 0.0, 0.0, 0.0)
|
|
409
|
-
|
|
410
|
-
|
|
433
|
+
# lwd is supplied in R points; convert to user-space units once
|
|
434
|
+
# so set_line_width AND the dash array (below) share coordinates.
|
|
435
|
+
lw_user = self._lwd_to_user(lw)
|
|
436
|
+
ctx.set_line_width(lw_user)
|
|
437
|
+
|
|
438
|
+
# lty handling — single source of truth in grid_py/_lty.py.
|
|
439
|
+
# Short-circuit pattern is symmetric with the col=NA and lwd<=0
|
|
440
|
+
# branches above: ``Gpar(lty=NA)`` / ``Gpar(lty=0)`` skip the
|
|
441
|
+
# stroke entirely (R par convention).
|
|
411
442
|
lty = gp.get("lty", None)
|
|
412
|
-
if lty
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
if isinstance(raw, (int, float, np.integer, np.floating)) and \
|
|
416
|
-
not isinstance(raw, bool):
|
|
417
|
-
raw = _LTY_INT_TO_NAME.get(int(raw), str(raw))
|
|
418
|
-
lty_val = str(raw)
|
|
419
|
-
dashes = _LTY_DASHES.get(lty_val)
|
|
420
|
-
if dashes is not None:
|
|
421
|
-
ctx.set_dash(dashes)
|
|
422
|
-
else:
|
|
423
|
-
ctx.set_dash([])
|
|
424
|
-
else:
|
|
443
|
+
if is_blank_lty(lty):
|
|
444
|
+
return (0.0, 0.0, 0.0, 0.0)
|
|
445
|
+
if lty is None:
|
|
425
446
|
ctx.set_dash([])
|
|
447
|
+
else:
|
|
448
|
+
# lwd lives in *user space* after ``_lwd_to_user`` (see
|
|
449
|
+
# commit ef7aee5 for the historical CTM regression that
|
|
450
|
+
# established this). resolve_lty must receive the same
|
|
451
|
+
# space so set_dash and set_line_width share coordinates.
|
|
452
|
+
dashes = resolve_lty(lty, lwd=lw_user)
|
|
453
|
+
ctx.set_dash(dashes if dashes else [])
|
|
426
454
|
|
|
427
455
|
lineend = gp.get("lineend", None)
|
|
428
456
|
if lineend is not None:
|
|
@@ -685,8 +713,14 @@ class CairoRenderer(GridRenderer):
|
|
|
685
713
|
grid_draw(pat.grob, recording=False)
|
|
686
714
|
finally:
|
|
687
715
|
state._renderer = orig_renderer
|
|
688
|
-
except Exception:
|
|
689
|
-
#
|
|
716
|
+
except Exception as exc:
|
|
717
|
+
# Tiling-pattern grob is user code — surface failures so the
|
|
718
|
+
# user knows their pattern fell back to transparent.
|
|
719
|
+
warnings.warn(
|
|
720
|
+
f"tiling pattern grob render failed; "
|
|
721
|
+
f"falling back to transparent: {exc}",
|
|
722
|
+
UserWarning, stacklevel=2,
|
|
723
|
+
)
|
|
690
724
|
return (0.0, 0.0, 0.0, 0.0)
|
|
691
725
|
|
|
692
726
|
tile_surface = tile_renderer._surface
|
|
@@ -1742,7 +1776,11 @@ class CairoRenderer(GridRenderer):
|
|
|
1742
1776
|
result = ctx.pop_group()
|
|
1743
1777
|
return result
|
|
1744
1778
|
|
|
1745
|
-
except
|
|
1779
|
+
except cairo.Error as exc:
|
|
1780
|
+
warnings.warn(
|
|
1781
|
+
f"group composition failed (cairo): {exc}",
|
|
1782
|
+
UserWarning, stacklevel=2,
|
|
1783
|
+
)
|
|
1746
1784
|
return None
|
|
1747
1785
|
|
|
1748
1786
|
def use_group(self, ref: Any, transform: Any = None) -> None:
|
grid_py/renderer_web.py
CHANGED
|
@@ -17,6 +17,7 @@ import numpy as np
|
|
|
17
17
|
|
|
18
18
|
from ._font_metrics import get_font_backend, FontMetricsBackend
|
|
19
19
|
from ._gpar import Gpar
|
|
20
|
+
from ._lty import is_blank_lty, resolve_lty
|
|
20
21
|
from ._patterns import LinearGradient, RadialGradient, Pattern
|
|
21
22
|
from ._renderer_base import GridRenderer
|
|
22
23
|
from ._scene_graph import (
|
|
@@ -38,11 +39,20 @@ from ._colour import colour_to_css as _parse_colour_str
|
|
|
38
39
|
|
|
39
40
|
|
|
40
41
|
def _serialise_gpar(gp: Optional[Gpar], defs: DefsCollection, id_gen: _IdGenerator) -> dict:
|
|
41
|
-
"""Convert Gpar to a JSON-safe dict. Gradient/pattern fills become def refs.
|
|
42
|
+
"""Convert Gpar to a JSON-safe dict. Gradient/pattern fills become def refs.
|
|
43
|
+
|
|
44
|
+
Linetype handling: ``gpar.lty`` is *not* transmitted to JS as a string.
|
|
45
|
+
Instead we pre-resolve it to a numeric dash array via
|
|
46
|
+
:func:`grid_py._lty.resolve_lty` (R-faithful) and emit it as
|
|
47
|
+
``result["dash"]``. ``Gpar(lty=NA)`` (blank) becomes
|
|
48
|
+
``result["skip_stroke"] = True``. This collapses all R lty knowledge
|
|
49
|
+
into one Python module — the JS side just paints whatever array it
|
|
50
|
+
receives.
|
|
51
|
+
"""
|
|
42
52
|
if gp is None:
|
|
43
53
|
return {}
|
|
44
54
|
result: Dict[str, Any] = {}
|
|
45
|
-
for key in ("col", "fill", "lwd", "
|
|
55
|
+
for key in ("col", "fill", "lwd", "lineend", "linejoin",
|
|
46
56
|
"fontsize", "fontfamily", "fontface", "alpha"):
|
|
47
57
|
val = gp.get(key, None)
|
|
48
58
|
if val is None:
|
|
@@ -80,6 +90,25 @@ def _serialise_gpar(gp: Optional[Gpar], defs: DefsCollection, id_gen: _IdGenerat
|
|
|
80
90
|
result[key] = float(val)
|
|
81
91
|
else:
|
|
82
92
|
result[key] = str(val) if not isinstance(val, (int, float)) else val
|
|
93
|
+
|
|
94
|
+
# ---- lty -> dash array (single source of truth via _lty.resolve_lty) ----
|
|
95
|
+
# Web backend lwd convention: raw R points (matches the unconverted
|
|
96
|
+
# ``result["lwd"] = float(...)`` we just emitted, which is what JS
|
|
97
|
+
# passes verbatim to SVG `stroke-width`). SVG `stroke-dasharray`
|
|
98
|
+
# does *not* auto-scale with stroke-width, so the resolver pre-multiplies
|
|
99
|
+
# ``nibble × lwd`` here; the two end up in the same coordinate space.
|
|
100
|
+
lty_in = gp.get("lty", None)
|
|
101
|
+
if lty_in is not None and (not isinstance(lty_in, (list, tuple, np.ndarray)) or len(lty_in) > 0):
|
|
102
|
+
if is_blank_lty(lty_in):
|
|
103
|
+
result["skip_stroke"] = True
|
|
104
|
+
else:
|
|
105
|
+
lwd_raw_in = gp.get("lwd", None)
|
|
106
|
+
if isinstance(lwd_raw_in, (list, tuple, np.ndarray)) and len(lwd_raw_in):
|
|
107
|
+
lwd_raw_in = lwd_raw_in[0]
|
|
108
|
+
lwd_raw = float(lwd_raw_in) if lwd_raw_in is not None else 1.0
|
|
109
|
+
dash = resolve_lty(lty_in, lwd=lwd_raw)
|
|
110
|
+
if dash is not None:
|
|
111
|
+
result["dash"] = dash
|
|
83
112
|
return result
|
|
84
113
|
|
|
85
114
|
|
grid_py/resources/gridpy.js
CHANGED
|
@@ -24,13 +24,24 @@ var gridpy = (function () {
|
|
|
24
24
|
|
|
25
25
|
function applyGparSvg(sel, gpar) {
|
|
26
26
|
if (!gpar) return;
|
|
27
|
+
// skip_stroke is set by the Python serializer for blank lty
|
|
28
|
+
// (R LTY_BLANK) — mirrors col=NA short-circuit.
|
|
29
|
+
if (gpar.skip_stroke) {
|
|
30
|
+
sel.attr("stroke", "none");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
27
33
|
var fill = parseColour(gpar.fill);
|
|
28
34
|
var col = parseColour(gpar.col);
|
|
29
35
|
if (fill !== undefined) sel.attr("fill", fill || "none");
|
|
30
36
|
if (col !== undefined) sel.attr("stroke", col || "none");
|
|
31
37
|
if (gpar.lwd !== undefined) sel.attr("stroke-width", gpar.lwd);
|
|
32
38
|
if (gpar.alpha !== undefined) sel.attr("opacity", gpar.alpha);
|
|
33
|
-
|
|
39
|
+
// gpar.dash is the R-faithful dash array pre-resolved by
|
|
40
|
+
// grid_py._lty.resolve_lty (single source of truth shared with
|
|
41
|
+
// the Cairo backend). No JS-side lty knowledge needed.
|
|
42
|
+
if (gpar.dash && gpar.dash.length > 0) {
|
|
43
|
+
sel.attr("stroke-dasharray", gpar.dash.join(","));
|
|
44
|
+
}
|
|
34
45
|
if (gpar.lineend) sel.attr("stroke-linecap", gpar.lineend);
|
|
35
46
|
if (gpar.linejoin) sel.attr("stroke-linejoin",
|
|
36
47
|
gpar.linejoin === "mitre" ? "miter" : gpar.linejoin);
|
|
@@ -55,6 +66,12 @@ var gridpy = (function () {
|
|
|
55
66
|
|
|
56
67
|
function applyGparCanvas(ctx, gpar) {
|
|
57
68
|
if (!gpar) return;
|
|
69
|
+
if (gpar.skip_stroke) {
|
|
70
|
+
// R LTY_BLANK — short-circuit by zeroing stroke alpha.
|
|
71
|
+
ctx.strokeStyle = "rgba(0,0,0,0)";
|
|
72
|
+
ctx.setLineDash([]);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
58
75
|
var fill = parseColour(gpar.fill);
|
|
59
76
|
var col = parseColour(gpar.col);
|
|
60
77
|
ctx.fillStyle = fill || "rgba(0,0,0,0)";
|
|
@@ -64,30 +81,10 @@ var gridpy = (function () {
|
|
|
64
81
|
if (gpar.lineend) ctx.lineCap = gpar.lineend;
|
|
65
82
|
if (gpar.linejoin) ctx.lineJoin =
|
|
66
83
|
gpar.linejoin === "mitre" ? "miter" : (gpar.linejoin || "round");
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
ctx.setLineDash([]);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function ltyToDash(lty) {
|
|
76
|
-
var map = {
|
|
77
|
-
"dashed": "6,4", "dotted": "2,2",
|
|
78
|
-
"dotdash": "2,2,6,2", "longdash": "10,3",
|
|
79
|
-
"twodash": "5,2,10,2", "blank": "0,100"
|
|
80
|
-
};
|
|
81
|
-
return map[lty] || null;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function ltyToDashArray(lty) {
|
|
85
|
-
var map = {
|
|
86
|
-
"dashed": [6, 4], "dotted": [2, 2],
|
|
87
|
-
"dotdash": [2, 2, 6, 2], "longdash": [10, 3],
|
|
88
|
-
"twodash": [5, 2, 10, 2], "blank": [0, 100]
|
|
89
|
-
};
|
|
90
|
-
return map[lty] || [];
|
|
84
|
+
// gpar.dash is the pre-resolved cairo-style dash array from the
|
|
85
|
+
// Python side (grid_py._lty.resolve_lty). See applyGparSvg for
|
|
86
|
+
// the rationale.
|
|
87
|
+
ctx.setLineDash(gpar.dash || []);
|
|
91
88
|
}
|
|
92
89
|
|
|
93
90
|
function hjustToAnchor(hj) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rgrid-python
|
|
3
|
-
Version: 4.5.3.
|
|
3
|
+
Version: 4.5.3.post4
|
|
4
4
|
Summary: Python port of the R grid package (tracks R grid 4.5.3)
|
|
5
5
|
Project-URL: Homepage, https://github.com/Bio-Babel/grid_py
|
|
6
6
|
Project-URL: Repository, https://github.com/Bio-Babel/grid_py
|
|
@@ -1,26 +1,27 @@
|
|
|
1
|
-
grid_py/__init__.py,sha256=
|
|
1
|
+
grid_py/__init__.py,sha256=K_SAUQUXVz-7mYQEYPWR9_5aWLPJjUdhu1nVSunpk_I,10520
|
|
2
2
|
grid_py/_arrow.py,sha256=pgn4OCgF6TZY2yXl7ML-kt08DbwqlvT1ulApDcmRz4k,10867
|
|
3
3
|
grid_py/_clippath.py,sha256=p6fUAkcEc5Bg8chv-lIZwaOjEUbLK57duwjtIpDjAkw,4015
|
|
4
4
|
grid_py/_colour.py,sha256=WqaxGop-SM1LYZG4uMPmMX3Zjh6Y_ip0HcanP5wEij8,22872
|
|
5
5
|
grid_py/_coords.py,sha256=9cDD3MWHmw9TnT1I8QKHQA96SCG06OxRXNqYGFZsfwg,47210
|
|
6
6
|
grid_py/_curve.py,sha256=QnUkh9lWLd0OSm7h7QwS78JjzBAu9WHmRY4ZpZhccpY,54989
|
|
7
7
|
grid_py/_display_list.py,sha256=uMFAupSJaCgUCbLMv2-RDBQ-YLxCkAldypRqc7CnF3w,13622
|
|
8
|
-
grid_py/_draw.py,sha256=
|
|
8
|
+
grid_py/_draw.py,sha256=YR2JYfOspg3MRb-vR47ebhBJq3pKJZ7QMarkcVNHkNA,51150
|
|
9
9
|
grid_py/_edit.py,sha256=vQDZGBTTYy6DmlW33l94s1KJLrzPNym21NR4Od4qIFw,22263
|
|
10
10
|
grid_py/_font_metrics.py,sha256=XC_dgIN3eB72VPXIRFU-tynnS3wJl3bPugGHrwVzyeo,11086
|
|
11
|
-
grid_py/_gpar.py,sha256=
|
|
11
|
+
grid_py/_gpar.py,sha256=Bsq5oevTOf-ISb6T1K4cqPcA7UKX0XHbg-AddMomWzQ,21302
|
|
12
12
|
grid_py/_grab.py,sha256=XAPdYG2gyl9Px29xM_rByIPA2NwuvkbFaLcHGrqpaUQ,15423
|
|
13
|
-
grid_py/_grob.py,sha256=
|
|
13
|
+
grid_py/_grob.py,sha256=pxbXCt5aYstiBKLw3TyII_avZOZVLR79eSoWi_bE2Ag,40224
|
|
14
14
|
grid_py/_group.py,sha256=jmPQPcy_lrVjurF8w5ovhCXvwKLBeGVkOwAvX0X0gA0,22828
|
|
15
15
|
grid_py/_highlevel.py,sha256=KVcFc0aUm6WBGyNKlYJfZ16qC4bRolng8ZKb86lSLpc,61803
|
|
16
16
|
grid_py/_just.py,sha256=lh9ar8lw6JRqZE5qHP4Wx7GHPIW0y7Wp0EgJ7vhFjek,10590
|
|
17
17
|
grid_py/_layout.py,sha256=opOEvxTO2gjwuLEt8sbKjp4yexjnx0Gik6d95yoL3aI,19721
|
|
18
18
|
grid_py/_ls.py,sha256=RhUGZJCZkcTUjUK97fft_G4D9KxuyBr3OMFhzC5IShc,25547
|
|
19
|
+
grid_py/_lty.py,sha256=ciQKy_P_QmJbmYTsSeQY8cGxXvnrowJbBS-Zzf4birE,7762
|
|
19
20
|
grid_py/_mask.py,sha256=d2Wm2z-RJnfEKHdAHcjteL7VubimcaK1_f9Us9MfrUk,4902
|
|
20
21
|
grid_py/_path.py,sha256=Tr5bNNcGwPpOsxWKqEp65MRri-KV8IqplUh2Z90sxnk,11421
|
|
21
|
-
grid_py/_patterns.py,sha256=
|
|
22
|
-
grid_py/_primitives.py,sha256=
|
|
23
|
-
grid_py/_renderer_base.py,sha256=
|
|
22
|
+
grid_py/_patterns.py,sha256=t6IkayYPaupEbF9bFZQmS0iXZhB9TWrvN2xRZzR7baA,33302
|
|
23
|
+
grid_py/_primitives.py,sha256=Aq0fCdsy-eJ57v_X9JeFlsmm62jEZMlEOuK3u3v6YEg,59522
|
|
24
|
+
grid_py/_renderer_base.py,sha256=wECYIgNR2UNXXneWoaJspbBkclBTxTbM3in5Hva7nNY,59011
|
|
24
25
|
grid_py/_scene_graph.py,sha256=mKhNEMkUolbWt4_CFgGhrGUudrYenxFNXaBp6GLN9_Y,7412
|
|
25
26
|
grid_py/_size.py,sha256=q9yYdhO7dcp-RtQzJIIsXssFH5oFbsm5_P6UxZlFKjg,43312
|
|
26
27
|
grid_py/_state.py,sha256=SsEzcicTmxWaGExDeLLNNVXT-G38aUFtgSuZTJEYBbw,22800
|
|
@@ -28,15 +29,15 @@ grid_py/_transforms.py,sha256=mQvs_Qm6icti66peLXTsjgmSmvIUCCphf_-D-tiE1O4,11675
|
|
|
28
29
|
grid_py/_typeset.py,sha256=J_PJ5YcOAOnnFkBfo8IW86utJdkdsrnGZi3MvnHc8io,11516
|
|
29
30
|
grid_py/_units.py,sha256=bGrHV23Vr2A6lO15LJvcP7Qs1Yx_uQJLmyQ6bRB3yjc,61893
|
|
30
31
|
grid_py/_utils.py,sha256=QaWNkCF3BbPHyrqPPRxN7Ji2vB5ethNwVT3SJ1ad9Lc,8335
|
|
31
|
-
grid_py/_viewport.py,sha256=
|
|
32
|
+
grid_py/_viewport.py,sha256=qscv8eRKt0vHH0osxZUkbwM9PfgZwTKXi8n7L8921DE,49490
|
|
32
33
|
grid_py/_vp_calc.py,sha256=v0MJc-YmWohO5t7LHFopk5ogH-Sink_A_zNFV_1769w,32935
|
|
33
34
|
grid_py/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
34
|
-
grid_py/renderer.py,sha256=
|
|
35
|
-
grid_py/renderer_web.py,sha256=
|
|
35
|
+
grid_py/renderer.py,sha256=DlzC7srfqZ0JRpEUddjwX39Kw4PUg9-FDH57-sI1RcQ,65093
|
|
36
|
+
grid_py/renderer_web.py,sha256=ntbLwcvTJNz_S0xRNGweiZmrkThUfW1AvCCCDSn2vSY,29461
|
|
36
37
|
grid_py/resources/d3.v7.min.js,sha256=8glLv2FBs1lyLE_kVOtsSw8OQswQzHr5IfwVj864ZTk,279706
|
|
37
38
|
grid_py/resources/gridpy.css,sha256=tR5LF2rLvi_bGRWuH9CnmLQk-aG2f-jlZjQZqS7_4uY,1351
|
|
38
|
-
grid_py/resources/gridpy.js,sha256=
|
|
39
|
-
rgrid_python-4.5.3.
|
|
40
|
-
rgrid_python-4.5.3.
|
|
41
|
-
rgrid_python-4.5.3.
|
|
42
|
-
rgrid_python-4.5.3.
|
|
39
|
+
grid_py/resources/gridpy.js,sha256=ekbmsIMCJU9ZqIU0WC1c-Y-LBEinGIL8UftFEVF9pC4,28555
|
|
40
|
+
rgrid_python-4.5.3.post4.dist-info/METADATA,sha256=3sNILdeJwNGhe9zFPrssIt90C_csOUrrrd7Ad6Uw3IY,19910
|
|
41
|
+
rgrid_python-4.5.3.post4.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
42
|
+
rgrid_python-4.5.3.post4.dist-info/licenses/LICENSE,sha256=8zNQKZlkc4JOaW7br_a8aALg4baZwZkLKLTQx3kuHkc,48
|
|
43
|
+
rgrid_python-4.5.3.post4.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|