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.
Files changed (45) hide show
  1. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/PKG-INFO +1 -1
  2. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/__init__.py +3 -3
  3. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_curve.py +22 -1
  4. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_draw.py +64 -16
  5. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_gpar.py +56 -19
  6. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_grob.py +6 -3
  7. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_layout.py +9 -2
  8. rgrid_python-4.5.3.post4/grid_py/_lty.py +197 -0
  9. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_patterns.py +11 -4
  10. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_primitives.py +18 -5
  11. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_renderer_base.py +219 -18
  12. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_size.py +36 -10
  13. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_state.py +42 -0
  14. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_units.py +14 -1
  15. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_viewport.py +28 -7
  16. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/renderer.py +118 -58
  17. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/renderer_web.py +31 -2
  18. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/resources/gridpy.js +22 -25
  19. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/pyproject.toml +1 -1
  20. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/.gitattributes +0 -0
  21. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/.gitignore +0 -0
  22. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/LICENSE +0 -0
  23. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/README.md +0 -0
  24. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_arrow.py +0 -0
  25. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_clippath.py +0 -0
  26. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_colour.py +0 -0
  27. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_coords.py +0 -0
  28. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_display_list.py +0 -0
  29. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_edit.py +0 -0
  30. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_font_metrics.py +0 -0
  31. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_grab.py +0 -0
  32. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_group.py +0 -0
  33. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_highlevel.py +0 -0
  34. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_just.py +0 -0
  35. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_ls.py +0 -0
  36. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_mask.py +0 -0
  37. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_path.py +0 -0
  38. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_scene_graph.py +0 -0
  39. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_transforms.py +0 -0
  40. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_typeset.py +0 -0
  41. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_utils.py +0 -0
  42. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/_vp_calc.py +0 -0
  43. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/py.typed +0 -0
  44. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post4}/grid_py/resources/d3.v7.min.js +0 -0
  45. {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.post1
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.post1"
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, 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
  )
@@ -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,
@@ -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 VpTree.
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
- # VpPath stores the number of path components
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
- _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:
@@ -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: 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":
@@ -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] and _col_respected(i, layout)
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] and _row_respected(j, layout)
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 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)