rgrid-python 4.5.3.post2__tar.gz → 4.5.3.post5__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.post2 → rgrid_python-4.5.3.post5}/PKG-INFO +1 -1
  2. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/__init__.py +1 -1
  3. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/_colour.py +9 -2
  4. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/_curve.py +22 -1
  5. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/_draw.py +172 -51
  6. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/_gpar.py +44 -18
  7. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/_grob.py +6 -3
  8. rgrid_python-4.5.3.post5/grid_py/_lty.py +197 -0
  9. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/_patterns.py +11 -4
  10. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/_primitives.py +85 -5
  11. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/_renderer_base.py +240 -26
  12. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/_state.py +68 -10
  13. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/_viewport.py +28 -7
  14. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/_vp_calc.py +45 -3
  15. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/renderer.py +363 -72
  16. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/renderer_web.py +38 -3
  17. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/resources/gridpy.js +22 -25
  18. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/pyproject.toml +1 -1
  19. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/.gitattributes +0 -0
  20. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/.gitignore +0 -0
  21. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/LICENSE +0 -0
  22. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/README.md +0 -0
  23. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/_arrow.py +0 -0
  24. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/_clippath.py +0 -0
  25. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/_coords.py +0 -0
  26. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/_display_list.py +0 -0
  27. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/_edit.py +0 -0
  28. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/_font_metrics.py +0 -0
  29. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/_grab.py +0 -0
  30. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/_group.py +0 -0
  31. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/_highlevel.py +0 -0
  32. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/_just.py +0 -0
  33. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/_layout.py +0 -0
  34. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/_ls.py +0 -0
  35. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/_mask.py +0 -0
  36. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/_path.py +0 -0
  37. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/_scene_graph.py +0 -0
  38. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/_size.py +0 -0
  39. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/_transforms.py +0 -0
  40. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/_typeset.py +0 -0
  41. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/_units.py +0 -0
  42. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/_utils.py +0 -0
  43. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/py.typed +0 -0
  44. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/grid_py/resources/d3.v7.min.js +0 -0
  45. {rgrid_python-4.5.3.post2 → rgrid_python-4.5.3.post5}/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.post2
3
+ Version: 4.5.3.post5
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.post2"
9
+ __version__ = "4.5.3.post5"
10
10
 
11
11
  # --- Utilities ---
12
12
  from grid_py._utils import depth, explode, grid_pretty, n2mfrow
@@ -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
@@ -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
  )
@@ -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))
@@ -412,7 +460,15 @@ def _render_grob(
412
460
  xs, ys = _calc_xspline_points(
413
461
  x, y, shape=shape_arr, open_=open_, repEnds=rep_ends,
414
462
  )
415
- renderer.draw_polyline(xs, ys, id_=None, gp=gp)
463
+ # R's ``xsplineGrob(open=FALSE)`` is a *filled closed* shape, not
464
+ # a stroked path; route through ``draw_polygon`` so ``gp$fill``
465
+ # actually paints (R/grid: drawDetails.xspline → C_xspline,
466
+ # filled when ``open == FALSE``). ``open=TRUE`` stays a stroked
467
+ # polyline.
468
+ if open_:
469
+ renderer.draw_polyline(xs, ys, id_=None, gp=gp)
470
+ else:
471
+ renderer.draw_polygon(xs, ys, gp=gp)
416
472
  if arr is not None and len(xs) >= 2:
417
473
  _draw_arrow_heads(xs, ys, arr, renderer, gp)
418
474
  else:
@@ -436,12 +492,24 @@ def _render_grob(
436
492
  out_id += 1
437
493
  per_group.append((xs_g, ys_g))
438
494
  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
- )
495
+ # Multi-id closed splines render as N separate filled
496
+ # subpolygons (R: xsplineGrob(id=..., open=FALSE)); use
497
+ # ``draw_path`` with the path_id array. Open splines stay
498
+ # multi-stroke polylines.
499
+ if open_:
500
+ renderer.draw_polyline(
501
+ np.asarray(all_xs, dtype=float),
502
+ np.asarray(all_ys, dtype=float),
503
+ id_=np.asarray(all_ids, dtype=int),
504
+ gp=gp,
505
+ )
506
+ else:
507
+ renderer.draw_path(
508
+ np.asarray(all_xs, dtype=float),
509
+ np.asarray(all_ys, dtype=float),
510
+ path_id=np.asarray(all_ids, dtype=int),
511
+ gp=gp,
512
+ )
445
513
  if arr is not None:
446
514
  for xs_g, ys_g in per_group:
447
515
  if len(xs_g) >= 2:
@@ -449,8 +517,9 @@ def _render_grob(
449
517
 
450
518
  # ---- polygon ---------------------------------------------------------
451
519
  elif cls == "polygon":
452
- px = renderer.resolve_x_array(getattr(grob, "x", []), gp=gp)
453
- 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
+ )
454
523
  pid = getattr(grob, "id", None)
455
524
  if pid is not None:
456
525
  # R semantics: polygonGrob(id=...) draws separate polygons
@@ -484,9 +553,8 @@ def _render_grob(
484
553
  else:
485
554
  labels = [str(label_raw)]
486
555
 
487
- # Resolve x/y to arrays
488
- xx = renderer.resolve_x_array(x_unit, gp=gp)
489
- 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)
490
558
 
491
559
  # Normalise rot to array
492
560
  if isinstance(rot_raw, (list, tuple, np.ndarray)):
@@ -528,16 +596,20 @@ def _render_grob(
528
596
  # ---- points ----------------------------------------------------------
529
597
  elif cls == "points":
530
598
  pch_raw = getattr(grob, "pch", 19)
531
- # pch may be a scalar or per-point array pass through as-is
532
- if isinstance(pch_raw, (np.ndarray, list, tuple)):
533
- pch_val = np.asarray(pch_raw, dtype=int)
534
- elif isinstance(pch_raw, (int, float, np.integer, np.floating)):
535
- pch_val = int(pch_raw)
536
- else:
537
- 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
+ )
538
611
  renderer.draw_points(
539
- x=renderer.resolve_x_array(getattr(grob, "x", []), gp=gp),
540
- y=renderer.resolve_y_array(getattr(grob, "y", []), gp=gp),
612
+ x=pts_x, y=pts_y,
541
613
  size=renderer.resolve_w(getattr(grob, "size", 1.0), gp=gp),
542
614
  pch=pch_val,
543
615
  gp=gp,
@@ -545,18 +617,63 @@ def _render_grob(
545
617
 
546
618
  # ---- pathgrob --------------------------------------------------------
547
619
  elif cls == "pathgrob":
548
- x = renderer.resolve_x_array(getattr(grob, "x", []), gp=gp)
549
- y = renderer.resolve_y_array(getattr(grob, "y", []), gp=gp)
550
- 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
+
551
658
  if path_id is None:
552
- 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
+ )
553
662
  else:
554
- path_id = np.atleast_1d(np.asarray(path_id, dtype=int))
555
- renderer.draw_path(
556
- x=x, y=y, path_id=path_id,
557
- rule=getattr(grob, "rule", "winding"),
558
- gp=gp,
559
- )
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
+ )
560
677
 
561
678
  # ---- rastergrob ------------------------------------------------------
562
679
  elif cls == "rastergrob":
@@ -564,15 +681,19 @@ def _render_grob(
564
681
  if image is None:
565
682
  image = getattr(grob, "image", None)
566
683
  if image is not None:
567
- # Apply justification (same as rect_grob)
568
684
  hj, vj = _resolve_just(grob)
569
- raw_x = renderer.resolve_x(getattr(grob, "x", 0.0), gp=gp)
570
- 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
+ )
571
688
  raw_w = renderer.resolve_w(getattr(grob, "width", 1.0), gp=gp)
572
689
  raw_h = renderer.resolve_h(getattr(grob, "height", 1.0), gp=gp)
573
- # Compute bottom-left corner from anchor + justification
690
+ # `renderer.draw_raster` expects the *top-left* corner in y-down
691
+ # device pixels. `resolve_y` already returns y-down coords, so the
692
+ # y component of justification must be flipped relative to R's
693
+ # `justifyY(y, h, vjust) = y - h*vjust` (which assumes y-up NPC).
694
+ # Same correction `draw_rect` applies (renderer.py:815).
574
695
  x0 = raw_x - raw_w * hj
575
- y0 = raw_y - raw_h * vj
696
+ y0 = raw_y - raw_h * (1.0 - vj)
576
697
  renderer.draw_raster(
577
698
  image=image,
578
699
  x=x0,
@@ -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:
@@ -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":