rgrid-python 4.5.3.post2__tar.gz → 4.5.3.post4__tar.gz
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.
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/PKG-INFO +1 -1
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/__init__.py +1 -1
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/_curve.py +22 -1
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/_draw.py +39 -11
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/_gpar.py +44 -18
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/_grob.py +6 -3
- rgrid_python-4.5.3.post4/grid_py/_lty.py +197 -0
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/_patterns.py +11 -4
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/_primitives.py +18 -5
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/_renderer_base.py +100 -8
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/_viewport.py +28 -7
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/renderer.py +118 -58
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/renderer_web.py +31 -2
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/resources/gridpy.js +22 -25
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/pyproject.toml +1 -1
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/.gitattributes +0 -0
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/.gitignore +0 -0
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/LICENSE +0 -0
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/README.md +0 -0
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/_arrow.py +0 -0
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/_clippath.py +0 -0
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/_colour.py +0 -0
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/_coords.py +0 -0
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/_display_list.py +0 -0
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/_edit.py +0 -0
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/_font_metrics.py +0 -0
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/_grab.py +0 -0
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/_group.py +0 -0
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/_highlevel.py +0 -0
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/_just.py +0 -0
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/_layout.py +0 -0
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/_ls.py +0 -0
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/_mask.py +0 -0
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/_path.py +0 -0
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/_scene_graph.py +0 -0
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/_size.py +0 -0
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/_state.py +0 -0
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/_transforms.py +0 -0
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/_typeset.py +0 -0
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/_units.py +0 -0
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/_utils.py +0 -0
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/_vp_calc.py +0 -0
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/py.typed +0 -0
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/resources/d3.v7.min.js +0 -0
- {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post4}/grid_py/resources/gridpy.css +0 -0
|
@@ -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
|
|
@@ -1383,6 +1383,8 @@ def grid_curve(
|
|
|
1383
1383
|
def xspline_grob(
|
|
1384
1384
|
x: Optional[Any] = None,
|
|
1385
1385
|
y: Optional[Any] = None,
|
|
1386
|
+
id: Optional[Any] = None,
|
|
1387
|
+
id_lengths: Optional[Any] = None,
|
|
1386
1388
|
default_units: str = "npc",
|
|
1387
1389
|
shape: Union[float, Sequence[float]] = 0.0,
|
|
1388
1390
|
open_: bool = True,
|
|
@@ -1402,6 +1404,15 @@ def xspline_grob(
|
|
|
1402
1404
|
x, y : Unit, numeric, sequence, or None
|
|
1403
1405
|
Control-point coordinates. Defaults to ``Unit([0, 1], "npc")``
|
|
1404
1406
|
when ``None``.
|
|
1407
|
+
id : array-like of int or None
|
|
1408
|
+
Group label for each control point. Points sharing an ``id`` are
|
|
1409
|
+
rendered as one X-spline; the grob therefore renders one spline
|
|
1410
|
+
per unique ``id`` value. Mirrors R ``xsplineGrob(id=...)``.
|
|
1411
|
+
Mutually meaningful with ``id_lengths``: pass at most one.
|
|
1412
|
+
id_lengths : array-like of int or None
|
|
1413
|
+
Run-length encoding of ``id``: the n-th entry is the number of
|
|
1414
|
+
consecutive control points belonging to spline n. Mirrors R's
|
|
1415
|
+
``xsplineGrob(id.lengths=...)``.
|
|
1405
1416
|
default_units : str
|
|
1406
1417
|
Unit type for bare numerics.
|
|
1407
1418
|
shape : float or sequence of float
|
|
@@ -1439,9 +1450,16 @@ def xspline_grob(
|
|
|
1439
1450
|
if np.any((shape_arr < -1) | (shape_arr > 1)):
|
|
1440
1451
|
raise ValueError("all 'shape' values must be between -1 and 1")
|
|
1441
1452
|
|
|
1453
|
+
id_arr = None if id is None else np.asarray(id, dtype=np.int64)
|
|
1454
|
+
id_lengths_arr = (
|
|
1455
|
+
None if id_lengths is None else np.asarray(id_lengths, dtype=np.int64)
|
|
1456
|
+
)
|
|
1457
|
+
|
|
1442
1458
|
return Grob(
|
|
1443
1459
|
x=x,
|
|
1444
1460
|
y=y,
|
|
1461
|
+
id=id_arr,
|
|
1462
|
+
id_lengths=id_lengths_arr,
|
|
1445
1463
|
shape=shape_arr,
|
|
1446
1464
|
open_=bool(open_),
|
|
1447
1465
|
arrow=arrow,
|
|
@@ -1456,6 +1474,8 @@ def xspline_grob(
|
|
|
1456
1474
|
def grid_xspline(
|
|
1457
1475
|
x: Optional[Any] = None,
|
|
1458
1476
|
y: Optional[Any] = None,
|
|
1477
|
+
id: Optional[Any] = None,
|
|
1478
|
+
id_lengths: Optional[Any] = None,
|
|
1459
1479
|
default_units: str = "npc",
|
|
1460
1480
|
shape: Union[float, Sequence[float]] = 0.0,
|
|
1461
1481
|
open_: bool = True,
|
|
@@ -1497,7 +1517,8 @@ def grid_xspline(
|
|
|
1497
1517
|
The xspline grob.
|
|
1498
1518
|
"""
|
|
1499
1519
|
grob = xspline_grob(
|
|
1500
|
-
x=x, y=y,
|
|
1520
|
+
x=x, y=y, id=id, id_lengths=id_lengths,
|
|
1521
|
+
default_units=default_units,
|
|
1501
1522
|
shape=shape, open_=open_, arrow=arrow,
|
|
1502
1523
|
repEnds=repEnds, name=name, gp=gp, vp=vp,
|
|
1503
1524
|
)
|
|
@@ -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):
|
|
@@ -412,7 +417,15 @@ def _render_grob(
|
|
|
412
417
|
xs, ys = _calc_xspline_points(
|
|
413
418
|
x, y, shape=shape_arr, open_=open_, repEnds=rep_ends,
|
|
414
419
|
)
|
|
415
|
-
|
|
420
|
+
# R's ``xsplineGrob(open=FALSE)`` is a *filled closed* shape, not
|
|
421
|
+
# a stroked path; route through ``draw_polygon`` so ``gp$fill``
|
|
422
|
+
# actually paints (R/grid: drawDetails.xspline → C_xspline,
|
|
423
|
+
# filled when ``open == FALSE``). ``open=TRUE`` stays a stroked
|
|
424
|
+
# polyline.
|
|
425
|
+
if open_:
|
|
426
|
+
renderer.draw_polyline(xs, ys, id_=None, gp=gp)
|
|
427
|
+
else:
|
|
428
|
+
renderer.draw_polygon(xs, ys, gp=gp)
|
|
416
429
|
if arr is not None and len(xs) >= 2:
|
|
417
430
|
_draw_arrow_heads(xs, ys, arr, renderer, gp)
|
|
418
431
|
else:
|
|
@@ -436,12 +449,24 @@ def _render_grob(
|
|
|
436
449
|
out_id += 1
|
|
437
450
|
per_group.append((xs_g, ys_g))
|
|
438
451
|
if all_xs:
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
452
|
+
# Multi-id closed splines render as N separate filled
|
|
453
|
+
# subpolygons (R: xsplineGrob(id=..., open=FALSE)); use
|
|
454
|
+
# ``draw_path`` with the path_id array. Open splines stay
|
|
455
|
+
# multi-stroke polylines.
|
|
456
|
+
if open_:
|
|
457
|
+
renderer.draw_polyline(
|
|
458
|
+
np.asarray(all_xs, dtype=float),
|
|
459
|
+
np.asarray(all_ys, dtype=float),
|
|
460
|
+
id_=np.asarray(all_ids, dtype=int),
|
|
461
|
+
gp=gp,
|
|
462
|
+
)
|
|
463
|
+
else:
|
|
464
|
+
renderer.draw_path(
|
|
465
|
+
np.asarray(all_xs, dtype=float),
|
|
466
|
+
np.asarray(all_ys, dtype=float),
|
|
467
|
+
path_id=np.asarray(all_ids, dtype=int),
|
|
468
|
+
gp=gp,
|
|
469
|
+
)
|
|
445
470
|
if arr is not None:
|
|
446
471
|
for xs_g, ys_g in per_group:
|
|
447
472
|
if len(xs_g) >= 2:
|
|
@@ -564,15 +589,18 @@ def _render_grob(
|
|
|
564
589
|
if image is None:
|
|
565
590
|
image = getattr(grob, "image", None)
|
|
566
591
|
if image is not None:
|
|
567
|
-
# Apply justification (same as rect_grob)
|
|
568
592
|
hj, vj = _resolve_just(grob)
|
|
569
593
|
raw_x = renderer.resolve_x(getattr(grob, "x", 0.0), gp=gp)
|
|
570
594
|
raw_y = renderer.resolve_y(getattr(grob, "y", 0.0), gp=gp)
|
|
571
595
|
raw_w = renderer.resolve_w(getattr(grob, "width", 1.0), gp=gp)
|
|
572
596
|
raw_h = renderer.resolve_h(getattr(grob, "height", 1.0), gp=gp)
|
|
573
|
-
#
|
|
597
|
+
# `renderer.draw_raster` expects the *top-left* corner in y-down
|
|
598
|
+
# device pixels. `resolve_y` already returns y-down coords, so the
|
|
599
|
+
# y component of justification must be flipped relative to R's
|
|
600
|
+
# `justifyY(y, h, vjust) = y - h*vjust` (which assumes y-up NPC).
|
|
601
|
+
# Same correction `draw_rect` applies (renderer.py:815).
|
|
574
602
|
x0 = raw_x - raw_w * hj
|
|
575
|
-
y0 = raw_y - raw_h * vj
|
|
603
|
+
y0 = raw_y - raw_h * (1.0 - vj)
|
|
576
604
|
renderer.draw_raster(
|
|
577
605
|
image=image,
|
|
578
606
|
x=x0,
|
|
@@ -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:
|
|
@@ -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":
|
|
@@ -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
|
|
@@ -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)
|
|
@@ -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,
|