rgrid-python 4.5.3.post1__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.post1 → rgrid_python-4.5.3.post4}/PKG-INFO +1 -1
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/__init__.py +3 -3
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_curve.py +22 -1
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_draw.py +64 -16
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_gpar.py +56 -19
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_grob.py +6 -3
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_layout.py +9 -2
- rgrid_python-4.5.3.post4/grid_py/_lty.py +197 -0
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_patterns.py +11 -4
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_primitives.py +18 -5
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_renderer_base.py +219 -18
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_size.py +36 -10
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_state.py +42 -0
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_units.py +14 -1
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_viewport.py +28 -7
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/renderer.py +118 -58
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/renderer_web.py +31 -2
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/resources/gridpy.js +22 -25
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/pyproject.toml +1 -1
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/.gitattributes +0 -0
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/.gitignore +0 -0
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/LICENSE +0 -0
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/README.md +0 -0
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_arrow.py +0 -0
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_clippath.py +0 -0
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_colour.py +0 -0
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_coords.py +0 -0
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_display_list.py +0 -0
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_edit.py +0 -0
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_font_metrics.py +0 -0
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_grab.py +0 -0
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_group.py +0 -0
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_highlevel.py +0 -0
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_just.py +0 -0
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_ls.py +0 -0
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_mask.py +0 -0
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_path.py +0 -0
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_scene_graph.py +0 -0
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_transforms.py +0 -0
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_typeset.py +0 -0
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_utils.py +0 -0
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_vp_calc.py +0 -0
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/py.typed +0 -0
- {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/resources/d3.v7.min.js +0 -0
- {rgrid_python-4.5.3.post1 → 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
|
|
@@ -6,7 +6,7 @@ units, viewports, grobs (graphical objects), layouts, and rendering via
|
|
|
6
6
|
Cairo (pycairo).
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
-
__version__ = "4.5.3.
|
|
9
|
+
__version__ = "4.5.3.post4"
|
|
10
10
|
|
|
11
11
|
# --- Utilities ---
|
|
12
12
|
from grid_py._utils import depth, explode, grid_pretty, n2mfrow
|
|
@@ -25,7 +25,7 @@ from grid_py._units import (
|
|
|
25
25
|
)
|
|
26
26
|
|
|
27
27
|
# --- Graphical Parameters ---
|
|
28
|
-
from grid_py._gpar import Gpar, get_gpar
|
|
28
|
+
from grid_py._gpar import Gpar, get_gpar, gpar
|
|
29
29
|
|
|
30
30
|
# --- Arrow ---
|
|
31
31
|
from grid_py._arrow import Arrow, arrow
|
|
@@ -232,7 +232,7 @@ __all__ = [
|
|
|
232
232
|
"absolute_size",
|
|
233
233
|
"convert_unit", "convert_x", "convert_y", "convert_width", "convert_height",
|
|
234
234
|
# Gpar
|
|
235
|
-
"Gpar", "get_gpar",
|
|
235
|
+
"Gpar", "gpar", "get_gpar",
|
|
236
236
|
# Arrow
|
|
237
237
|
"Arrow", "arrow",
|
|
238
238
|
# Path
|
|
@@ -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,
|
|
@@ -690,24 +718,44 @@ def _pop_grob_vp(vp: Any) -> None:
|
|
|
690
718
|
def _vp_depth(vp: Any) -> int:
|
|
691
719
|
"""Return the depth of a viewport (number of levels it adds).
|
|
692
720
|
|
|
721
|
+
Mirrors R's ``depth()`` generic (grid R/grid.R) for every viewport
|
|
722
|
+
container: VpStack pushes ``sum(depth(child))`` levels, VpList
|
|
723
|
+
pushes ``depth(last)``, VpTree pushes ``depth(parent) + depth(last
|
|
724
|
+
child)``, plain Viewport pushes 1, VpPath pushes ``n``.
|
|
725
|
+
|
|
726
|
+
Recognised types are routed through :func:`._viewport.depth` (the
|
|
727
|
+
canonical R-faithful dispatch); this is what guarantees
|
|
728
|
+
``_pop_grob_vp(grob.vp)`` pops the right count when a Gtable's
|
|
729
|
+
``make_context`` returns ``VpStack(orig_vp, layout_vp)``. The
|
|
730
|
+
prior implementation only checked ``hasattr(vp, "depth")`` —
|
|
731
|
+
VpStack/VpList don't expose a method, so it fell through to
|
|
732
|
+
``return 1`` and left every nested layout vp on the layout_stack
|
|
733
|
+
after the gtable rendered, breaking every grob drawn after.
|
|
734
|
+
|
|
735
|
+
Duck-typed objects with a ``.depth()`` method (used by tests and
|
|
736
|
+
user-defined viewport-like objects) are still honoured as a
|
|
737
|
+
fallback for unknown types.
|
|
738
|
+
|
|
693
739
|
Parameters
|
|
694
740
|
----------
|
|
695
741
|
vp : Any
|
|
696
|
-
A viewport, VpPath, VpStack, VpList, or
|
|
742
|
+
A viewport, VpPath, VpStack, VpList, VpTree, or any
|
|
743
|
+
depth-bearing duck-typed object.
|
|
697
744
|
|
|
698
745
|
Returns
|
|
699
746
|
-------
|
|
700
747
|
int
|
|
701
748
|
The depth.
|
|
702
749
|
"""
|
|
750
|
+
from ._viewport import (
|
|
751
|
+
Viewport, VpList, VpStack, VpTree, depth as _depth,
|
|
752
|
+
)
|
|
703
753
|
from ._path import VpPath
|
|
704
754
|
|
|
705
|
-
if isinstance(vp, VpPath):
|
|
706
|
-
|
|
707
|
-
return getattr(vp, "n", 1)
|
|
755
|
+
if isinstance(vp, (Viewport, VpList, VpStack, VpTree, VpPath)):
|
|
756
|
+
return _depth(vp)
|
|
708
757
|
if hasattr(vp, "depth"):
|
|
709
758
|
return vp.depth()
|
|
710
|
-
# Default single viewport depth
|
|
711
759
|
return 1
|
|
712
760
|
|
|
713
761
|
|
|
@@ -14,20 +14,18 @@ from typing import Any, Dict, List, Optional, Sequence, Union
|
|
|
14
14
|
|
|
15
15
|
import numpy as np
|
|
16
16
|
|
|
17
|
-
__all__ = ["Gpar", "get_gpar"]
|
|
17
|
+
__all__ = ["Gpar", "gpar", "get_gpar"]
|
|
18
18
|
|
|
19
19
|
# ---------------------------------------------------------------------------
|
|
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:
|
|
@@ -586,3 +612,14 @@ def get_gpar(names: Optional[Sequence[str]] = None) -> Gpar:
|
|
|
586
612
|
gp = object.__new__(Gpar)
|
|
587
613
|
gp._params = subset
|
|
588
614
|
return gp
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def gpar(**kwargs: Any) -> Gpar:
|
|
618
|
+
"""Factory mirroring R ``grid::gpar(...)``.
|
|
619
|
+
|
|
620
|
+
R's ``gpar`` function constructs a ``gpar`` object from arbitrary
|
|
621
|
+
keyword arguments. ``Gpar(**kwargs)`` does the same, so this is a
|
|
622
|
+
thin alias kept for direct R-to-Python translation of code that
|
|
623
|
+
reads ``gpar(col="red")``.
|
|
624
|
+
"""
|
|
625
|
+
return Gpar(**kwargs)
|
|
@@ -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":
|
|
@@ -400,16 +400,23 @@ def _calc_layout_sizes(
|
|
|
400
400
|
reduced_h = max(reduced_h, 0.0)
|
|
401
401
|
|
|
402
402
|
# ---- Phase 2: allocate respected null units (layout.c:allocateRespected)
|
|
403
|
+
# R sums ALL relative widths/heights via totalWidth/totalHeight
|
|
404
|
+
# (layout.c:154-194) — not just the respected ones — when computing
|
|
405
|
+
# the aspect-ratio normalisation denominator. This is what lets a
|
|
406
|
+
# respect matrix that marks a single cell coexist with other null
|
|
407
|
+
# cells in the same dimension; restricting the sum to respected
|
|
408
|
+
# cells (the prior Python implementation) over-allocated the
|
|
409
|
+
# respected slot and collapsed everything else to 0.
|
|
403
410
|
if layout._valid_respect > 0 and (reduced_w > 0 or reduced_h > 0):
|
|
404
411
|
sum_w = sum(
|
|
405
412
|
float(widths._values[i])
|
|
406
413
|
for i in range(ncol)
|
|
407
|
-
if relative_w[i]
|
|
414
|
+
if relative_w[i]
|
|
408
415
|
)
|
|
409
416
|
sum_h = sum(
|
|
410
417
|
float(heights._values[j])
|
|
411
418
|
for j in range(nrow)
|
|
412
|
-
if relative_h[j]
|
|
419
|
+
if relative_h[j]
|
|
413
420
|
)
|
|
414
421
|
|
|
415
422
|
temp_w = reduced_w
|
|
@@ -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)
|