rgrid-python 4.5.3.post4__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.post4"
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",
@@ -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
grid_py/_primitives.py CHANGED
@@ -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",
grid_py/_renderer_base.py CHANGED
@@ -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)
grid_py/_state.py CHANGED
@@ -16,6 +16,7 @@ from __future__ import annotations
16
16
 
17
17
  import copy
18
18
  from collections import deque
19
+ from types import SimpleNamespace
19
20
  from typing import Any, Deque, Dict, List, Optional, Sequence, Tuple, Union
20
21
 
21
22
  import numpy as np
@@ -50,15 +51,31 @@ def _make_root_viewport() -> Any:
50
51
  dict
51
52
  A mapping that quacks like a viewport for bootstrap purposes.
52
53
  """
53
- return {
54
- "name": "ROOT",
55
- "parent": None,
56
- "children": [],
57
- "layout_pos": None,
58
- "gpar": Gpar(),
59
- "rotation": 0.0,
60
- "transform": np.eye(3, dtype=np.float64),
61
- }
54
+ # Default xscale/yscale = (0, 1). At ``init_device`` time these get
55
+ # overwritten with (0, dev_w_px) / (0, dev_h_px) to mirror R's
56
+ # ``initVP`` (src/viewport.c:399-406):
57
+ # REAL(xscale)[0] = dd->dev->left;
58
+ # REAL(xscale)[1] = dd->dev->right;
59
+ # REAL(yscale)[0] = dd->dev->bottom;
60
+ # REAL(yscale)[1] = dd->dev->top;
61
+ # which is what makes ``unit(..., "native")`` resolve to device pixels
62
+ # at the root viewport.
63
+ #
64
+ # We expose the root as a ``SimpleNamespace`` so callers can use the
65
+ # R-style ``vp.xscale`` / ``vp.yscale`` attribute access (mirroring
66
+ # ``current.viewport()$xscale`` in R) while still keeping the duck-
67
+ # typed dict-style ``_vp_attr`` helper.
68
+ return SimpleNamespace(
69
+ name="ROOT",
70
+ parent=None,
71
+ children=[],
72
+ layout_pos=None,
73
+ gpar=Gpar(),
74
+ rotation=0.0,
75
+ transform=np.eye(3, dtype=np.float64),
76
+ xscale=(0.0, 1.0),
77
+ yscale=(0.0, 1.0),
78
+ )
62
79
 
63
80
 
64
81
  def _vp_attr(vp: Any, attr: str, default: Any = None) -> Any:
@@ -508,10 +525,25 @@ class GridState:
508
525
  float
509
526
  The sum of ``rotation`` attributes from root to current viewport.
510
527
  """
528
+ # R always treats a missing or NULL rotation as 0 (src/viewport.c:
529
+ # initVP / addRotation paths default angle=0). Mirror that here
530
+ # without try/except.
531
+ #
532
+ # Two attribute names coexist in grid_py for historical reasons:
533
+ # ``Viewport`` instances expose the user-supplied angle as
534
+ # ``vp.angle`` (matching R's ``viewport(angle=)`` argument), while
535
+ # the root SimpleNamespace + pushedvp metadata uses ``rotation``.
536
+ # Both designate the same quantity — this viewport's own rotation
537
+ # contribution — so we read whichever is present, preferring
538
+ # ``angle`` for Viewport / R parity. R itself walks the vp stack
539
+ # summing each vp's ``angle`` slot, which is what we mirror.
511
540
  total: float = 0.0
512
541
  vp: Any = self._current_vp
513
542
  while vp is not None:
514
- total += float(_vp_attr(vp, "rotation", 0.0))
543
+ rot = _vp_attr(vp, "angle", None)
544
+ if rot is None:
545
+ rot = _vp_attr(vp, "rotation", 0.0)
546
+ total += float(rot if rot is not None else 0.0)
515
547
  vp = _vp_parent(vp)
516
548
  return total
517
549
 
@@ -686,6 +718,32 @@ class GridState:
686
718
  self._device_width_cm = float(width_cm)
687
719
  self._device_height_cm = float(height_cm)
688
720
 
721
+ # Mirror R src/viewport.c:initVP — set the ROOT viewport's
722
+ # xscale/yscale to the device coord range. The orientation
723
+ # follows the device convention:
724
+ # raster (PNG, CairoImage): pixel coords, y INCREASES DOWN
725
+ # → xscale=(0, w_px), yscale=(h_px, 0)
726
+ # vector (PDF, SVG, PS): point coords, y INCREASES UP
727
+ # → xscale=(0, w_pt), yscale=(0, h_pt)
728
+ # The renderer's ``_surface_type`` ("image" vs anything else)
729
+ # tells us which axis convention to use. This keeps the dict-
730
+ # accessible xscale/yscale in sync with the transform stack's
731
+ # ``vpc`` (set by ``calc_root_transform`` in _vp_calc.py).
732
+ dpi = float(getattr(renderer, "dpi", 0.0) or 0.0)
733
+ if dpi > 0:
734
+ surface_type = getattr(renderer, "_surface_type", "image")
735
+ if surface_type == "image":
736
+ dev_w = self._device_width_cm / 2.54 * dpi
737
+ dev_h = self._device_height_cm / 2.54 * dpi
738
+ _vp_set_attr(self._vp_tree, "xscale", (0.0, dev_w))
739
+ _vp_set_attr(self._vp_tree, "yscale", (dev_h, 0.0))
740
+ else:
741
+ # Vector surface: 72 user units per inch, y-up.
742
+ dev_w = self._device_width_cm / 2.54 * 72.0
743
+ dev_h = self._device_height_cm / 2.54 * 72.0
744
+ _vp_set_attr(self._vp_tree, "xscale", (0.0, dev_w))
745
+ _vp_set_attr(self._vp_tree, "yscale", (0.0, dev_h))
746
+
689
747
  def get_renderer(self) -> Any:
690
748
  """Return the current rendering backend.
691
749
 
grid_py/_vp_calc.py CHANGED
@@ -955,16 +955,58 @@ def calc_viewport_transform(
955
955
  def calc_root_transform(
956
956
  device_width_cm: float,
957
957
  device_height_cm: float,
958
+ dev_units_per_inch: Optional[float] = None,
959
+ y_down: bool = True,
958
960
  ) -> ViewportTransformResult:
959
961
  """Compute the root (device-level) viewport transform.
960
962
 
961
- Port of ``viewport.c:233-260``: when the parent is NULL (top-level
962
- viewport), the parent is the device itself.
963
+ Port of ``viewport.c:initVP`` (src/viewport.c:384-420 in R 4.5.3).
964
+ After the R-level ``grid.top.level.vp()`` constructs a default
965
+ viewport (xscale=c(0,1), yscale=c(0,1)), R immediately overrides
966
+ those scales with the device coordinate range::
967
+
968
+ REAL(xscale)[0] = dd->dev->left;
969
+ REAL(xscale)[1] = dd->dev->right;
970
+ REAL(yscale)[0] = dd->dev->bottom;
971
+ REAL(yscale)[1] = dd->dev->top;
972
+
973
+ For a raster device the range is in pixels; for a vector device it
974
+ is in points (1/72 inch). In grid_py the unit-per-inch is encoded
975
+ by *dev_units_per_inch*: ``dpi`` for raster surfaces, ``72`` for
976
+ vector. When that argument is omitted we fall back to (0, 1) —
977
+ the historical behaviour — purely to avoid breaking callers that
978
+ don't yet thread dpi through; new code should pass it.
979
+
980
+ Parameters
981
+ ----------
982
+ device_width_cm, device_height_cm
983
+ Physical canvas size in centimetres.
984
+ dev_units_per_inch
985
+ Device units per inch (``dpi`` for raster, ``72`` for vector).
986
+ When supplied, ``xscale=(0, dev_w_px)`` and
987
+ ``yscale=(0, dev_h_px)`` so that ``unit(v, "native")`` at the
988
+ root viewport resolves to device pixels exactly as in R.
963
989
  """
990
+ if dev_units_per_inch is not None and dev_units_per_inch > 0:
991
+ dev_w_px = (device_width_cm / 2.54) * dev_units_per_inch
992
+ dev_h_px = (device_height_cm / 2.54) * dev_units_per_inch
993
+ xscale = (0.0, float(dev_w_px))
994
+ # R's PNG / raster devices have ``dev->bottom = h_px`` and
995
+ # ``dev->top = 0`` (pixel y increases downward), so initVP
996
+ # produces ``yscale = (h_px, 0)``. Vector devices (PDF, PS)
997
+ # are bottom-up: ``yscale = (0, h_pt)``. Mirror both.
998
+ if y_down:
999
+ yscale = (float(dev_h_px), 0.0)
1000
+ else:
1001
+ yscale = (0.0, float(dev_h_px))
1002
+ else:
1003
+ xscale = (0.0, 1.0)
1004
+ yscale = (0.0, 1.0)
1005
+
964
1006
  return ViewportTransformResult(
965
1007
  width_cm=device_width_cm,
966
1008
  height_cm=device_height_cm,
967
1009
  rotation_angle=0.0,
968
1010
  transform=identity(),
969
- vpc=ViewportContext(xscale=(0.0, 1.0), yscale=(0.0, 1.0)),
1011
+ vpc=ViewportContext(xscale=xscale, yscale=yscale),
970
1012
  )
grid_py/renderer.py CHANGED
@@ -274,7 +274,11 @@ class CairoRenderer(GridRenderer):
274
274
  from ._vp_calc import calc_root_transform
275
275
  mask_w_in = float(dw) / self.dpi
276
276
  mask_h_in = float(dh) / self.dpi
277
- root_vtr = calc_root_transform(mask_w_in * 2.54, mask_h_in * 2.54)
277
+ # Mask surfaces are raster (Cairo IMAGE) use dpi as device units/in.
278
+ root_vtr = calc_root_transform(
279
+ mask_w_in * 2.54, mask_h_in * 2.54,
280
+ dev_units_per_inch=self.dpi,
281
+ )
278
282
  mask_renderer._vp_transform_stack = [root_vtr]
279
283
  mask_renderer._vp_obj_stack = [None]
280
284
  mask_renderer._layout_stack = []
@@ -353,28 +357,48 @@ class CairoRenderer(GridRenderer):
353
357
 
354
358
  # ---- gpar application --------------------------------------------------
355
359
 
356
- def _lwd_to_user(self, lwd_pt: float) -> float:
357
- """Convert R-grid lwd (points) to a Cairo user-space line width.
358
-
359
- R's grid measures ``lwd`` in 1/72 inch (points) regardless of the
360
- active viewport scale a value of 1 should always produce a stroke
361
- 1pt wide on the output medium. Cairo's ``set_line_width`` takes a
362
- user-space distance, so we have to:
363
-
364
- 1. Map points → device units. Raster ``ImageSurface`` has 1 user
365
- unit = 1 pixel, so device units per point = ``dpi/72``. Vector
366
- surfaces (PDF/SVG/PS) have 1 user unit = 1 pt, so the conversion
367
- factor is 1.0. Equivalently, use ``_dev_units_per_inch / 72``.
368
- 2. Undo any active CTM scaling via ``device_to_user_distance`` so
369
- that nested viewports (which scale the CTM) do not also scale
370
- the stroke.
371
-
372
- This is the analogue of the font-size handling in ``_set_font``.
360
+ # R-grid lwd unit conversion factor.
361
+ # ----------------------------------------------------------------
362
+ # R's gpar() lwd argument is in **1/96 inch** (per ?gpar in R 3.0+).
363
+ # Internally R's Cairo device (src/library/grDevices/src/cairoFns.c)
364
+ # converts lwd to cairo user-space (= points) using
365
+ # cairo_user = lwd * 72.27 / 96
366
+ # — the 72.27 is the TeX big-point per inch constant R inherits
367
+ # from its PostScript ancestry, retained for backward parity with
368
+ # R's vector devices. Reproducing the same constant here keeps
369
+ # stroke widths bit-for-bit identical with R Cairo across raster
370
+ # and vector surfaces.
371
+ _LWD_BIGPOINT_PER_96TH_INCH: float = 72.27 / 96.0
372
+
373
+ def _lwd_to_user(self, lwd: float) -> float:
374
+ """Convert R-grid lwd (units of 1/96 inch) to a Cairo user-space
375
+ line width.
376
+
377
+ Earlier this method treated ``lwd`` as if it were in points
378
+ (1/72 inch). Empirical R-vs-grid_py measurement shows every
379
+ stroke is then 96/72 = 1.333× wider than R's — a port bug
380
+ affecting *all* primitives that stroke (rect / circle /
381
+ polygon / lines / segments / points-pch / arrow). R's actual
382
+ convention is lwd = 1/96 inch, mirrored here via
383
+ ``_LWD_BIGPOINT_PER_96TH_INCH``.
384
+
385
+ Pipeline:
386
+
387
+ 1. Convert ``lwd × (72.27/96)`` (TeX points) to device units —
388
+ raster surfaces use ``dpi/72``; vector surfaces keep the
389
+ value in points.
390
+ 2. Run the result through ``device_to_user_distance`` so any
391
+ CTM scaling (rotated/scaled viewport) does not also scale
392
+ the stroke. This matches R's behaviour: ``lwd`` is a
393
+ device-level width, independent of viewport coordinate
394
+ changes.
373
395
  """
396
+ # 1/96 inch → TeX big-points (the unit R hands to Cairo)
397
+ lwd_bp = lwd * self._LWD_BIGPOINT_PER_96TH_INCH
374
398
  if self._surface_type == "image":
375
- lw_dev = lwd_pt * self.dpi / 72.0
399
+ lw_dev = lwd_bp * self.dpi / 72.0
376
400
  else:
377
- lw_dev = lwd_pt
401
+ lw_dev = lwd_bp
378
402
  try:
379
403
  ux, uy = self._ctx.device_to_user_distance(lw_dev, lw_dev)
380
404
  return max(abs(ux), abs(uy))
@@ -840,10 +864,46 @@ class CairoRenderer(GridRenderer):
840
864
  vjust: float = 0.5,
841
865
  gp: Optional[Gpar] = None,
842
866
  ) -> None:
843
- """Draw a rectangle. x, y, w, h are in device coordinates."""
867
+ """Draw a rectangle.
868
+
869
+ Parameters
870
+ ----------
871
+ x, y : float
872
+ Anchor point in **device coords** (already mapped through
873
+ the viewport CTM, including rotation, by
874
+ ``resolve_loc_array``).
875
+ w, h : float
876
+ Width / height in device units of the viewport-local
877
+ (i.e. unrotated) frame.
878
+ hjust, vjust : float
879
+ Justification of the rect relative to ``(x, y)``.
880
+ gp : Gpar, optional
881
+ Graphical parameters.
882
+
883
+ Implementation note
884
+ -------------------
885
+ ``(x, y)`` is the *rotated* anchor and ``(w, h)`` are the
886
+ *unrotated* dimensions, so we still have to orient the box.
887
+ We rotate the Cairo CTM around the anchor by the cumulative
888
+ viewport rotation; the axis-aligned ``ctx.rectangle`` then
889
+ becomes the rotated rect in device space. This matches R's
890
+ grid C engine which applies the device CTM (including
891
+ rotation) at draw time — see ``src/grid.c``'s ``L_rect``
892
+ + the LTransform machinery in ``viewport.c``.
893
+ """
894
+ from ._state import get_state
895
+
844
896
  ctx = self._ctx
845
897
  ctx.save()
846
898
 
899
+ rotation_deg = float(get_state().current_rotation())
900
+ if abs(rotation_deg) > 1e-9:
901
+ # Cairo's y axis grows downward, R-grid's grows upward —
902
+ # invert sign so a positive R angle visually matches.
903
+ ctx.translate(x, y)
904
+ ctx.rotate(-math.radians(rotation_deg))
905
+ ctx.translate(-x, -y)
906
+
847
907
  # x,y is the anchor point; apply justification to get top-left
848
908
  dx = x - w * hjust
849
909
  dy = y - h * (1.0 - vjust) # device y increases downward
@@ -1366,6 +1426,85 @@ class CairoRenderer(GridRenderer):
1366
1426
 
1367
1427
  ctx.restore()
1368
1428
 
1429
+ def _draw_pch_dot(
1430
+ self,
1431
+ ctx: Any,
1432
+ cx: float,
1433
+ cy: float,
1434
+ r: float,
1435
+ col_rgba: Tuple[float, float, float, float],
1436
+ cex: float = 1.0,
1437
+ ) -> None:
1438
+ """Draw R's ``pch="."`` — a *tiny* filled point.
1439
+
1440
+ R's engine (``GESymbol``) draws ``'.'`` as a small filled
1441
+ **rectangle** whose side is an engine constant (~0.01 inch)
1442
+ scaled by ``cex``, **independent of the symbol size**. Verified
1443
+ against grid 4.5.3: at 72 dpi the dot is ~1px regardless of the
1444
+ points-grob ``size``, growing only with large ``cex``. We
1445
+ mirror that by drawing a small filled square (a circle would
1446
+ antialias away at sub-pixel radius), so it reads as a single
1447
+ dot rather than a full symbol-19 disc or a full-size glyph.
1448
+
1449
+ ``r`` (the resolved symbol radius) is intentionally unused for the
1450
+ dot's *size* — kept in the signature for call-site symmetry.
1451
+ """
1452
+ # ~0.01 inch side at cex=1, in device units. For image surfaces
1453
+ # device units are pixels (dpi-scaled); for vector surfaces they
1454
+ # are points (72 per inch). Floor at 1 device unit so the dot is
1455
+ # always a solidly-visible pixel (matching R, whose '.' never
1456
+ # vanishes), exactly as R's GERect renders a minimum dot.
1457
+ if self._surface_type == "image":
1458
+ side = 0.01 * self.dpi * cex
1459
+ else:
1460
+ side = 0.01 * 72.0 * cex
1461
+ side = max(side, 1.0)
1462
+ half = side / 2.0
1463
+ if col_rgba[3] > 0:
1464
+ ctx.save()
1465
+ ctx.new_path()
1466
+ ctx.rectangle(cx - half, cy - half, side, side)
1467
+ ctx.set_source_rgba(*col_rgba)
1468
+ ctx.fill()
1469
+ ctx.restore()
1470
+
1471
+ def _draw_pch_glyph(
1472
+ self,
1473
+ ctx: Any,
1474
+ ch: str,
1475
+ cx: float,
1476
+ cy: float,
1477
+ r: float,
1478
+ col_rgba: Tuple[float, float, float, float],
1479
+ gp: Optional[Gpar] = None,
1480
+ ) -> None:
1481
+ """Draw a single-character pch (e.g. ``"A"``) as a glyph.
1482
+
1483
+ R's engine renders a non-``'.'`` character pch as that glyph,
1484
+ horizontally and vertically centred on the point, sized by the
1485
+ font (``cex * fontsize``). We reuse the renderer's font setup
1486
+ and centre the glyph on ``(cx, cy)`` via its ink extents — the
1487
+ same centring R uses for symbol text.
1488
+ """
1489
+ if not ch or col_rgba[3] <= 0:
1490
+ return
1491
+ ctx.save()
1492
+ self._set_font(gp)
1493
+ ext = ctx.text_extents(ch)
1494
+ # Centre the glyph's ink box on (cx, cy). text_extents gives
1495
+ # x_bearing/y_bearing relative to the pen origin; centring the
1496
+ # ink box requires offsetting by bearing + half-extent.
1497
+ off_x = -(ext.x_bearing + ext.width / 2.0)
1498
+ off_y = -(ext.y_bearing + ext.height / 2.0)
1499
+ ctx.set_source_rgba(*col_rgba)
1500
+ ctx.move_to(cx + off_x, cy + off_y)
1501
+ if self._path_collecting:
1502
+ ctx.text_path(ch)
1503
+ ctx.fill()
1504
+ else:
1505
+ ctx.show_text(ch)
1506
+ ctx.restore()
1507
+
1369
1508
  def draw_points(
1370
1509
  self,
1371
1510
  x: np.ndarray,
@@ -1397,24 +1536,61 @@ class CairoRenderer(GridRenderer):
1397
1536
  return
1398
1537
 
1399
1538
  # --- per-point pch array ---
1539
+ # ``pch`` may be a numeric symbol code (0-25), a single
1540
+ # character (a glyph such as "A", or "." for R's tiny point),
1541
+ # or a MIXED per-point sequence (e.g. [".", 19, "A"]). R keeps
1542
+ # character pch as character and coerces numerics to int
1543
+ # (grid:::valid.pch), so the value reaching us is already
1544
+ # normalised — we must NOT force ``dtype=int`` (it crashes on
1545
+ # "."), only preserve each element as int-or-str in an object
1546
+ # array and dispatch per point below.
1400
1547
  if isinstance(pch, (list, tuple, np.ndarray)):
1401
- pch_arr = np.asarray(pch, dtype=int)
1548
+ pch_arr = np.atleast_1d(np.asarray(pch, dtype=object))
1402
1549
  else:
1403
- pch_arr = np.full(n, int(pch), dtype=int)
1550
+ pch_arr = np.array([pch], dtype=object)
1551
+ # Coerce numeric-looking scalars to int once, leave strings as-is.
1552
+ pch_arr = np.array(
1553
+ [int(p) if isinstance(p, (int, float, np.integer, np.floating))
1554
+ else p for p in pch_arr],
1555
+ dtype=object,
1556
+ )
1404
1557
  if len(pch_arr) < n:
1405
1558
  pch_arr = np.resize(pch_arr, n)
1406
1559
 
1407
- # --- per-point sizes from gpar.fontsize (R: cex * fontsize) ---
1560
+ # --- per-point sizes ---------------------------------------
1561
+ # Two paths, distinguished by where the size originated:
1562
+ #
1563
+ # (1) ``gp.fontsize`` is set → ``size_arr`` is in points
1564
+ # (R: cex * fontsize), so we convert pt → device pixels
1565
+ # via ``dpi/72`` and halve to get a radius.
1566
+ #
1567
+ # (2) ``gp.fontsize`` is absent → ``size`` is what _draw.py
1568
+ # resolved from ``grob.size`` via ``renderer.resolve_w``,
1569
+ # which already returns DEVICE PIXELS. In that case the
1570
+ # previous code re-applied the dpi/72 scaling, producing
1571
+ # symbols ~``dpi/72``× too large (e.g. at 150 dpi the
1572
+ # diameter came out ~2× R). Mirror R's points.c which
1573
+ # uses the size unit-resolved value directly as the
1574
+ # symbol diameter, so radius = size / 2.
1575
+ size_from_fontsize: bool
1408
1576
  fs = gp.get("fontsize", None) if gp else None
1409
1577
  if isinstance(fs, (list, tuple, np.ndarray)):
1410
1578
  size_arr = np.asarray(fs, dtype=float)
1579
+ size_from_fontsize = True
1411
1580
  elif fs is not None:
1412
1581
  size_arr = np.full(n, float(fs))
1582
+ size_from_fontsize = True
1413
1583
  else:
1414
1584
  size_arr = np.full(n, float(size))
1585
+ size_from_fontsize = False
1415
1586
 
1416
- # Radius conversion: fontsize (pt) → device pixels
1417
- scale = self.dpi / 72.0 * 0.5 if self._surface_type == "image" else 0.5
1587
+ if size_from_fontsize:
1588
+ # fontsize is in points convert to device-pixel radius.
1589
+ scale = (self.dpi / 72.0 * 0.5
1590
+ if self._surface_type == "image" else 0.5)
1591
+ else:
1592
+ # ``size`` already in device pixels (diameter) → radius=size/2.
1593
+ scale = 0.5
1418
1594
 
1419
1595
  # --- per-point colours (col) ---
1420
1596
  col_raw = gp.get("col", None) if gp else None
@@ -1445,17 +1621,49 @@ class CairoRenderer(GridRenderer):
1445
1621
  else:
1446
1622
  lwd_arr = np.full(n, 1.0)
1447
1623
 
1624
+ # --- per-point cex (used only for the pch="." dot size) ---
1625
+ cex_raw = gp.get("cex", None) if gp else None
1626
+ if isinstance(cex_raw, (list, tuple, np.ndarray)):
1627
+ cex_arr = np.asarray(cex_raw, dtype=float)
1628
+ elif cex_raw is not None:
1629
+ cex_arr = np.full(n, float(cex_raw))
1630
+ else:
1631
+ cex_arr = np.full(n, 1.0)
1632
+
1448
1633
  for i in range(n):
1449
1634
  cx = x[i]
1450
1635
  cy = y[i]
1451
1636
  r = size_arr[i] * scale if i < len(size_arr) else size * scale
1452
1637
  lwd_i = float(lwd_arr[i % len(lwd_arr)])
1453
- self._draw_pch_shape(
1454
- ctx, int(pch_arr[i]), cx, cy, r,
1455
- col_rgba=col_list[i],
1456
- fill_rgba=fill_list[i],
1457
- lwd=lwd_i,
1458
- )
1638
+ pch_i = pch_arr[i]
1639
+ if isinstance(pch_i, (str, bytes, np.str_)):
1640
+ # Character pch. R's engine takes only the FIRST
1641
+ # character of the string for a per-point glyph.
1642
+ ch = str(pch_i)
1643
+ if ch == ".":
1644
+ # R's pch="." draws a tiny filled point (a small
1645
+ # engine-constant rect, scaled by cex). Render a
1646
+ # tiny filled circle so it reads as a single pixel-
1647
+ # scale dot rather than the symbol-19 disc or a
1648
+ # full-size glyph. ``r`` is the symbol radius; the
1649
+ # dot is a small fraction of it (matching R's "."
1650
+ # which is visually ~1px at default cex).
1651
+ self._draw_pch_dot(
1652
+ ctx, cx, cy, r, col_rgba=col_list[i],
1653
+ cex=float(cex_arr[i % len(cex_arr)]),
1654
+ )
1655
+ else:
1656
+ self._draw_pch_glyph(
1657
+ ctx, ch[:1], cx, cy, r,
1658
+ col_rgba=col_list[i], gp=gp,
1659
+ )
1660
+ else:
1661
+ self._draw_pch_shape(
1662
+ ctx, int(pch_i), cx, cy, r,
1663
+ col_rgba=col_list[i],
1664
+ fill_rgba=fill_list[i],
1665
+ lwd=lwd_i,
1666
+ )
1459
1667
 
1460
1668
  ctx.restore()
1461
1669
 
@@ -1476,6 +1684,18 @@ class CairoRenderer(GridRenderer):
1476
1684
  # Handle colour string arrays (e.g. from colourbar raster)
1477
1685
  # Convert colour strings to uint8 RGBA
1478
1686
  if img_array.dtype.kind in ("U", "S", "O"):
1687
+ # A colour-string raster must be 2-D (rows × cols of colours).
1688
+ # A 1-D vector here means an upstream bug fed a flat colour
1689
+ # vector instead of a matrix. Fail loud rather than silently
1690
+ # coercing it to a 1-row strip (that would mask the caller's
1691
+ # error and produce wrong output).
1692
+ if img_array.ndim != 2:
1693
+ ctx.restore()
1694
+ raise ValueError(
1695
+ "raster image must be 2-D (got shape "
1696
+ f"{img_array.shape}); a colour-string raster needs "
1697
+ "rows × columns of colours, not a flat vector"
1698
+ )
1479
1699
  h_img, w_img = img_array.shape[:2]
1480
1700
  rgba = np.zeros((h_img, w_img, 4), dtype=np.uint8)
1481
1701
  for r in range(h_img):
@@ -1554,10 +1774,21 @@ class CairoRenderer(GridRenderer):
1554
1774
  vjust: float = 0.5,
1555
1775
  gp: Optional[Gpar] = None,
1556
1776
  ) -> None:
1557
- """Draw a rounded rectangle. All coords in device units."""
1777
+ """Draw a rounded rectangle. Anchor in device units; (w, h)
1778
+ in the viewport-local (unrotated) frame. CTM rotation applied
1779
+ around the anchor for the same reason as ``draw_rect``.
1780
+ """
1781
+ from ._state import get_state
1782
+
1558
1783
  ctx = self._ctx
1559
1784
  ctx.save()
1560
1785
 
1786
+ rotation_deg = float(get_state().current_rotation())
1787
+ if abs(rotation_deg) > 1e-9:
1788
+ ctx.translate(x, y)
1789
+ ctx.rotate(-math.radians(rotation_deg))
1790
+ ctx.translate(-x, -y)
1791
+
1561
1792
  dx = x - w * hjust
1562
1793
  dy = y - h * (1.0 - vjust)
1563
1794
  dr = min(r, w / 2, h / 2)
grid_py/renderer_web.py CHANGED
@@ -759,7 +759,13 @@ class WebRenderer(GridRenderer):
759
759
  self._node_stack = [self._scene_root]
760
760
  # Re-init base class viewport stack
761
761
  from ._vp_calc import calc_root_transform
762
- root_vtr = calc_root_transform(self.width_in * 2.54, self.height_in * 2.54)
762
+ # Web renderer emits SVG device units are CSS px = 1/96 inch.
763
+ # Use that so unit("native", ...) at root matches R's pixel-based
764
+ # convention for raster-like surfaces.
765
+ root_vtr = calc_root_transform(
766
+ self.width_in * 2.54, self.height_in * 2.54,
767
+ dev_units_per_inch=96.0,
768
+ )
763
769
  self._vp_transform_stack = [root_vtr]
764
770
  self._vp_obj_stack = [None]
765
771
  self._layout_stack = []
@@ -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
@@ -1,11 +1,11 @@
1
- grid_py/__init__.py,sha256=K_SAUQUXVz-7mYQEYPWR9_5aWLPJjUdhu1nVSunpk_I,10520
1
+ grid_py/__init__.py,sha256=CE6FeESr4RXg2kI4HbTA_d7vxOBwkcjxyMQ8kpz1J8Q,10520
2
2
  grid_py/_arrow.py,sha256=pgn4OCgF6TZY2yXl7ML-kt08DbwqlvT1ulApDcmRz4k,10867
3
3
  grid_py/_clippath.py,sha256=p6fUAkcEc5Bg8chv-lIZwaOjEUbLK57duwjtIpDjAkw,4015
4
- grid_py/_colour.py,sha256=WqaxGop-SM1LYZG4uMPmMX3Zjh6Y_ip0HcanP5wEij8,22872
4
+ grid_py/_colour.py,sha256=JOfUC5GPHIccVfmGwmK5DJEb-klPIbr9HzKra30K9UM,23353
5
5
  grid_py/_coords.py,sha256=9cDD3MWHmw9TnT1I8QKHQA96SCG06OxRXNqYGFZsfwg,47210
6
6
  grid_py/_curve.py,sha256=QnUkh9lWLd0OSm7h7QwS78JjzBAu9WHmRY4ZpZhccpY,54989
7
7
  grid_py/_display_list.py,sha256=uMFAupSJaCgUCbLMv2-RDBQ-YLxCkAldypRqc7CnF3w,13622
8
- grid_py/_draw.py,sha256=YR2JYfOspg3MRb-vR47ebhBJq3pKJZ7QMarkcVNHkNA,51150
8
+ grid_py/_draw.py,sha256=2vYdD9DqOUPtMtLazCN-n4jLlE3hZYDbTUNLRMfzUkY,55218
9
9
  grid_py/_edit.py,sha256=vQDZGBTTYy6DmlW33l94s1KJLrzPNym21NR4Od4qIFw,22263
10
10
  grid_py/_font_metrics.py,sha256=XC_dgIN3eB72VPXIRFU-tynnS3wJl3bPugGHrwVzyeo,11086
11
11
  grid_py/_gpar.py,sha256=Bsq5oevTOf-ISb6T1K4cqPcA7UKX0XHbg-AddMomWzQ,21302
@@ -20,24 +20,24 @@ grid_py/_lty.py,sha256=ciQKy_P_QmJbmYTsSeQY8cGxXvnrowJbBS-Zzf4birE,7762
20
20
  grid_py/_mask.py,sha256=d2Wm2z-RJnfEKHdAHcjteL7VubimcaK1_f9Us9MfrUk,4902
21
21
  grid_py/_path.py,sha256=Tr5bNNcGwPpOsxWKqEp65MRri-KV8IqplUh2Z90sxnk,11421
22
22
  grid_py/_patterns.py,sha256=t6IkayYPaupEbF9bFZQmS0iXZhB9TWrvN2xRZzR7baA,33302
23
- grid_py/_primitives.py,sha256=Aq0fCdsy-eJ57v_X9JeFlsmm62jEZMlEOuK3u3v6YEg,59522
24
- grid_py/_renderer_base.py,sha256=wECYIgNR2UNXXneWoaJspbBkclBTxTbM3in5Hva7nNY,59011
23
+ grid_py/_primitives.py,sha256=YptgRYJxTbXgycUH21Ek_ZD3sK9x_Ys46KRdDPNpoBc,62302
24
+ grid_py/_renderer_base.py,sha256=SU-Hl0pxmnzm9DasodDiTA0pQQkkWUlq3Z3mtiWU4TQ,64497
25
25
  grid_py/_scene_graph.py,sha256=mKhNEMkUolbWt4_CFgGhrGUudrYenxFNXaBp6GLN9_Y,7412
26
26
  grid_py/_size.py,sha256=q9yYdhO7dcp-RtQzJIIsXssFH5oFbsm5_P6UxZlFKjg,43312
27
- grid_py/_state.py,sha256=SsEzcicTmxWaGExDeLLNNVXT-G38aUFtgSuZTJEYBbw,22800
27
+ grid_py/_state.py,sha256=3JWGYOAUjmuqsYGG93v7vcX4ZM3k1uXOepxlAkb690w,25978
28
28
  grid_py/_transforms.py,sha256=mQvs_Qm6icti66peLXTsjgmSmvIUCCphf_-D-tiE1O4,11675
29
29
  grid_py/_typeset.py,sha256=J_PJ5YcOAOnnFkBfo8IW86utJdkdsrnGZi3MvnHc8io,11516
30
30
  grid_py/_units.py,sha256=bGrHV23Vr2A6lO15LJvcP7Qs1Yx_uQJLmyQ6bRB3yjc,61893
31
31
  grid_py/_utils.py,sha256=QaWNkCF3BbPHyrqPPRxN7Ji2vB5ethNwVT3SJ1ad9Lc,8335
32
32
  grid_py/_viewport.py,sha256=qscv8eRKt0vHH0osxZUkbwM9PfgZwTKXi8n7L8921DE,49490
33
- grid_py/_vp_calc.py,sha256=v0MJc-YmWohO5t7LHFopk5ogH-Sink_A_zNFV_1769w,32935
33
+ grid_py/_vp_calc.py,sha256=zW_nmO5KKdy2oW2H6tk34G4JAnEtmBsBkZr0fESIIUg,34805
34
34
  grid_py/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
35
- grid_py/renderer.py,sha256=DlzC7srfqZ0JRpEUddjwX39Kw4PUg9-FDH57-sI1RcQ,65093
36
- grid_py/renderer_web.py,sha256=ntbLwcvTJNz_S0xRNGweiZmrkThUfW1AvCCCDSn2vSY,29461
35
+ grid_py/renderer.py,sha256=-A4SsBtJW4hds2caG_t_ewnsVmx_WdKoUs0cRTvCUJQ,75269
36
+ grid_py/renderer_web.py,sha256=hgABfks1K6oWyNDN5wPpxKP4HpPsYCXxagG_oO1QWpY,29716
37
37
  grid_py/resources/d3.v7.min.js,sha256=8glLv2FBs1lyLE_kVOtsSw8OQswQzHr5IfwVj864ZTk,279706
38
38
  grid_py/resources/gridpy.css,sha256=tR5LF2rLvi_bGRWuH9CnmLQk-aG2f-jlZjQZqS7_4uY,1351
39
39
  grid_py/resources/gridpy.js,sha256=ekbmsIMCJU9ZqIU0WC1c-Y-LBEinGIL8UftFEVF9pC4,28555
40
- rgrid_python-4.5.3.post4.dist-info/METADATA,sha256=3sNILdeJwNGhe9zFPrssIt90C_csOUrrrd7Ad6Uw3IY,19910
41
- rgrid_python-4.5.3.post4.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
42
- rgrid_python-4.5.3.post4.dist-info/licenses/LICENSE,sha256=8zNQKZlkc4JOaW7br_a8aALg4baZwZkLKLTQx3kuHkc,48
43
- rgrid_python-4.5.3.post4.dist-info/RECORD,,
40
+ rgrid_python-4.5.3.post5.dist-info/METADATA,sha256=sNw4AKkaHb203t8SaZJvY_qcRcD1WCvF5fciDQJbhhw,19910
41
+ rgrid_python-4.5.3.post5.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
42
+ rgrid_python-4.5.3.post5.dist-info/licenses/LICENSE,sha256=8zNQKZlkc4JOaW7br_a8aALg4baZwZkLKLTQx3kuHkc,48
43
+ rgrid_python-4.5.3.post5.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.29.0
2
+ Generator: hatchling 1.30.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any