rgrid-python 4.5.3.post3__py3-none-any.whl → 4.5.3.post5__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/_colour.py +9 -2
- grid_py/_draw.py +139 -41
- 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 +85 -5
- grid_py/_renderer_base.py +232 -25
- grid_py/_state.py +68 -10
- grid_py/_viewport.py +28 -7
- grid_py/_vp_calc.py +45 -3
- grid_py/renderer.py +344 -75
- grid_py/renderer_web.py +38 -3
- grid_py/resources/gridpy.js +22 -25
- {rgrid_python-4.5.3.post3.dist-info → rgrid_python-4.5.3.post5.dist-info}/METADATA +1 -1
- {rgrid_python-4.5.3.post3.dist-info → rgrid_python-4.5.3.post5.dist-info}/RECORD +19 -18
- {rgrid_python-4.5.3.post3.dist-info → rgrid_python-4.5.3.post5.dist-info}/WHEEL +1 -1
- {rgrid_python-4.5.3.post3.dist-info → rgrid_python-4.5.3.post5.dist-info}/licenses/LICENSE +0 -0
grid_py/__init__.py
CHANGED
grid_py/_colour.py
CHANGED
|
@@ -761,8 +761,15 @@ def parse_r_colour(c: Any) -> Tuple[float, float, float, float]:
|
|
|
761
761
|
s = c.strip()
|
|
762
762
|
low = s.lower()
|
|
763
763
|
|
|
764
|
-
#
|
|
765
|
-
|
|
764
|
+
# R's ``col2rgb("transparent", alpha=TRUE)`` returns (255, 255, 255, 0)
|
|
765
|
+
# — by R convention the RGB channels are white even though α=0
|
|
766
|
+
# makes the colour invisible. Mirror exactly so hex round-trips
|
|
767
|
+
# and downstream tooling that compares against R's encoding agree.
|
|
768
|
+
if low == "transparent":
|
|
769
|
+
return (1.0, 1.0, 1.0, 0.0)
|
|
770
|
+
# NA / none / "" are Python-port-specific transparent sentinels not
|
|
771
|
+
# defined as parseable colours in R (``col2rgb('NA')`` errors).
|
|
772
|
+
if low in ("na", "none", ""):
|
|
766
773
|
return (0.0, 0.0, 0.0, 0.0)
|
|
767
774
|
|
|
768
775
|
# Hex colour
|
grid_py/_draw.py
CHANGED
|
@@ -25,6 +25,7 @@ from ._state import get_state
|
|
|
25
25
|
from ._display_list import DisplayList, DLDrawGrob
|
|
26
26
|
from ._units import Unit
|
|
27
27
|
from ._utils import grid_pretty as _grid_pretty
|
|
28
|
+
from ._primitives import valid_pch
|
|
28
29
|
|
|
29
30
|
__all__ = [
|
|
30
31
|
"grid_draw",
|
|
@@ -215,7 +216,12 @@ def _draw_arrow_heads(
|
|
|
215
216
|
try:
|
|
216
217
|
l_w = float(renderer.resolve_w(length_unit, gp=gp))
|
|
217
218
|
l_h = float(renderer.resolve_h(length_unit, gp=gp))
|
|
218
|
-
except
|
|
219
|
+
except (ValueError, AttributeError, TypeError) as exc:
|
|
220
|
+
warnings.warn(
|
|
221
|
+
f"arrow length cannot be resolved ({exc}); arrow head skipped",
|
|
222
|
+
UserWarning,
|
|
223
|
+
stacklevel=2,
|
|
224
|
+
)
|
|
219
225
|
return
|
|
220
226
|
length_dev = min(l_w, l_h)
|
|
221
227
|
if not (length_dev > 0):
|
|
@@ -302,8 +308,16 @@ def _render_grob(
|
|
|
302
308
|
|
|
303
309
|
# ---- rect -----------------------------------------------------------
|
|
304
310
|
if cls == "rect":
|
|
305
|
-
|
|
306
|
-
|
|
311
|
+
# Use paired resolve_loc_array so that under a rotated viewport
|
|
312
|
+
# each rect's anchor (x_i, y_i) is mapped through the full 2-D
|
|
313
|
+
# CTM together, not via two independent 1-D projections (which
|
|
314
|
+
# silently drop the rotation contribution from the orthogonal
|
|
315
|
+
# axis — see _renderer_base.resolve_x_array docstring).
|
|
316
|
+
xs, ys = renderer.resolve_loc_array(
|
|
317
|
+
getattr(grob, "x", [0.0]),
|
|
318
|
+
getattr(grob, "y", [0.0]),
|
|
319
|
+
gp=gp,
|
|
320
|
+
)
|
|
307
321
|
ws = renderer.resolve_w_array(getattr(grob, "width", [1.0]), gp=gp)
|
|
308
322
|
hs = renderer.resolve_h_array(getattr(grob, "height", [1.0]), gp=gp)
|
|
309
323
|
hj, vj = _resolve_just(grob)
|
|
@@ -326,9 +340,11 @@ def _render_grob(
|
|
|
326
340
|
|
|
327
341
|
# ---- roundrect ------------------------------------------------------
|
|
328
342
|
elif cls == "roundrect":
|
|
343
|
+
ax, ay = renderer.resolve_loc(
|
|
344
|
+
getattr(grob, "x", 0.0), getattr(grob, "y", 0.0), gp=gp,
|
|
345
|
+
)
|
|
329
346
|
renderer.draw_roundrect(
|
|
330
|
-
x=
|
|
331
|
-
y=renderer.resolve_y(getattr(grob, "y", 0.0), gp=gp),
|
|
347
|
+
x=ax, y=ay,
|
|
332
348
|
w=renderer.resolve_w(getattr(grob, "width", 1.0), gp=gp),
|
|
333
349
|
h=renderer.resolve_h(getattr(grob, "height", 1.0), gp=gp),
|
|
334
350
|
r=renderer.resolve_w(getattr(grob, "r", 0.0), gp=gp),
|
|
@@ -339,17 +355,22 @@ def _render_grob(
|
|
|
339
355
|
|
|
340
356
|
# ---- circle ---------------------------------------------------------
|
|
341
357
|
elif cls == "circle":
|
|
358
|
+
cx, cy = renderer.resolve_loc(
|
|
359
|
+
getattr(grob, "x", 0.5), getattr(grob, "y", 0.5), gp=gp,
|
|
360
|
+
)
|
|
342
361
|
renderer.draw_circle(
|
|
343
|
-
x=
|
|
344
|
-
y=renderer.resolve_y(getattr(grob, "y", 0.5), gp=gp),
|
|
362
|
+
x=cx, y=cy,
|
|
345
363
|
r=renderer.resolve_w(getattr(grob, "r", 0.5), gp=gp),
|
|
346
364
|
gp=gp,
|
|
347
365
|
)
|
|
348
366
|
|
|
349
367
|
# ---- lines / polyline ------------------------------------------------
|
|
350
368
|
elif cls in ("lines", "polyline"):
|
|
351
|
-
x = renderer.
|
|
352
|
-
|
|
369
|
+
x, y = renderer.resolve_loc_array(
|
|
370
|
+
getattr(grob, "x", [0.0, 1.0]),
|
|
371
|
+
getattr(grob, "y", [0.0, 1.0]),
|
|
372
|
+
gp=gp,
|
|
373
|
+
)
|
|
353
374
|
id_ = getattr(grob, "id", None)
|
|
354
375
|
id_lengths = getattr(grob, "id_lengths", None)
|
|
355
376
|
# R polylineGrob supports either `id` (per-point group) or
|
|
@@ -363,12 +384,36 @@ def _render_grob(
|
|
|
363
384
|
id_ = np.atleast_1d(np.asarray(id_, dtype=int))
|
|
364
385
|
renderer.draw_polyline(x, y, id_=id_, gp=gp)
|
|
365
386
|
|
|
387
|
+
# R linesGrob / polylineGrob carry an optional ``arrow=`` (port
|
|
388
|
+
# of src/grid.c::L_lines / L_polyline arrowhead emission). The
|
|
389
|
+
# earlier code consumed it for ``segmentsGrob`` only, silently
|
|
390
|
+
# dropping it on lines / polyline — symptom: an arrow= argument
|
|
391
|
+
# produced a bare line. Apply ``_draw_arrow_heads`` per
|
|
392
|
+
# polyline (each unique id) here, with the same reference-point
|
|
393
|
+
# convention segments uses.
|
|
394
|
+
arr = getattr(grob, "arrow", None)
|
|
395
|
+
if arr is not None:
|
|
396
|
+
if id_ is not None:
|
|
397
|
+
for uid in np.unique(id_):
|
|
398
|
+
mask = id_ == uid
|
|
399
|
+
if int(np.sum(mask)) >= 2:
|
|
400
|
+
_draw_arrow_heads(
|
|
401
|
+
np.asarray(x)[mask], np.asarray(y)[mask],
|
|
402
|
+
arr, renderer, gp,
|
|
403
|
+
)
|
|
404
|
+
elif len(x) >= 2:
|
|
405
|
+
_draw_arrow_heads(
|
|
406
|
+
np.asarray(x), np.asarray(y), arr, renderer, gp,
|
|
407
|
+
)
|
|
408
|
+
|
|
366
409
|
# ---- segments --------------------------------------------------------
|
|
367
410
|
elif cls == "segments":
|
|
368
|
-
x0 = renderer.
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
y1 = renderer.
|
|
411
|
+
x0, y0 = renderer.resolve_loc_array(
|
|
412
|
+
getattr(grob, "x0", []), getattr(grob, "y0", []), gp=gp,
|
|
413
|
+
)
|
|
414
|
+
x1, y1 = renderer.resolve_loc_array(
|
|
415
|
+
getattr(grob, "x1", []), getattr(grob, "y1", []), gp=gp,
|
|
416
|
+
)
|
|
372
417
|
renderer.draw_segments(x0=x0, y0=y0, x1=x1, y1=y1, gp=gp)
|
|
373
418
|
|
|
374
419
|
# Each segment may carry its own arrowhead (``arrow=`` parameter on
|
|
@@ -387,8 +432,11 @@ def _render_grob(
|
|
|
387
432
|
elif cls == "xspline":
|
|
388
433
|
from ._curve import _calc_xspline_points # lazy to avoid import cycle
|
|
389
434
|
|
|
390
|
-
x = renderer.
|
|
391
|
-
|
|
435
|
+
x, y = renderer.resolve_loc_array(
|
|
436
|
+
getattr(grob, "x", [0.0, 1.0]),
|
|
437
|
+
getattr(grob, "y", [0.0, 1.0]),
|
|
438
|
+
gp=gp,
|
|
439
|
+
)
|
|
392
440
|
shape_raw = getattr(grob, "shape", 0.0)
|
|
393
441
|
open_ = bool(getattr(grob, "open_", True))
|
|
394
442
|
rep_ends = bool(getattr(grob, "repEnds", True))
|
|
@@ -469,8 +517,9 @@ def _render_grob(
|
|
|
469
517
|
|
|
470
518
|
# ---- polygon ---------------------------------------------------------
|
|
471
519
|
elif cls == "polygon":
|
|
472
|
-
px = renderer.
|
|
473
|
-
|
|
520
|
+
px, py = renderer.resolve_loc_array(
|
|
521
|
+
getattr(grob, "x", []), getattr(grob, "y", []), gp=gp,
|
|
522
|
+
)
|
|
474
523
|
pid = getattr(grob, "id", None)
|
|
475
524
|
if pid is not None:
|
|
476
525
|
# R semantics: polygonGrob(id=...) draws separate polygons
|
|
@@ -504,9 +553,8 @@ def _render_grob(
|
|
|
504
553
|
else:
|
|
505
554
|
labels = [str(label_raw)]
|
|
506
555
|
|
|
507
|
-
# Resolve x/y
|
|
508
|
-
xx = renderer.
|
|
509
|
-
yy = renderer.resolve_y_array(y_unit, gp=gp)
|
|
556
|
+
# Resolve x/y as pairs so rotation contribution is preserved.
|
|
557
|
+
xx, yy = renderer.resolve_loc_array(x_unit, y_unit, gp=gp)
|
|
510
558
|
|
|
511
559
|
# Normalise rot to array
|
|
512
560
|
if isinstance(rot_raw, (list, tuple, np.ndarray)):
|
|
@@ -548,16 +596,20 @@ def _render_grob(
|
|
|
548
596
|
# ---- points ----------------------------------------------------------
|
|
549
597
|
elif cls == "points":
|
|
550
598
|
pch_raw = getattr(grob, "pch", 19)
|
|
551
|
-
# pch may be a scalar or
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
599
|
+
# pch may be a scalar (int or single character such as "."/"A")
|
|
600
|
+
# or a per-point array (possibly MIXED int/str, e.g.
|
|
601
|
+
# [".", 19, "A"]). R keeps character pch as character and
|
|
602
|
+
# coerces numeric pch to int (grid:::valid.pch). Normalise via
|
|
603
|
+
# the same helper and pass the result through *unchanged* — the
|
|
604
|
+
# renderer dispatches symbol-vs-glyph per point. Do NOT force
|
|
605
|
+
# ``dtype=int`` (that crashes on "."), and do NOT substitute a
|
|
606
|
+
# silent default for non-numeric scalars (that masks bugs).
|
|
607
|
+
pch_val = valid_pch(pch_raw)
|
|
608
|
+
pts_x, pts_y = renderer.resolve_loc_array(
|
|
609
|
+
getattr(grob, "x", []), getattr(grob, "y", []), gp=gp,
|
|
610
|
+
)
|
|
558
611
|
renderer.draw_points(
|
|
559
|
-
x=
|
|
560
|
-
y=renderer.resolve_y_array(getattr(grob, "y", []), gp=gp),
|
|
612
|
+
x=pts_x, y=pts_y,
|
|
561
613
|
size=renderer.resolve_w(getattr(grob, "size", 1.0), gp=gp),
|
|
562
614
|
pch=pch_val,
|
|
563
615
|
gp=gp,
|
|
@@ -565,18 +617,63 @@ def _render_grob(
|
|
|
565
617
|
|
|
566
618
|
# ---- pathgrob --------------------------------------------------------
|
|
567
619
|
elif cls == "pathgrob":
|
|
568
|
-
x = renderer.
|
|
569
|
-
|
|
570
|
-
|
|
620
|
+
x, y = renderer.resolve_loc_array(
|
|
621
|
+
getattr(grob, "x", []), getattr(grob, "y", []), gp=gp,
|
|
622
|
+
)
|
|
623
|
+
# R ``pathGrob`` carries two grouping levels (src/grid.c::L_path):
|
|
624
|
+
#
|
|
625
|
+
# - id / id_lengths : per-point SUB-PATH identifier — each
|
|
626
|
+
# unique value is one closed Cairo sub-path.
|
|
627
|
+
# - path_id / path_id_lengths : per-sub-path COMPOUND grouping
|
|
628
|
+
# — each compound is filled+stroked independently with its
|
|
629
|
+
# own fill rule application.
|
|
630
|
+
#
|
|
631
|
+
# The earlier port read ``grob.pathId`` (camelCase, never stored
|
|
632
|
+
# by path_grob) and ignored ``grob.id`` entirely, so every path
|
|
633
|
+
# collapsed into a single 8-vertex sub-path that connected the
|
|
634
|
+
# two rectangles into a bow-tie. Read both attributes properly
|
|
635
|
+
# here and pass the per-point sub-path identifier through to
|
|
636
|
+
# ``draw_path`` (which iterates unique values).
|
|
637
|
+
sub_id = getattr(grob, "id", None)
|
|
638
|
+
sub_id_lengths = getattr(grob, "id_lengths", None)
|
|
639
|
+
if sub_id is None and sub_id_lengths is not None:
|
|
640
|
+
lengths = np.atleast_1d(np.asarray(sub_id_lengths, dtype=int))
|
|
641
|
+
sub_id = np.repeat(np.arange(1, len(lengths) + 1), lengths)
|
|
642
|
+
if sub_id is None:
|
|
643
|
+
sub_id = np.ones(len(x), dtype=int)
|
|
644
|
+
else:
|
|
645
|
+
sub_id = np.atleast_1d(np.asarray(sub_id, dtype=int))
|
|
646
|
+
|
|
647
|
+
# Optional second-level compound grouping. R fills each
|
|
648
|
+
# compound separately with its own fill-rule application. Most
|
|
649
|
+
# uses (and every current test) have a single compound; we
|
|
650
|
+
# iterate when more are supplied.
|
|
651
|
+
path_id = getattr(grob, "path_id", None)
|
|
652
|
+
path_id_lengths = getattr(grob, "path_id_lengths", None)
|
|
653
|
+
if path_id is None and path_id_lengths is not None:
|
|
654
|
+
lengths = np.atleast_1d(np.asarray(path_id_lengths, dtype=int))
|
|
655
|
+
path_id = np.repeat(np.arange(1, len(lengths) + 1), lengths)
|
|
656
|
+
rule = getattr(grob, "rule", "winding")
|
|
657
|
+
|
|
571
658
|
if path_id is None:
|
|
572
|
-
|
|
659
|
+
renderer.draw_path(
|
|
660
|
+
x=x, y=y, path_id=sub_id, rule=rule, gp=gp,
|
|
661
|
+
)
|
|
573
662
|
else:
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
663
|
+
# Map per-point compound id from per-sub-path path_id.
|
|
664
|
+
path_id_arr = np.atleast_1d(np.asarray(path_id, dtype=int))
|
|
665
|
+
unique_subs = np.unique(sub_id)
|
|
666
|
+
sub_to_compound = {int(s): int(path_id_arr[k % len(path_id_arr)])
|
|
667
|
+
for k, s in enumerate(unique_subs)}
|
|
668
|
+
point_compound = np.asarray(
|
|
669
|
+
[sub_to_compound[int(s)] for s in sub_id], dtype=int,
|
|
670
|
+
)
|
|
671
|
+
for ck in np.unique(point_compound):
|
|
672
|
+
mask = point_compound == ck
|
|
673
|
+
renderer.draw_path(
|
|
674
|
+
x=x[mask], y=y[mask], path_id=sub_id[mask],
|
|
675
|
+
rule=rule, gp=gp,
|
|
676
|
+
)
|
|
580
677
|
|
|
581
678
|
# ---- rastergrob ------------------------------------------------------
|
|
582
679
|
elif cls == "rastergrob":
|
|
@@ -585,8 +682,9 @@ def _render_grob(
|
|
|
585
682
|
image = getattr(grob, "image", None)
|
|
586
683
|
if image is not None:
|
|
587
684
|
hj, vj = _resolve_just(grob)
|
|
588
|
-
raw_x = renderer.
|
|
589
|
-
|
|
685
|
+
raw_x, raw_y = renderer.resolve_loc(
|
|
686
|
+
getattr(grob, "x", 0.0), getattr(grob, "y", 0.0), gp=gp,
|
|
687
|
+
)
|
|
590
688
|
raw_w = renderer.resolve_w(getattr(grob, "width", 1.0), gp=gp)
|
|
591
689
|
raw_h = renderer.resolve_h(getattr(grob, "height", 1.0), gp=gp)
|
|
592
690
|
# `renderer.draw_raster` expects the *top-left* corner in y-down
|
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)
|