rgrid-python 4.5.3.post4__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.post4 → rgrid_python-4.5.3.post5}/PKG-INFO +1 -1
  2. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/__init__.py +1 -1
  3. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/_colour.py +9 -2
  4. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/_draw.py +133 -40
  5. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/_primitives.py +67 -0
  6. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/_renderer_base.py +140 -18
  7. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/_state.py +68 -10
  8. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/_vp_calc.py +45 -3
  9. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/renderer.py +264 -33
  10. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/renderer_web.py +7 -1
  11. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/pyproject.toml +1 -1
  12. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/.gitattributes +0 -0
  13. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/.gitignore +0 -0
  14. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/LICENSE +0 -0
  15. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/README.md +0 -0
  16. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/_arrow.py +0 -0
  17. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/_clippath.py +0 -0
  18. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/_coords.py +0 -0
  19. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/_curve.py +0 -0
  20. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/_display_list.py +0 -0
  21. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/_edit.py +0 -0
  22. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/_font_metrics.py +0 -0
  23. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/_gpar.py +0 -0
  24. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/_grab.py +0 -0
  25. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/_grob.py +0 -0
  26. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/_group.py +0 -0
  27. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/_highlevel.py +0 -0
  28. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/_just.py +0 -0
  29. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/_layout.py +0 -0
  30. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/_ls.py +0 -0
  31. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/_lty.py +0 -0
  32. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/_mask.py +0 -0
  33. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/_path.py +0 -0
  34. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/_patterns.py +0 -0
  35. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/_scene_graph.py +0 -0
  36. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/_size.py +0 -0
  37. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/_transforms.py +0 -0
  38. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/_typeset.py +0 -0
  39. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/_units.py +0 -0
  40. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/_utils.py +0 -0
  41. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/_viewport.py +0 -0
  42. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/py.typed +0 -0
  43. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/resources/d3.v7.min.js +0 -0
  44. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/resources/gridpy.css +0 -0
  45. {rgrid_python-4.5.3.post4 → rgrid_python-4.5.3.post5}/grid_py/resources/gridpy.js +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rgrid-python
3
- Version: 4.5.3.post4
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.post4"
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
@@ -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",
@@ -307,8 +308,16 @@ def _render_grob(
307
308
 
308
309
  # ---- rect -----------------------------------------------------------
309
310
  if cls == "rect":
310
- xs = renderer.resolve_x_array(getattr(grob, "x", [0.0]), gp=gp)
311
- 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
+ )
312
321
  ws = renderer.resolve_w_array(getattr(grob, "width", [1.0]), gp=gp)
313
322
  hs = renderer.resolve_h_array(getattr(grob, "height", [1.0]), gp=gp)
314
323
  hj, vj = _resolve_just(grob)
@@ -331,9 +340,11 @@ def _render_grob(
331
340
 
332
341
  # ---- roundrect ------------------------------------------------------
333
342
  elif cls == "roundrect":
343
+ ax, ay = renderer.resolve_loc(
344
+ getattr(grob, "x", 0.0), getattr(grob, "y", 0.0), gp=gp,
345
+ )
334
346
  renderer.draw_roundrect(
335
- x=renderer.resolve_x(getattr(grob, "x", 0.0), gp=gp),
336
- y=renderer.resolve_y(getattr(grob, "y", 0.0), gp=gp),
347
+ x=ax, y=ay,
337
348
  w=renderer.resolve_w(getattr(grob, "width", 1.0), gp=gp),
338
349
  h=renderer.resolve_h(getattr(grob, "height", 1.0), gp=gp),
339
350
  r=renderer.resolve_w(getattr(grob, "r", 0.0), gp=gp),
@@ -344,17 +355,22 @@ def _render_grob(
344
355
 
345
356
  # ---- circle ---------------------------------------------------------
346
357
  elif cls == "circle":
358
+ cx, cy = renderer.resolve_loc(
359
+ getattr(grob, "x", 0.5), getattr(grob, "y", 0.5), gp=gp,
360
+ )
347
361
  renderer.draw_circle(
348
- x=renderer.resolve_x(getattr(grob, "x", 0.5), gp=gp),
349
- y=renderer.resolve_y(getattr(grob, "y", 0.5), gp=gp),
362
+ x=cx, y=cy,
350
363
  r=renderer.resolve_w(getattr(grob, "r", 0.5), gp=gp),
351
364
  gp=gp,
352
365
  )
353
366
 
354
367
  # ---- lines / polyline ------------------------------------------------
355
368
  elif cls in ("lines", "polyline"):
356
- x = renderer.resolve_x_array(getattr(grob, "x", [0.0, 1.0]), gp=gp)
357
- 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
+ )
358
374
  id_ = getattr(grob, "id", None)
359
375
  id_lengths = getattr(grob, "id_lengths", None)
360
376
  # R polylineGrob supports either `id` (per-point group) or
@@ -368,12 +384,36 @@ def _render_grob(
368
384
  id_ = np.atleast_1d(np.asarray(id_, dtype=int))
369
385
  renderer.draw_polyline(x, y, id_=id_, gp=gp)
370
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
+
371
409
  # ---- segments --------------------------------------------------------
372
410
  elif cls == "segments":
373
- x0 = renderer.resolve_x_array(getattr(grob, "x0", []), gp=gp)
374
- y0 = renderer.resolve_y_array(getattr(grob, "y0", []), gp=gp)
375
- x1 = renderer.resolve_x_array(getattr(grob, "x1", []), gp=gp)
376
- 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
+ )
377
417
  renderer.draw_segments(x0=x0, y0=y0, x1=x1, y1=y1, gp=gp)
378
418
 
379
419
  # Each segment may carry its own arrowhead (``arrow=`` parameter on
@@ -392,8 +432,11 @@ def _render_grob(
392
432
  elif cls == "xspline":
393
433
  from ._curve import _calc_xspline_points # lazy to avoid import cycle
394
434
 
395
- x = renderer.resolve_x_array(getattr(grob, "x", [0.0, 1.0]), gp=gp)
396
- 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
+ )
397
440
  shape_raw = getattr(grob, "shape", 0.0)
398
441
  open_ = bool(getattr(grob, "open_", True))
399
442
  rep_ends = bool(getattr(grob, "repEnds", True))
@@ -474,8 +517,9 @@ def _render_grob(
474
517
 
475
518
  # ---- polygon ---------------------------------------------------------
476
519
  elif cls == "polygon":
477
- px = renderer.resolve_x_array(getattr(grob, "x", []), gp=gp)
478
- 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
+ )
479
523
  pid = getattr(grob, "id", None)
480
524
  if pid is not None:
481
525
  # R semantics: polygonGrob(id=...) draws separate polygons
@@ -509,9 +553,8 @@ def _render_grob(
509
553
  else:
510
554
  labels = [str(label_raw)]
511
555
 
512
- # Resolve x/y to arrays
513
- xx = renderer.resolve_x_array(x_unit, gp=gp)
514
- 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)
515
558
 
516
559
  # Normalise rot to array
517
560
  if isinstance(rot_raw, (list, tuple, np.ndarray)):
@@ -553,16 +596,20 @@ def _render_grob(
553
596
  # ---- points ----------------------------------------------------------
554
597
  elif cls == "points":
555
598
  pch_raw = getattr(grob, "pch", 19)
556
- # pch may be a scalar or per-point array pass through as-is
557
- if isinstance(pch_raw, (np.ndarray, list, tuple)):
558
- pch_val = np.asarray(pch_raw, dtype=int)
559
- elif isinstance(pch_raw, (int, float, np.integer, np.floating)):
560
- pch_val = int(pch_raw)
561
- else:
562
- 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
+ )
563
611
  renderer.draw_points(
564
- x=renderer.resolve_x_array(getattr(grob, "x", []), gp=gp),
565
- y=renderer.resolve_y_array(getattr(grob, "y", []), gp=gp),
612
+ x=pts_x, y=pts_y,
566
613
  size=renderer.resolve_w(getattr(grob, "size", 1.0), gp=gp),
567
614
  pch=pch_val,
568
615
  gp=gp,
@@ -570,18 +617,63 @@ def _render_grob(
570
617
 
571
618
  # ---- pathgrob --------------------------------------------------------
572
619
  elif cls == "pathgrob":
573
- x = renderer.resolve_x_array(getattr(grob, "x", []), gp=gp)
574
- y = renderer.resolve_y_array(getattr(grob, "y", []), gp=gp)
575
- 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
+
576
658
  if path_id is None:
577
- 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
+ )
578
662
  else:
579
- path_id = np.atleast_1d(np.asarray(path_id, dtype=int))
580
- renderer.draw_path(
581
- x=x, y=y, path_id=path_id,
582
- rule=getattr(grob, "rule", "winding"),
583
- gp=gp,
584
- )
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
+ )
585
677
 
586
678
  # ---- rastergrob ------------------------------------------------------
587
679
  elif cls == "rastergrob":
@@ -590,8 +682,9 @@ def _render_grob(
590
682
  image = getattr(grob, "image", None)
591
683
  if image is not None:
592
684
  hj, vj = _resolve_just(grob)
593
- raw_x = renderer.resolve_x(getattr(grob, "x", 0.0), gp=gp)
594
- 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
+ )
595
688
  raw_w = renderer.resolve_w(getattr(grob, "width", 1.0), gp=gp)
596
689
  raw_h = renderer.resolve_h(getattr(grob, "height", 1.0), gp=gp)
597
690
  # `renderer.draw_raster` expects the *top-left* corner in y-down
@@ -56,6 +56,7 @@ __all__ = [
56
56
  "arrows_grob",
57
57
  "grid_arrows",
58
58
  # points
59
+ "valid_pch",
59
60
  "points_grob",
60
61
  "grid_points",
61
62
  # rect
@@ -706,6 +707,70 @@ def grid_arrows(
706
707
  # ===================================================================== #
707
708
 
708
709
 
710
+ def valid_pch(pch: Any) -> Any:
711
+ """Validate / normalise a plotting character (``pch``).
712
+
713
+ Faithful port of R's ``grid:::valid.pch`` (primitives.R:1504-1512)::
714
+
715
+ valid.pch <- function(pch) {
716
+ if (length(pch) == 0L) stop("zero-length 'pch'")
717
+ if (is.null(pch)) pch <- 1L
718
+ else if (!is.character(pch)) pch <- as.integer(pch)
719
+ pch
720
+ }
721
+
722
+ Semantics, verified against grid 4.5.3:
723
+
724
+ * **Zero-length** input (e.g. ``[]``, ``np.array([])``) → ``ValueError``.
725
+ (In R ``length(NULL) == 0`` so ``NULL`` also hits this branch and
726
+ errors; the ``is.null`` arm is effectively unreachable. We keep a
727
+ ``None`` → ``1`` arm for ergonomics, matching the *written* R source
728
+ rather than its dead-code quirk, since ``None`` is the natural
729
+ Python sentinel for "use the default".)
730
+ * **Character** ``pch`` is kept *as character* (e.g. ``"."`` stays
731
+ ``"."``; the engine later draws it as a glyph / tiny point).
732
+ * **Any other (numeric)** ``pch`` is coerced to ``int`` (truncating
733
+ floats, mirroring ``as.integer``).
734
+ * **Mixed** sequences containing any character element are kept as an
735
+ object array of per-point values (R's ``c(".", 19, "A")`` coerces
736
+ to the character vector ``c(".", "19", "A")``; we preserve each
737
+ element so the per-point dispatch can decide glyph-vs-symbol).
738
+
739
+ Returns the normalised ``pch``: an ``int``, a ``str``, or a numpy
740
+ array of per-point values (int dtype if all-numeric, else object).
741
+ """
742
+ if pch is None:
743
+ return 1
744
+
745
+ if isinstance(pch, str):
746
+ return pch
747
+
748
+ if isinstance(pch, (int, np.integer)):
749
+ return int(pch)
750
+ if isinstance(pch, (float, np.floating)):
751
+ return int(pch) # as.integer truncates toward zero
752
+
753
+ if isinstance(pch, (list, tuple, np.ndarray)):
754
+ arr = np.atleast_1d(np.asarray(pch, dtype=object))
755
+ if arr.size == 0:
756
+ raise ValueError("zero-length 'pch'")
757
+ # If any element is a (non-numeric) string, R coerces the whole
758
+ # vector to character. We keep per-element values in an object
759
+ # array so the renderer can dispatch each point individually.
760
+ has_char = any(isinstance(v, (str, bytes, np.str_)) for v in arr)
761
+ if has_char:
762
+ return np.asarray(
763
+ [v if isinstance(v, (str, bytes, np.str_)) else str(v)
764
+ for v in arr],
765
+ dtype=object,
766
+ )
767
+ # All numeric → integer array (as.integer).
768
+ return np.asarray([int(v) for v in arr], dtype=int)
769
+
770
+ # Fallback for any other scalar numeric-like (e.g. numpy bool):
771
+ return int(pch)
772
+
773
+
709
774
  def points_grob(
710
775
  x: Any = None,
711
776
  y: Any = None,
@@ -770,6 +835,8 @@ def points_grob(
770
835
  size = Unit(1, "char")
771
836
  else:
772
837
  size = _ensure_unit(size, default_units)
838
+ # R: validDetails.points -> x$pch <- valid.pch(x$pch)
839
+ pch = valid_pch(pch)
773
840
  return Grob(
774
841
  x=x, y=y, pch=pch, size=size,
775
842
  name=name, gp=gp, vp=vp, _grid_class="points",
@@ -91,7 +91,17 @@ class GridRenderer(ABC):
91
91
  # containing width_cm, height_cm, rotation_angle, 3×3 transform matrix,
92
92
  # and ViewportContext (xscale/yscale).
93
93
  # The root entry represents the device itself.
94
- root_vtr = calc_root_transform(self._device_width_cm, self._device_height_cm)
94
+ # ``y_down`` is True for raster surfaces (PNG / ImageSurface),
95
+ # False for vector surfaces (PDF / SVG / PS) — matches R's
96
+ # per-device behaviour in src/viewport.c:initVP. We probe the
97
+ # subclass via ``_surface_type`` (CairoRenderer's flag); raster
98
+ # is the default when the subclass has not declared one.
99
+ _surface_type = getattr(self, "_surface_type", "image")
100
+ root_vtr = calc_root_transform(
101
+ self._device_width_cm, self._device_height_cm,
102
+ dev_units_per_inch=self._dev_units_per_inch,
103
+ y_down=(_surface_type == "image"),
104
+ )
95
105
  self._vp_transform_stack: List[ViewportTransformResult] = [root_vtr]
96
106
 
97
107
  # Keep a parallel list of viewport objects for attribute access
@@ -216,7 +226,14 @@ class GridRenderer(ABC):
216
226
  # layout (for its children). In R, layout_pos determines the
217
227
  # viewport's own size/position first, then the layout applies
218
228
  # within that region.
219
- if layout_pos_row is not None and layout_pos_col is not None:
229
+ #
230
+ # Per R src/layout.c:617-633 ``calcViewportLocationFromLayout``:
231
+ # exactly ONE of layoutPosRow / layoutPosCol may be NULL — that
232
+ # is interpreted as "occupy all rows/cols". We mirror the
233
+ # behaviour here so e.g. ``viewport(layout_pos_col=i)`` in a
234
+ # 1-row layout correctly spans the whole row instead of
235
+ # collapsing back to centre.
236
+ if layout_pos_row is not None or layout_pos_col is not None:
220
237
  if self._layout_stack:
221
238
  grid = self._layout_stack[-1]
222
239
  col_starts = grid["col_starts"]
@@ -224,11 +241,19 @@ class GridRenderer(ABC):
224
241
  row_starts = grid["row_starts"]
225
242
  row_heights = grid["row_heights"]
226
243
 
227
- if isinstance(layout_pos_row, (list, tuple)):
244
+ if layout_pos_row is None:
245
+ # NULL → span all rows (R layout.c:621-624).
246
+ t = 0
247
+ b = len(row_heights) - 1
248
+ elif isinstance(layout_pos_row, (list, tuple)):
228
249
  t, b = int(layout_pos_row[0]) - 1, int(layout_pos_row[1]) - 1
229
250
  else:
230
251
  t = b = int(layout_pos_row) - 1
231
- if isinstance(layout_pos_col, (list, tuple)):
252
+ if layout_pos_col is None:
253
+ # NULL → span all cols (R layout.c:628-631).
254
+ l = 0
255
+ r = len(col_widths) - 1
256
+ elif isinstance(layout_pos_col, (list, tuple)):
232
257
  l, r = int(layout_pos_col[0]) - 1, int(layout_pos_col[1]) - 1
233
258
  else:
234
259
  l = r = int(layout_pos_col) - 1
@@ -857,18 +882,24 @@ class GridRenderer(ABC):
857
882
  h_in = _details_inches(height_details, "y")
858
883
 
859
884
  # hjust / vjust control which corner of the box is anchored at (x, y).
860
- def _just_to_float(v: Any, default: float) -> float:
861
- if v is None:
862
- return default
863
- if isinstance(v, (int, float)):
864
- return float(v)
865
- _H = {"left": 0.0, "right": 1.0, "centre": 0.5, "center": 0.5}
866
- _V = {"bottom": 0.0, "top": 1.0, "centre": 0.5, "center": 0.5}
867
- s = str(v).lower()
868
- return _H.get(s, _V.get(s, default))
869
-
870
- hjust = _just_to_float(getattr(grob, "hjust", 0.5), 0.5)
871
- vjust = _just_to_float(getattr(grob, "vjust", 0.5), 0.5)
885
+ #
886
+ # Per R/just.R (4.5.3) ``resolveHJust`` / ``resolveVJust``:
887
+ # when ``hjust`` / ``vjust`` is NULL the value is derived from
888
+ # ``just`` (which may be a string like ``"left"`` or a 2-element
889
+ # vector). Previously this code looked at ``grob.hjust`` and
890
+ # ``grob.vjust`` only, so a grob built with
891
+ # ``rect_grob(just="left")`` (which stores ``hjust=None``,
892
+ # ``just="left"``) fell back to centre. Going through the
893
+ # shared ``resolve_hjust`` / ``resolve_vjust`` helpers brings
894
+ # the bbox computation in line with R for every just-string
895
+ # form (single string, tuple of strings, numeric, mixed).
896
+ from ._just import resolve_hjust, resolve_vjust
897
+
898
+ just = getattr(grob, "just", None)
899
+ if just is None:
900
+ just = "centre"
901
+ hjust = float(resolve_hjust(just, getattr(grob, "hjust", None)))
902
+ vjust = float(resolve_vjust(just, getattr(grob, "vjust", None)))
872
903
 
873
904
  # Centre of the bounding box in inches.
874
905
  cx = x_inches + (0.5 - hjust) * w_in
@@ -1090,7 +1121,16 @@ class GridRenderer(ABC):
1090
1121
  return self.inches_to_dev_h(inches)
1091
1122
 
1092
1123
  def resolve_x_array(self, val: Any, gp: Optional[Any] = None) -> "np.ndarray":
1093
- """Resolve *val* to an array of device x-coordinates."""
1124
+ """Resolve *val* to an array of device x-coordinates.
1125
+
1126
+ WARNING: this method evaluates each ``x_i`` against a hard-coded
1127
+ ``y=0``. That is exact for an unrotated viewport, but for a
1128
+ viewport with non-zero ``angle`` the rotation component of the
1129
+ 2-D CTM mixes the x and y axes — the y=0 stub then drops part
1130
+ of the answer. ``resolve_loc_array`` (below) does the
1131
+ rotation-correct pairing and is what every drawing site
1132
+ should use whenever an associated ``y`` array exists.
1133
+ """
1094
1134
  from ._units import Unit
1095
1135
  if isinstance(val, Unit):
1096
1136
  out = np.empty(len(val), dtype=float)
@@ -1103,8 +1143,90 @@ class GridRenderer(ABC):
1103
1143
  return np.asarray([self.resolve_x(v, gp) for v in val], dtype=float)
1104
1144
  return np.atleast_1d(np.asarray(val, dtype=float))
1105
1145
 
1146
+ def resolve_loc(
1147
+ self,
1148
+ x_val: Any,
1149
+ y_val: Any,
1150
+ gp: Optional[Any] = None,
1151
+ ) -> Tuple[float, float]:
1152
+ """Resolve an ``(x, y)`` location pair to device coordinates.
1153
+
1154
+ Port of R's ``transformLocn`` (src/unit.c) one-shot pairing —
1155
+ the inches values for both axes are passed through the
1156
+ viewport's 3×3 transform together so that rotation correctly
1157
+ mixes them.
1158
+
1159
+ Unlike ``resolve_x(val) + resolve_y(val)``, which each call
1160
+ ``transform_loc_to_device`` with the orthogonal coord forced to
1161
+ 0 and so cannot represent the rotation contribution, this
1162
+ helper preserves both inputs and matches ``device_loc``'s
1163
+ algorithm.
1164
+
1165
+ Every grob-drawing site with a genuine 2-D anchor (rect,
1166
+ circle, text, roundrect, raster, ...) should use this in
1167
+ preference to the single-axis helpers.
1168
+ """
1169
+ x_in = self._resolve_to_inches(x_val, axis="x", is_dim=False, gp=gp)
1170
+ y_in = self._resolve_to_inches(y_val, axis="y", is_dim=False, gp=gp)
1171
+ return self.transform_loc_to_device(x_in, y_in)
1172
+
1173
+ def resolve_loc_array(
1174
+ self,
1175
+ x_vals: Any,
1176
+ y_vals: Any,
1177
+ gp: Optional[Any] = None,
1178
+ ) -> Tuple["np.ndarray", "np.ndarray"]:
1179
+ """Resolve parallel ``x`` / ``y`` arrays to device coord pairs.
1180
+
1181
+ Each ``(x_i, y_i)`` pair is mapped through the full 2-D vp
1182
+ transform via ``transform_loc_to_device``, so polygon / lines /
1183
+ segments / points vertices stay self-consistent under
1184
+ rotation. Length follows R's recycling rule — the longer of
1185
+ ``len(x_vals)`` and ``len(y_vals)``.
1186
+ """
1187
+ from ._units import Unit
1188
+
1189
+ def _length(v: Any) -> int:
1190
+ return len(v) if hasattr(v, "__len__") and not isinstance(v, str) else 1
1191
+
1192
+ nx = _length(x_vals)
1193
+ ny = _length(y_vals)
1194
+ n = max(nx, ny)
1195
+
1196
+ out_x = np.empty(n, dtype=float)
1197
+ out_y = np.empty(n, dtype=float)
1198
+
1199
+ for i in range(n):
1200
+ # x_i in inches (viewport-local frame)
1201
+ if isinstance(x_vals, Unit):
1202
+ x_in = self._resolve_to_inches_idx(x_vals, i % nx, "x", False, gp)
1203
+ elif isinstance(x_vals, (list, tuple, np.ndarray)):
1204
+ x_in = self._resolve_to_inches(
1205
+ x_vals[i % nx], axis="x", is_dim=False, gp=gp,
1206
+ )
1207
+ else:
1208
+ x_in = self._resolve_to_inches(x_vals, axis="x", is_dim=False, gp=gp)
1209
+ # y_i in inches
1210
+ if isinstance(y_vals, Unit):
1211
+ y_in = self._resolve_to_inches_idx(y_vals, i % ny, "y", False, gp)
1212
+ elif isinstance(y_vals, (list, tuple, np.ndarray)):
1213
+ y_in = self._resolve_to_inches(
1214
+ y_vals[i % ny], axis="y", is_dim=False, gp=gp,
1215
+ )
1216
+ else:
1217
+ y_in = self._resolve_to_inches(y_vals, axis="y", is_dim=False, gp=gp)
1218
+
1219
+ out_x[i], out_y[i] = self.transform_loc_to_device(x_in, y_in)
1220
+
1221
+ return out_x, out_y
1222
+
1106
1223
  def resolve_y_array(self, val: Any, gp: Optional[Any] = None) -> "np.ndarray":
1107
- """Resolve *val* to an array of device y-coordinates."""
1224
+ """Resolve *val* to an array of device y-coordinates.
1225
+
1226
+ Like ``resolve_x_array`` this loses the rotation contribution
1227
+ from the orthogonal axis (x is forced to 0). Prefer
1228
+ ``resolve_loc_array`` whenever a paired x is available.
1229
+ """
1108
1230
  from ._units import Unit
1109
1231
  if isinstance(val, Unit):
1110
1232
  out = np.empty(len(val), dtype=float)