rgrid-python 4.5.3.post2__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 CHANGED
@@ -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.post2"
9
+ __version__ = "4.5.3.post4"
10
10
 
11
11
  # --- Utilities ---
12
12
  from grid_py._utils import depth, explode, grid_pretty, n2mfrow
grid_py/_curve.py CHANGED
@@ -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, default_units=default_units,
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
  )
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 Exception:
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
- renderer.draw_polyline(xs, ys, id_=None, gp=gp)
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
- renderer.draw_polyline(
440
- np.asarray(all_xs, dtype=float),
441
- np.asarray(all_ys, dtype=float),
442
- id_=np.asarray(all_ids, dtype=int),
443
- gp=gp,
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
- # Compute bottom-left corner from anchor + justification
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,
grid_py/_gpar.py CHANGED
@@ -20,14 +20,12 @@ __all__ = ["Gpar", "gpar", "get_gpar"]
20
20
  # Constants
21
21
  # ---------------------------------------------------------------------------
22
22
 
23
- _VALID_LTY: set[str] = {
24
- "solid",
25
- "dashed",
26
- "dotted",
27
- "dotdash",
28
- "longdash",
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
- def _is_hex_lty(s: str) -> bool:
81
- """Return True when *s* looks like a valid hex-string line-type spec."""
82
- return all(c in "0123456789abcdefABCDEF" for c in s) and len(s) > 0
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 isinstance(v, str):
259
- if v not in _VALID_LTY and not _is_hex_lty(v):
260
- raise ValueError(
261
- f"invalid line type '{v}'; must be one of "
262
- f"{sorted(_VALID_LTY)} or a hex string"
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: merge gpar
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
- # Merge new gp on top of existing
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 Exception:
918
- # Fallback: store the original gradient for later resolution
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 Exception:
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 Exception:
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. When ``None`` a default is chosen.
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. When ``None`` a default is chosen.
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.5, "npc")
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.5, "npc")
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 = "npc",
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
 
@@ -425,8 +450,15 @@ class GridRenderer(ABC):
425
450
  from ._layout import _calc_layout_sizes, GridLayout
426
451
 
427
452
  if isinstance(layout, GridLayout):
453
+ # Use device-units-per-inch (= dpi for raster ImageSurface, 72 for
454
+ # vector PDF/SVG/PS surfaces) so that absolute units (cm/mm/in/pt
455
+ # …) get converted to the SAME unit as ``parent_w``/``parent_h``.
456
+ # Passing ``self.dpi`` blindly would, for vector surfaces, scale
457
+ # absolute widths by dpi while the parent is measured in points,
458
+ # over-allocating fixed cells by ``dpi/72`` and shrinking the
459
+ # null-unit panel by the same factor.
428
460
  col_widths, row_heights = _calc_layout_sizes(
429
- layout, parent_w, parent_h, self.dpi,
461
+ layout, parent_w, parent_h, self._dev_units_per_inch,
430
462
  )
431
463
  else:
432
464
  nrow = getattr(layout, "nrow", 1)
@@ -531,7 +563,10 @@ class GridRenderer(ABC):
531
563
  try:
532
564
  from ._state import get_state
533
565
  return get_state()._scale
534
- except Exception:
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).
535
570
  return 1.0
536
571
 
537
572
  # ===================================================================== #
@@ -721,7 +756,16 @@ class GridRenderer(ABC):
721
756
  if grob.vp is not None:
722
757
  _pop_grob_vp(grob.vp)
723
758
 
724
- except Exception:
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
+ )
725
769
  result = 0.0
726
770
  finally:
727
771
  # --- Restore state (R unit.c:561-562) ---
@@ -763,18 +807,35 @@ class GridRenderer(ABC):
763
807
  y_unit = Unit(0.5, "npc")
764
808
  try:
765
809
  x_inches = self._resolve_to_inches(x_unit, "x", False, gp)
766
- except Exception:
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
+ )
767
816
  x_inches = 0.0
768
817
  try:
769
818
  y_inches = self._resolve_to_inches(y_unit, "y", False, gp)
770
- except Exception:
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
+ )
771
825
  y_inches = 0.0
772
826
 
773
827
  # Width / height of the grob's bounding box, in inches.
774
828
  def _details_inches(fn, axis: str) -> float:
775
829
  try:
776
830
  u = fn(grob)
777
- except Exception:
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
+ )
778
839
  return 0.0
779
840
  if u is None:
780
841
  return 0.0
@@ -784,7 +845,12 @@ class GridRenderer(ABC):
784
845
  return 0.0
785
846
  try:
786
847
  return float(self._resolve_to_inches(u, axis, True, gp))
787
- except Exception:
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
+ )
788
854
  return 0.0
789
855
 
790
856
  w_in = _details_inches(width_details, "x")
@@ -1159,6 +1225,32 @@ class GridRenderer(ABC):
1159
1225
  def _restore_clip(self) -> None:
1160
1226
  ...
1161
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
+
1162
1254
  # ===================================================================== #
1163
1255
  # Abstract methods: graphics state save/restore #
1164
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 other
119
- ``"on"`` -> ``True``, ``"off"`` -> ``None``, ``"inherit"`` -> ``False``.
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 None
133
+ bool, None, or GridClipPath
125
134
 
126
135
  Raises
127
136
  ------
128
137
  ValueError
129
- If *clip* is a string that is not one of the accepted values.
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 boolean"
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 boolean"
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
- _LTY_DASHES: Dict[str, Optional[Sequence[float]]] = {
44
- "solid": None,
45
- "dashed": [6.0, 4.0],
46
- "dotted": [2.0, 2.0],
47
- "dotdash": [2.0, 2.0, 6.0, 2.0],
48
- "longdash": [10.0, 3.0],
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,
@@ -141,6 +127,17 @@ class CairoRenderer(GridRenderer):
141
127
  if filename is None:
142
128
  raise ValueError("filename is required for SVG surface")
143
129
  self._surface = cairo.SVGSurface(filename, width_pt, height_pt)
130
+ # Cairo's SVGSurface measures user-space in points; emit explicit
131
+ # ``pt`` units in the resulting <svg width="...pt" height="...pt">
132
+ # so SVG renderers (browsers, cairosvg, Inkscape) interpret the
133
+ # canvas at the intended physical size instead of treating the
134
+ # raw numbers as user-units (= pixels by SVG default).
135
+ try:
136
+ self._surface.set_document_unit(cairo.SVG_UNIT_PT)
137
+ except (AttributeError, TypeError):
138
+ # Older pycairo / cairo without set_document_unit — file is
139
+ # still well-formed, just unit-less.
140
+ pass
144
141
  elif surface_type == "ps":
145
142
  if filename is None:
146
143
  raise ValueError("filename is required for PS surface")
@@ -170,6 +167,33 @@ class CairoRenderer(GridRenderer):
170
167
  self._ctx.rectangle(x0, y0, w, h)
171
168
  self._ctx.clip()
172
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
+
173
197
  def _restore_clip(self) -> None:
174
198
  self._ctx.restore()
175
199
 
@@ -285,7 +309,14 @@ class CairoRenderer(GridRenderer):
285
309
  grid_draw(mask_grob, recording=False)
286
310
  finally:
287
311
  state._renderer = orig_renderer
288
- 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
+ )
289
320
  return None
290
321
 
291
322
  return mask_surface
@@ -322,6 +353,38 @@ class CairoRenderer(GridRenderer):
322
353
 
323
354
  # ---- gpar application --------------------------------------------------
324
355
 
356
+ def _lwd_to_user(self, lwd_pt: float) -> float:
357
+ """Convert R-grid lwd (points) to a Cairo user-space line width.
358
+
359
+ R's grid measures ``lwd`` in 1/72 inch (points) regardless of the
360
+ active viewport scale — a value of 1 should always produce a stroke
361
+ 1pt wide on the output medium. Cairo's ``set_line_width`` takes a
362
+ user-space distance, so we have to:
363
+
364
+ 1. Map points → device units. Raster ``ImageSurface`` has 1 user
365
+ unit = 1 pixel, so device units per point = ``dpi/72``. Vector
366
+ surfaces (PDF/SVG/PS) have 1 user unit = 1 pt, so the conversion
367
+ factor is 1.0. Equivalently, use ``_dev_units_per_inch / 72``.
368
+ 2. Undo any active CTM scaling via ``device_to_user_distance`` so
369
+ that nested viewports (which scale the CTM) do not also scale
370
+ the stroke.
371
+
372
+ This is the analogue of the font-size handling in ``_set_font``.
373
+ """
374
+ if self._surface_type == "image":
375
+ lw_dev = lwd_pt * self.dpi / 72.0
376
+ else:
377
+ lw_dev = lwd_pt
378
+ try:
379
+ ux, uy = self._ctx.device_to_user_distance(lw_dev, lw_dev)
380
+ return max(abs(ux), abs(uy))
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.
386
+ return lw_dev
387
+
325
388
  def _apply_stroke(self, gp: Optional[Gpar]) -> Tuple[float, float, float, float]:
326
389
  """Set stroke colour, line width, dash, caps, joins from Gpar.
327
390
 
@@ -331,7 +394,7 @@ class CairoRenderer(GridRenderer):
331
394
  ctx = self._ctx
332
395
  if gp is None:
333
396
  ctx.set_source_rgba(0, 0, 0, 1)
334
- ctx.set_line_width(1.0)
397
+ ctx.set_line_width(self._lwd_to_user(1.0))
335
398
  return (0.0, 0.0, 0.0, 1.0)
336
399
 
337
400
  col = gp.get("col", None)
@@ -367,40 +430,27 @@ class CairoRenderer(GridRenderer):
367
430
  # R semantics: lwd=0 means invisible line
368
431
  if lw <= 0:
369
432
  return (0.0, 0.0, 0.0, 0.0)
370
- # R grid semantics: ``lwd`` is always in **points** (1/72 inch)
371
- # regardless of the current viewport's scale. Cairo's
372
- # ``set_line_width`` takes a user-space distance, which the
373
- # viewport CTM has scaled down to NPC-like units — so a value
374
- # of 0.5 user-space becomes sub-pixel after ``scale(w, h)``.
375
- # Convert ``lw`` from points → device pixels using the
376
- # renderer's DPI, then back to user-space via
377
- # ``device_to_user_distance`` so the stroke width stays at
378
- # 0.5pt on the output device no matter how deep the
379
- # viewport stack is (matches R grid's device-unit lwd).
380
- dpi = getattr(self, "dpi", None) or getattr(self, "_dpi", 72.0) or 72.0
381
- lw_px = lw * dpi / 72.0
382
- try:
383
- ux, uy = ctx.device_to_user_distance(lw_px, lw_px)
384
- lw_user = max(abs(ux), abs(uy))
385
- except Exception:
386
- lw_user = lw
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)
387
436
  ctx.set_line_width(lw_user)
388
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).
389
442
  lty = gp.get("lty", None)
390
- if lty is not None:
391
- raw = lty[0] if isinstance(lty, (list, tuple)) else lty
392
- # R integer code (0..6) → name
393
- if isinstance(raw, (int, float, np.integer, np.floating)) and \
394
- not isinstance(raw, bool):
395
- raw = _LTY_INT_TO_NAME.get(int(raw), str(raw))
396
- lty_val = str(raw)
397
- dashes = _LTY_DASHES.get(lty_val)
398
- if dashes is not None:
399
- ctx.set_dash(dashes)
400
- else:
401
- ctx.set_dash([])
402
- else:
443
+ if is_blank_lty(lty):
444
+ return (0.0, 0.0, 0.0, 0.0)
445
+ if lty is None:
403
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 [])
404
454
 
405
455
  lineend = gp.get("lineend", None)
406
456
  if lineend is not None:
@@ -663,8 +713,14 @@ class CairoRenderer(GridRenderer):
663
713
  grid_draw(pat.grob, recording=False)
664
714
  finally:
665
715
  state._renderer = orig_renderer
666
- except Exception:
667
- # If grob rendering fails, return transparent
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
+ )
668
724
  return (0.0, 0.0, 0.0, 0.0)
669
725
 
670
726
  tile_surface = tile_renderer._surface
@@ -1197,7 +1253,7 @@ class CairoRenderer(GridRenderer):
1197
1253
  """
1198
1254
  ctx.save()
1199
1255
  ctx.new_path() # clear any residual path from prior draws
1200
- ctx.set_line_width(lwd)
1256
+ ctx.set_line_width(self._lwd_to_user(lwd))
1201
1257
 
1202
1258
  if pch_val <= 14:
1203
1259
  # --- Group 0-14: stroke-only (use col for outline, no fill) ---
@@ -1720,7 +1776,11 @@ class CairoRenderer(GridRenderer):
1720
1776
  result = ctx.pop_group()
1721
1777
  return result
1722
1778
 
1723
- except Exception:
1779
+ except cairo.Error as exc:
1780
+ warnings.warn(
1781
+ f"group composition failed (cairo): {exc}",
1782
+ UserWarning, stacklevel=2,
1783
+ )
1724
1784
  return None
1725
1785
 
1726
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", "lty", "lineend", "linejoin",
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
 
@@ -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
- if (gpar.lty) sel.attr("stroke-dasharray", ltyToDash(gpar.lty));
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
- if (gpar.lty) {
68
- var dash = ltyToDashArray(gpar.lty);
69
- ctx.setLineDash(dash || []);
70
- } else {
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.post2
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=UWOAMmQkCKI5H7Kx1yPSwFvI6pLQRci2jVDURr8d6H8,10520
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
- grid_py/_curve.py,sha256=MV7hl1MR-4FZfKBKEm4MCSvHgJLYP9UaWLp8eHXPhv0,54041
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=seDvNzkxz3PYcWLeI6MXQ80elWaY18HgfyKshr2cMl0,49662
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=AW3PNlD7UWOozzZvY5Y3f7ZGTWCTRnOs2YqOvlFD1_A,19757
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=kHSxkPHWqHHyMVum7ueKS-ddDqFF2E-8tUQOM-azEXU,39992
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=PBwV2b3oJkbueTLCku6cxeZ1PThREjiPcA6lmrwH3pE,32691
22
- grid_py/_primitives.py,sha256=dpX5_q5CS8p1vhuVzTa77MVbtaxigGBA5mmUzfFfHks,58861
23
- grid_py/_renderer_base.py,sha256=-wZYsKXaetQw1wPDsjUJoJffIDmjEVZN6P_lj5xOPT4,54239
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=AOrYmv05Y4QJbsTksn86RLStfHdbOHERc2YDHdZAekE,48477
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=hzCRgHMrR8tFpsdqT1al09gnZhgShoe_kIEO4MTYZNY,61620
35
- grid_py/renderer_web.py,sha256=pbOijES1eUUqkeAadXVyZPCBRIUIgzs3HIOgAElTYmo,27958
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=l8KGgK-qZbJPVvrMAmLUu57RhpSAo_LMibs6rx3RnvA,28340
39
- rgrid_python-4.5.3.post2.dist-info/METADATA,sha256=uGrI2aBhk9PL1tjCk4izXQNr0niwxEGVhjbI4eOpmJM,19910
40
- rgrid_python-4.5.3.post2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
41
- rgrid_python-4.5.3.post2.dist-info/licenses/LICENSE,sha256=8zNQKZlkc4JOaW7br_a8aALg4baZwZkLKLTQx3kuHkc,48
42
- rgrid_python-4.5.3.post2.dist-info/RECORD,,
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,,