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 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.post3"
9
+ __version__ = "4.5.3.post5"
10
10
 
11
11
  # --- Utilities ---
12
12
  from grid_py._utils import depth, explode, grid_pretty, n2mfrow
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
- # Transparent / NA
765
- if low in ("transparent", "na", "none", ""):
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 Exception:
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
- xs = renderer.resolve_x_array(getattr(grob, "x", [0.0]), gp=gp)
306
- ys = renderer.resolve_y_array(getattr(grob, "y", [0.0]), gp=gp)
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=renderer.resolve_x(getattr(grob, "x", 0.0), gp=gp),
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=renderer.resolve_x(getattr(grob, "x", 0.5), gp=gp),
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.resolve_x_array(getattr(grob, "x", [0.0, 1.0]), gp=gp)
352
- y = renderer.resolve_y_array(getattr(grob, "y", [0.0, 1.0]), gp=gp)
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.resolve_x_array(getattr(grob, "x0", []), gp=gp)
369
- y0 = renderer.resolve_y_array(getattr(grob, "y0", []), gp=gp)
370
- x1 = renderer.resolve_x_array(getattr(grob, "x1", []), gp=gp)
371
- y1 = renderer.resolve_y_array(getattr(grob, "y1", []), gp=gp)
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.resolve_x_array(getattr(grob, "x", [0.0, 1.0]), gp=gp)
391
- y = renderer.resolve_y_array(getattr(grob, "y", [0.0, 1.0]), gp=gp)
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.resolve_x_array(getattr(grob, "x", []), gp=gp)
473
- py = renderer.resolve_y_array(getattr(grob, "y", []), gp=gp)
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 to arrays
508
- xx = renderer.resolve_x_array(x_unit, gp=gp)
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 per-point array pass through as-is
552
- if isinstance(pch_raw, (np.ndarray, list, tuple)):
553
- pch_val = np.asarray(pch_raw, dtype=int)
554
- elif isinstance(pch_raw, (int, float, np.integer, np.floating)):
555
- pch_val = int(pch_raw)
556
- else:
557
- pch_val = 19
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=renderer.resolve_x_array(getattr(grob, "x", []), gp=gp),
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.resolve_x_array(getattr(grob, "x", []), gp=gp)
569
- y = renderer.resolve_y_array(getattr(grob, "y", []), gp=gp)
570
- path_id = getattr(grob, "pathId", None)
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
- path_id = np.ones(len(x), dtype=int)
659
+ renderer.draw_path(
660
+ x=x, y=y, path_id=sub_id, rule=rule, gp=gp,
661
+ )
573
662
  else:
574
- path_id = np.atleast_1d(np.asarray(path_id, dtype=int))
575
- renderer.draw_path(
576
- x=x, y=y, path_id=path_id,
577
- rule=getattr(grob, "rule", "winding"),
578
- gp=gp,
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.resolve_x(getattr(grob, "x", 0.0), gp=gp)
589
- raw_y = renderer.resolve_y(getattr(grob, "y", 0.0), gp=gp)
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
- _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)