rgrid-python 4.5.3__tar.gz → 4.5.3.post2__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 (44) hide show
  1. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/PKG-INFO +3 -1
  2. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/README.md +2 -0
  3. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/__init__.py +5 -3
  4. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/_draw.py +31 -5
  5. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/_gpar.py +30 -3
  6. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/_layout.py +9 -2
  7. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/_renderer_base.py +119 -10
  8. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/_size.py +36 -10
  9. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/_state.py +42 -0
  10. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/_units.py +90 -11
  11. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/renderer.py +8 -6
  12. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/pyproject.toml +1 -1
  13. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/.gitattributes +0 -0
  14. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/.gitignore +0 -0
  15. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/LICENSE +0 -0
  16. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/_arrow.py +0 -0
  17. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/_clippath.py +0 -0
  18. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/_colour.py +0 -0
  19. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/_coords.py +0 -0
  20. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/_curve.py +0 -0
  21. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/_display_list.py +0 -0
  22. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/_edit.py +0 -0
  23. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/_font_metrics.py +0 -0
  24. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/_grab.py +0 -0
  25. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/_grob.py +0 -0
  26. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/_group.py +0 -0
  27. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/_highlevel.py +0 -0
  28. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/_just.py +0 -0
  29. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/_ls.py +0 -0
  30. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/_mask.py +0 -0
  31. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/_path.py +0 -0
  32. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/_patterns.py +0 -0
  33. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/_primitives.py +0 -0
  34. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/_scene_graph.py +0 -0
  35. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/_transforms.py +0 -0
  36. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/_typeset.py +0 -0
  37. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/_utils.py +0 -0
  38. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/_viewport.py +0 -0
  39. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/_vp_calc.py +0 -0
  40. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/py.typed +0 -0
  41. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/renderer_web.py +0 -0
  42. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/resources/d3.v7.min.js +0 -0
  43. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/grid_py/resources/gridpy.css +0 -0
  44. {rgrid_python-4.5.3 → rgrid_python-4.5.3.post2}/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
3
+ Version: 4.5.3.post2
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
@@ -41,6 +41,8 @@ Description-Content-Type: text/markdown
41
41
 
42
42
  # grid_py
43
43
 
44
+ [![PyPI](https://img.shields.io/pypi/v/rgrid-python)](https://pypi.org/project/rgrid-python/)
45
+
44
46
  Python port of the R **grid** package.
45
47
 
46
48
  ## Installation
@@ -1,5 +1,7 @@
1
1
  # grid_py
2
2
 
3
+ [![PyPI](https://img.shields.io/pypi/v/rgrid-python)](https://pypi.org/project/rgrid-python/)
4
+
3
5
  Python port of the R **grid** package.
4
6
 
5
7
  ## Installation
@@ -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"
9
+ __version__ = "4.5.3.post2"
10
10
 
11
11
  # --- Utilities ---
12
12
  from grid_py._utils import depth, explode, grid_pretty, n2mfrow
@@ -18,13 +18,14 @@ from grid_py._just import valid_just, resolve_hjust, resolve_vjust, resolve_rast
18
18
  from grid_py._units import (
19
19
  Unit, is_unit, unit_type, unit_c, unit_length,
20
20
  unit_pmax, unit_pmin, unit_psum, unit_rep,
21
+ unit_summary_sum, unit_summary_min, unit_summary_max,
21
22
  string_width, string_height, string_ascent, string_descent,
22
23
  absolute_size,
23
24
  convert_unit, convert_x, convert_y, convert_width, convert_height,
24
25
  )
25
26
 
26
27
  # --- Graphical Parameters ---
27
- from grid_py._gpar import Gpar, get_gpar
28
+ from grid_py._gpar import Gpar, get_gpar, gpar
28
29
 
29
30
  # --- Arrow ---
30
31
  from grid_py._arrow import Arrow, arrow
@@ -226,11 +227,12 @@ __all__ = [
226
227
  # Units
227
228
  "Unit", "is_unit", "unit_type", "unit_c", "unit_length",
228
229
  "unit_pmax", "unit_pmin", "unit_psum", "unit_rep",
230
+ "unit_summary_sum", "unit_summary_min", "unit_summary_max",
229
231
  "string_width", "string_height", "string_ascent", "string_descent",
230
232
  "absolute_size",
231
233
  "convert_unit", "convert_x", "convert_y", "convert_width", "convert_height",
232
234
  # Gpar
233
- "Gpar", "get_gpar",
235
+ "Gpar", "gpar", "get_gpar",
234
236
  # Arrow
235
237
  "Arrow", "arrow",
236
238
  # Path
@@ -109,6 +109,12 @@ def _subset_gpar(gp: Optional[Gpar], i: int) -> Optional[Gpar]:
109
109
  picked = val[i % len(val)]
110
110
  elif isinstance(val, (list, tuple)) and len(val) > 1:
111
111
  picked = val[i % len(val)]
112
+ elif isinstance(val, (list, tuple)) and len(val) == 1 \
113
+ and val[0] is None and key in ("col", "fill"):
114
+ # Length-1 [None] NA sentinel from Gpar(col=None)/Gpar(fill=None).
115
+ # Preserve the NA intent across subset — emit "transparent" so
116
+ # the renderer's scalar-colour path parses to (0,0,0,0).
117
+ picked = "transparent"
112
118
  else:
113
119
  picked = val
114
120
 
@@ -684,24 +690,44 @@ def _pop_grob_vp(vp: Any) -> None:
684
690
  def _vp_depth(vp: Any) -> int:
685
691
  """Return the depth of a viewport (number of levels it adds).
686
692
 
693
+ Mirrors R's ``depth()`` generic (grid R/grid.R) for every viewport
694
+ container: VpStack pushes ``sum(depth(child))`` levels, VpList
695
+ pushes ``depth(last)``, VpTree pushes ``depth(parent) + depth(last
696
+ child)``, plain Viewport pushes 1, VpPath pushes ``n``.
697
+
698
+ Recognised types are routed through :func:`._viewport.depth` (the
699
+ canonical R-faithful dispatch); this is what guarantees
700
+ ``_pop_grob_vp(grob.vp)`` pops the right count when a Gtable's
701
+ ``make_context`` returns ``VpStack(orig_vp, layout_vp)``. The
702
+ prior implementation only checked ``hasattr(vp, "depth")`` —
703
+ VpStack/VpList don't expose a method, so it fell through to
704
+ ``return 1`` and left every nested layout vp on the layout_stack
705
+ after the gtable rendered, breaking every grob drawn after.
706
+
707
+ Duck-typed objects with a ``.depth()`` method (used by tests and
708
+ user-defined viewport-like objects) are still honoured as a
709
+ fallback for unknown types.
710
+
687
711
  Parameters
688
712
  ----------
689
713
  vp : Any
690
- A viewport, VpPath, VpStack, VpList, or VpTree.
714
+ A viewport, VpPath, VpStack, VpList, VpTree, or any
715
+ depth-bearing duck-typed object.
691
716
 
692
717
  Returns
693
718
  -------
694
719
  int
695
720
  The depth.
696
721
  """
722
+ from ._viewport import (
723
+ Viewport, VpList, VpStack, VpTree, depth as _depth,
724
+ )
697
725
  from ._path import VpPath
698
726
 
699
- if isinstance(vp, VpPath):
700
- # VpPath stores the number of path components
701
- return getattr(vp, "n", 1)
727
+ if isinstance(vp, (Viewport, VpList, VpStack, VpTree, VpPath)):
728
+ return _depth(vp)
702
729
  if hasattr(vp, "depth"):
703
730
  return vp.depth()
704
- # Default single viewport depth
705
731
  return 1
706
732
 
707
733
 
@@ -14,7 +14,7 @@ from typing import Any, Dict, List, Optional, Sequence, Union
14
14
 
15
15
  import numpy as np
16
16
 
17
- __all__ = ["Gpar", "get_gpar"]
17
+ __all__ = ["Gpar", "gpar", "get_gpar"]
18
18
 
19
19
  # ---------------------------------------------------------------------------
20
20
  # Constants
@@ -199,6 +199,14 @@ class Gpar:
199
199
  # -- process remaining parameters ----------------------------------
200
200
  for name, value in kwargs.items():
201
201
  if value is None:
202
+ # Colour parameters: preserve explicit None as a one-element
203
+ # NA sentinel so the renderer can tell "col absent (inherit,
204
+ # default black)" apart from "col explicitly NA (transparent)".
205
+ # Mirrors R's ``gpar(col=NA)`` / ``gpar(fill=NA)`` semantics
206
+ # (see R grid src/gpar.c gpCol(): isNull(col) → R_TRANWHITE).
207
+ # Other parameters have no NA semantic; drop silently.
208
+ if name in ("col", "fill"):
209
+ params[name] = [None]
202
210
  continue
203
211
 
204
212
  vals = _as_list(value)
@@ -283,8 +291,16 @@ class Gpar:
283
291
  # backend, matching R's behaviour).
284
292
  pass
285
293
 
286
- # Store single-element lists as scalars for cleaner repr.
287
- params[name] = vals[0] if len(vals) == 1 else vals
294
+ # Store single-element lists as scalars for cleaner repr,
295
+ # except for the colour NA sentinel [None] which must stay a
296
+ # sequence so the renderer treats it as R's gpar(col=NA).
297
+ if len(vals) == 1:
298
+ if name in ("col", "fill") and vals[0] is None:
299
+ params[name] = [None]
300
+ else:
301
+ params[name] = vals[0]
302
+ else:
303
+ params[name] = vals
288
304
 
289
305
  self._params = params
290
306
 
@@ -570,3 +586,14 @@ def get_gpar(names: Optional[Sequence[str]] = None) -> Gpar:
570
586
  gp = object.__new__(Gpar)
571
587
  gp._params = subset
572
588
  return gp
589
+
590
+
591
+ def gpar(**kwargs: Any) -> Gpar:
592
+ """Factory mirroring R ``grid::gpar(...)``.
593
+
594
+ R's ``gpar`` function constructs a ``gpar`` object from arbitrary
595
+ keyword arguments. ``Gpar(**kwargs)`` does the same, so this is a
596
+ thin alias kept for direct R-to-Python translation of code that
597
+ reads ``gpar(col="red")``.
598
+ """
599
+ return Gpar(**kwargs)
@@ -400,16 +400,23 @@ def _calc_layout_sizes(
400
400
  reduced_h = max(reduced_h, 0.0)
401
401
 
402
402
  # ---- Phase 2: allocate respected null units (layout.c:allocateRespected)
403
+ # R sums ALL relative widths/heights via totalWidth/totalHeight
404
+ # (layout.c:154-194) — not just the respected ones — when computing
405
+ # the aspect-ratio normalisation denominator. This is what lets a
406
+ # respect matrix that marks a single cell coexist with other null
407
+ # cells in the same dimension; restricting the sum to respected
408
+ # cells (the prior Python implementation) over-allocated the
409
+ # respected slot and collapsed everything else to 0.
403
410
  if layout._valid_respect > 0 and (reduced_w > 0 or reduced_h > 0):
404
411
  sum_w = sum(
405
412
  float(widths._values[i])
406
413
  for i in range(ncol)
407
- if relative_w[i] and _col_respected(i, layout)
414
+ if relative_w[i]
408
415
  )
409
416
  sum_h = sum(
410
417
  float(heights._values[j])
411
418
  for j in range(nrow)
412
- if relative_h[j] and _row_respected(j, layout)
419
+ if relative_h[j]
413
420
  )
414
421
 
415
422
  temp_w = reduced_w
@@ -249,9 +249,30 @@ class GridRenderer(ABC):
249
249
  # Cell position in parent's NPC then inches
250
250
  parent_w_dev = parent_vtr.width_cm / 2.54 * self._dev_units_per_inch
251
251
  parent_h_dev = parent_vtr.height_cm / 2.54 * self._dev_units_per_inch
252
- cell_x_in = cell_x0_dev / self._dev_units_per_inch
253
- # Device y is top-down; convert to bottom-up inches
254
- cell_y_in = parent_h_in - (cell_y0_dev + cell_h_dev) / self._dev_units_per_inch
252
+
253
+ # Apply layout-level hjust / vjust (R: layout.c::subRegion
254
+ # lines 447-450). When the cells' total dimensions don't
255
+ # fill the parent vp (common for chrome-merge slices in
256
+ # patchwork), the leftover space is distributed per the
257
+ # layout's justification (default 0.5 → centred). The
258
+ # prior implementation always pinned cells to top-left
259
+ # (vjust=1, hjust=0), which placed merged chrome at the
260
+ # top of the parent panel cell rather than just outside
261
+ # it, breaking simplify_fixed renders.
262
+ hjust = float(grid.get("hjust", 0.0))
263
+ vjust = float(grid.get("vjust", 1.0))
264
+ total_w_dev = sum(col_widths)
265
+ total_h_dev = sum(row_heights)
266
+ hjust_offset_dev = hjust * (parent_w_dev - total_w_dev)
267
+ vjust_offset_dev = (1.0 - vjust) * (parent_h_dev - total_h_dev)
268
+
269
+ cell_x_in = (cell_x0_dev + hjust_offset_dev) / self._dev_units_per_inch
270
+ # Device y is top-down; convert to bottom-up inches.
271
+ # vjust_offset_dev shifts the block downward in device-y
272
+ # (i.e. upward in bottom-up coords) by the leftover * (1-vjust).
273
+ cell_y_in = parent_h_in - (
274
+ cell_y0_dev + cell_h_dev + vjust_offset_dev
275
+ ) / self._dev_units_per_inch
255
276
 
256
277
  # Build a simple translation transform for the cell
257
278
  from ._vp_calc import translation, multiply
@@ -280,9 +301,12 @@ class GridRenderer(ABC):
280
301
  if layout is not None:
281
302
  w_dev = cell_w_dev
282
303
  h_dev = cell_h_dev
283
- respect = getattr(layout, "respect", False)
284
- grid_info = self._compute_grid(
285
- layout, w_dev, h_dev, respect=bool(respect))
304
+ # R: ``calcViewportLayout`` (layout.c:492-590) reads
305
+ # ``layoutRespect(layout)`` / ``layoutRespectMat(layout)``
306
+ # straight off the layout object there is no caller-side
307
+ # respect argument. Mirror that: ``_calc_layout_sizes``
308
+ # consumes ``layout._valid_respect`` directly.
309
+ grid_info = self._compute_grid(layout, w_dev, h_dev)
286
310
  self._layout_stack.append(grid_info)
287
311
  self._layout_depth_stack.append(
288
312
  len(self._vp_transform_stack))
@@ -294,8 +318,9 @@ class GridRenderer(ABC):
294
318
  # Compute grid in device units for layout children.
295
319
  w_dev = parent_vtr.width_cm / 2.54 * self._dev_units_per_inch
296
320
  h_dev = parent_vtr.height_cm / 2.54 * self._dev_units_per_inch
297
- respect = getattr(layout, "respect", False)
298
- grid_info = self._compute_grid(layout, w_dev, h_dev, respect=bool(respect))
321
+ # See R layout.c:492-590 respect lives on the layout object;
322
+ # callers don't pass it down.
323
+ grid_info = self._compute_grid(layout, w_dev, h_dev)
299
324
 
300
325
  # The layout viewport itself has the same transform as parent
301
326
  # but we create a new VTR with the vp's xscale/yscale
@@ -388,9 +413,15 @@ class GridRenderer(ABC):
388
413
 
389
414
  def _compute_grid(
390
415
  self, layout: Any, parent_w: float, parent_h: float,
391
- respect: bool = False,
392
416
  ) -> dict:
393
- """Compute row/column positions for a GridLayout within the parent."""
417
+ """Compute row/column positions for a GridLayout within the parent.
418
+
419
+ R reference: ``layout.c:calcViewportLayout`` (lines 492-590).
420
+ Respect (full or matrix-form) lives on the layout object itself —
421
+ ``_calc_layout_sizes`` reads ``layout._valid_respect`` directly,
422
+ so this signature has no ``respect`` argument (matches R, where
423
+ there is no caller-side respect parameter either).
424
+ """
394
425
  from ._layout import _calc_layout_sizes, GridLayout
395
426
 
396
427
  if isinstance(layout, GridLayout):
@@ -412,9 +443,24 @@ class GridRenderer(ABC):
412
443
  col_starts = [sum(col_widths[:i]) for i in range(ncol)]
413
444
  row_starts = [sum(row_heights[:i]) for i in range(nrow)]
414
445
 
446
+ # Layout justification (R: layout.c::subRegion lines 433-450).
447
+ # When the cells' total size is less than the parent vp, hjust /
448
+ # vjust shift the layout block within the parent (default 0.5 →
449
+ # centred). ``GridLayout`` carries this on ``_valid_just`` as
450
+ # (hjust, vjust); fall back to (0, 1) for non-GridLayout
451
+ # containers (matching the prior top-left alignment).
452
+ valid_just = getattr(layout, "_valid_just", None)
453
+ if valid_just is not None:
454
+ hjust = float(valid_just[0])
455
+ vjust = float(valid_just[1])
456
+ else:
457
+ hjust, vjust = 0.0, 1.0
458
+
415
459
  return {
416
460
  "col_starts": col_starts, "col_widths": col_widths,
417
461
  "row_starts": row_starts, "row_heights": row_heights,
462
+ "parent_w": parent_w, "parent_h": parent_h,
463
+ "hjust": hjust, "vjust": vjust,
418
464
  }
419
465
 
420
466
  def _resolve_sizes(self, unit_obj: Any, n: int, total: float,
@@ -492,6 +538,28 @@ class GridRenderer(ABC):
492
538
  # evaluateGrobUnit -- port of R unit.c:325-590 #
493
539
  # ===================================================================== #
494
540
 
541
+ # Per-renderer cycle guard — populated with id(grob) for every grob
542
+ # currently being measured. ``_evaluate_grob_unit`` consults this
543
+ # before pushing the grob's vp; re-entry for the same grob means
544
+ # the vp itself references its own grob*Details (e.g.
545
+ # ``viewport(width = grobWidth(self))``), which would otherwise
546
+ # infinite-recurse through calcViewportTransform → grob_metric_fn
547
+ # → _evaluate_grob_unit → push vp → ...
548
+ #
549
+ # R bounds the same recursion implicitly because its
550
+ # widthDetails.gtable returns an absolute mm sum, and R's
551
+ # setviewport/calcViewportTransform doesn't re-trigger the metric
552
+ # path during the recursive pushViewport (verified empirically:
553
+ # preDraw.gtable runs exactly 3 times for one toplevel grid.draw —
554
+ # not unbounded). We mirror the bound explicitly.
555
+ @property
556
+ def _measuring_grobs(self) -> set:
557
+ s = getattr(self, "_measuring_grobs_set", None)
558
+ if s is None:
559
+ s = set()
560
+ self._measuring_grobs_set = s
561
+ return s
562
+
495
563
  def _evaluate_grob_unit(
496
564
  self,
497
565
  grob: Any,
@@ -545,6 +613,46 @@ class GridRenderer(ABC):
545
613
  if not isinstance(grob, Grob):
546
614
  return 0.0
547
615
 
616
+ # --- Cycle break for self-referential grobwidth/grobheight ---
617
+ # When we're already measuring this grob and the grob's vp
618
+ # references its own width/height (e.g. patchwork's
619
+ # ``as_patch.GT`` builds ``viewport(width=grobWidth(grob))``),
620
+ # the recursive vp push would trigger _evaluate_grob_unit for
621
+ # the same grob again, etc. Resolve widthDetails/heightDetails
622
+ # directly without the recursive push — for non-context-
623
+ # dependent grobs (gtable widthDetails returns absolute_size of
624
+ # the widths sum) this gives the same answer as the full path,
625
+ # matching R's behaviour where preDraw.gtable runs a bounded
626
+ # number of times rather than unboundedly.
627
+ if id(grob) in self._measuring_grobs:
628
+ if unit_type == "grobwidth":
629
+ ru = width_details(grob)
630
+ axis = "x"
631
+ elif unit_type == "grobheight":
632
+ ru = height_details(grob)
633
+ axis = "y"
634
+ elif unit_type == "grobascent":
635
+ ru = ascent_details(grob)
636
+ axis = "y"
637
+ elif unit_type == "grobdescent":
638
+ ru = descent_details(grob)
639
+ axis = "y"
640
+ else:
641
+ return 0.0
642
+ if ru is None:
643
+ return 0.0
644
+ from ._units import Unit as _U
645
+ if not isinstance(ru, _U):
646
+ return 0.0
647
+ if len(ru) == 1 and ru._units[0] == "null":
648
+ return 0.0
649
+ return float(self._resolve_to_inches(ru, axis, True))
650
+
651
+ # Capture id BEFORE make_context() rebinds ``grob`` to the
652
+ # context-wrapped copy — we need to release the same id we added.
653
+ _measuring_id = id(grob)
654
+ self._measuring_grobs.add(_measuring_id)
655
+
548
656
  # --- Save state (R unit.c:355-377) ---
549
657
  saved_dl_on = state._dl_on
550
658
  state.set_display_list_on(False)
@@ -620,6 +728,7 @@ class GridRenderer(ABC):
620
728
  state.replace_gpar(saved_gpar)
621
729
  state._current_grob = saved_current_grob
622
730
  state.set_display_list_on(saved_dl_on)
731
+ self._measuring_grobs.discard(_measuring_id)
623
732
 
624
733
  return result
625
734
 
@@ -232,13 +232,36 @@ def _resolve_grob_gp(grob: Any) -> "Optional[Gpar]":
232
232
  def _text_bbox(grob: Any) -> tuple:
233
233
  """Compute (width, height) of the text bounding box in inches.
234
234
 
235
- Mirrors what R's ``heightDetails.text`` / ``widthDetails.text`` return
236
- (empirically verified against R 4.4 ``cairo_png`` at 150 dpi):
237
-
238
- width = max(per-line ink widths) — R's ``GEStrWidth``
239
- height = ink_height(first line)
235
+ Ports R's ``heightDetails.text`` / ``widthDetails.text`` (grid's
236
+ ``primitives.R:1430-1452``) R's ``grobHeight`` on a text grob
237
+ returns the **ascent only** (glyph extent above baseline), never
238
+ the descent. ``grobDescent`` is a separate method (see
239
+ ``descentDetails.text``) and is exposed independently so that
240
+ callers can add it when needed. ggplot2's ``titleGrob``
241
+ (margins.R:115-132) relies on this convention — it uses
242
+ ``unit(1, "grobheight", grob) + y_descent`` to assemble the final
243
+ height — so any deviation here double-counts the descent.
244
+
245
+ Empirical verification (R 4.4 ``cairo_png`` at 150 dpi, default
246
+ Helvetica, fontsize 13.2 pt):
247
+
248
+ grobHeight(textGrob("A long title", fs=13.2)) = 3.293 mm
249
+ grobHeight(textGrob("gjpqy", fs=13.2)) = 3.293 mm
250
+ grobHeight(textGrob("y", fs=13.2)) = 3.293 mm
251
+
252
+ — i.e. the value is a font-level constant, independent of the
253
+ label content. Our cairo-backed ``calc_string_metric`` returns a
254
+ per-label ascent that varies slightly with glyph mix, which is the
255
+ closest we can get without implementing a full AFM font-metric
256
+ path; residual ≤ 0.3 mm discrepancy is a font-file difference
257
+ (AFM Helvetica vs. cairo's "Sans" fallback) and outside the
258
+ scope of this bbox function.
259
+
260
+ Height formula (port of R's ``GEStrHeight``):
261
+
262
+ width = max(per-line ink widths)
263
+ height = ascent(first line)
240
264
  + (n - 1) × cex × lineheight × fontsize × 1.2 / 72
241
- — R's ``GEStrHeight``
242
265
 
243
266
  The per-extra-line gap ``1.2 × fontsize / 72`` is R's device-level
244
267
  ``cra[1] × ipr[1] / default_ps`` collapsed for the standard cairo /
@@ -287,11 +310,14 @@ def _text_bbox(grob: Any) -> tuple:
287
310
  n_lines = len(lines)
288
311
  # Width: max per-line width (R's ``GEStrWidth`` walks each line).
289
312
  w = max(calc_string_metric(ln, gp=gp)["width"] for ln in lines)
290
- # Height: ink of first line + gap × (n - 1). R uses the first
291
- # line's ink bounds (``ascent + descent``) and extends by per-line
292
- # gaps for additional lines.
313
+ # Height: ascent of the first line + per-line gap × (n - 1).
314
+ # R's ``heightDetails.text`` / ``GEStrHeight`` returns ASCENT
315
+ # only never descent. Descent is a separate method
316
+ # (``descentDetails.text``). Including descent here would
317
+ # double-count it downstream in ggplot2 ``titleGrob``, which
318
+ # adds ``grobDescent`` manually to form the rendered height.
293
319
  m0 = calc_string_metric(lines[0], gp=gp)
294
- h = m0["ascent"] + m0["descent"] + (n_lines - 1) * inter_line_gap
320
+ h = m0["ascent"] + (n_lines - 1) * inter_line_gap
295
321
 
296
322
  if rot == 0.0:
297
323
  # No rotation: bbox is just the text extent
@@ -224,6 +224,12 @@ class GridState:
224
224
  # GSS_GROUPS: group registry for define/use (R grid.h:63, state.c:51)
225
225
  # Maps group name → dict with keys: ref, xy, xyin, wh, r, etc.
226
226
  self._groups: Dict[str, Any] = {}
227
+ # Per-push counter of redundant self-pushes (the same vp pushed
228
+ # again while already current). Each genuine push appends a 0;
229
+ # a redundant push increments the top entry; pops decrement
230
+ # before actually unwinding. Prevents the cyclic parent chain
231
+ # that would otherwise hang ``current_vp_path``.
232
+ self._redundant_push_count: List[int] = [0]
227
233
 
228
234
  # ---- reset ------------------------------------------------------------
229
235
 
@@ -244,7 +250,27 @@ class GridState:
244
250
  vp : Any
245
251
  A viewport-like object. Must expose ``name``, ``parent``,
246
252
  and ``children`` attributes (or dict keys).
253
+
254
+ Notes
255
+ -----
256
+ Pushing the same viewport object twice (e.g. when a Gtable's
257
+ ``make_context`` builds ``VpStack(orig_vp, layout_vp)`` and
258
+ the iterating push hits ``orig_vp`` while ``orig_vp`` is
259
+ already the current vp — happens in ``_evaluate_grob_unit``'s
260
+ preDraw for self-referential ``viewport(width = grobWidth(self))``
261
+ setups) would otherwise set ``vp.parent = vp`` and produce a
262
+ cyclic parent chain that deadlocks ``current_vp_path``. The
263
+ Python port stores parent on the vp object itself (rather than
264
+ on a separate per-push record like R's grid does), so we
265
+ track redundant self-pushes on a counter stack and treat the
266
+ matching ``up_viewport`` / ``pop_viewport`` as no-ops so depth
267
+ stays balanced.
247
268
  """
269
+ if vp is self._current_vp:
270
+ self._redundant_push_count[-1] = (
271
+ self._redundant_push_count[-1] + 1
272
+ )
273
+ return
248
274
  _vp_set_attr(vp, "parent", self._current_vp)
249
275
  children = _vp_children(self._current_vp)
250
276
  if children is None:
@@ -252,6 +278,7 @@ class GridState:
252
278
  _vp_set_attr(self._current_vp, "children", children)
253
279
  children.append(vp)
254
280
  self._current_vp = vp
281
+ self._redundant_push_count.append(0)
255
282
 
256
283
  def pop_viewport(self, n: int = 1) -> None:
257
284
  """Pop *n* viewports, navigating back toward the root.
@@ -272,8 +299,14 @@ class GridState:
272
299
  if n == 0:
273
300
  # Pop to root.
274
301
  self._current_vp = self._vp_tree
302
+ self._redundant_push_count = [0]
275
303
  return
276
304
  for _ in range(n):
305
+ # First absorb any redundant self-pushes recorded for the
306
+ # current vp (see ``push_viewport`` notes).
307
+ if self._redundant_push_count and self._redundant_push_count[-1] > 0:
308
+ self._redundant_push_count[-1] -= 1
309
+ continue
277
310
  parent = _vp_parent(self._current_vp)
278
311
  if parent is None:
279
312
  raise ValueError(
@@ -286,6 +319,8 @@ class GridState:
286
319
  except ValueError:
287
320
  pass
288
321
  self._current_vp = parent
322
+ if len(self._redundant_push_count) > 1:
323
+ self._redundant_push_count.pop()
289
324
 
290
325
  def up_viewport(self, n: int = 1) -> None:
291
326
  """Navigate up *n* levels without removing viewports from the tree.
@@ -305,14 +340,21 @@ class GridState:
305
340
  raise ValueError(f"'n' must be non-negative, got {n}")
306
341
  if n == 0:
307
342
  self._current_vp = self._vp_tree
343
+ self._redundant_push_count = [0]
308
344
  return
309
345
  for _ in range(n):
346
+ # Absorb redundant self-pushes first.
347
+ if self._redundant_push_count and self._redundant_push_count[-1] > 0:
348
+ self._redundant_push_count[-1] -= 1
349
+ continue
310
350
  parent = _vp_parent(self._current_vp)
311
351
  if parent is None:
312
352
  raise ValueError(
313
353
  "Cannot navigate above the root viewport."
314
354
  )
315
355
  self._current_vp = parent
356
+ if len(self._redundant_push_count) > 1:
357
+ self._redundant_push_count.pop()
316
358
 
317
359
  def down_viewport(self, name: str, strict: bool = False) -> int:
318
360
  """Navigate down to a named viewport (breadth-first search).
@@ -301,7 +301,20 @@ def _try_resolve_with_renderer(
301
301
  state = get_state()
302
302
  renderer = state.get_renderer()
303
303
 
304
- if renderer is None or not hasattr(renderer, "_resolve_to_inches_idx"):
304
+ if renderer is None:
305
+ # R parity: `convertUnit` (unit.R:59-75) dispatches to `L_convert` in
306
+ # src/unit.c, which resolves context via `GEcurrentDevice()`. When no
307
+ # device is open, R's graphics system auto-opens its default device
308
+ # (PDF, 7×7 in) and converts against it. We replicate that here by
309
+ # lazily installing a default 7×7 in CairoRenderer the first time a
310
+ # context-dependent conversion is requested — matching R's observed
311
+ # behaviour: `convertHeight(unit(0.3,"npc"),"mm") == 53.34` (=0.3×7in
312
+ # in mm) without any prior `grid_newpage()` / `pdf()` call.
313
+ from .renderer import CairoRenderer
314
+ renderer = CairoRenderer(width=7.0, height=7.0)
315
+ state.init_device(renderer)
316
+
317
+ if not hasattr(renderer, "_resolve_to_inches_idx"):
305
318
  return None
306
319
 
307
320
  # Build a single-element Unit for the source
@@ -1574,6 +1587,56 @@ def convert_unit(
1574
1587
  )
1575
1588
  if resolved is not None:
1576
1589
  result_vals[i] = resolved
1590
+ elif src_unit in ("sum", "min", "max"):
1591
+ # R ``grid/src/unit.c: L_convert`` dispatches ``sum.unit`` /
1592
+ # ``min.unit`` / ``max.unit`` via ``L_sumUnits`` /
1593
+ # ``L_minUnits`` / ``L_maxUnits``, each of which calls
1594
+ # ``convertUnit`` recursively on every child in the
1595
+ # compound's list and combines the resulting absolute
1596
+ # lengths. Without this branch the fallback at the end of
1597
+ # this loop returns the outer scalar (1.0) unchanged —
1598
+ # which is why ``convertWidth(unit(1,'grobwidth',g) +
1599
+ # unit(0.05,'inches'),'cm')`` returned 1.0 regardless of
1600
+ # the text's actual rendered width.
1601
+ child = x._data[i]
1602
+ if (
1603
+ child is not None
1604
+ and isinstance(child, Unit)
1605
+ and len(child) > 0
1606
+ ):
1607
+ child_inches: List[float] = []
1608
+ for j in range(len(child)):
1609
+ sub = Unit.__new__(Unit)
1610
+ sub._values = np.array(
1611
+ [child._values[j]], dtype=np.float64,
1612
+ )
1613
+ sub._units = [child._units[j]]
1614
+ sub._data = [child._data[j]]
1615
+ sub._is_absolute = (
1616
+ child._units[j] in _ABSOLUTE_UNIT_TYPES
1617
+ )
1618
+ inches_arr = convert_unit(
1619
+ sub, "inches",
1620
+ axisFrom=axisFrom, typeFrom=typeFrom,
1621
+ axisTo=axisTo, typeTo=typeTo,
1622
+ valueOnly=True,
1623
+ )
1624
+ child_inches.append(float(inches_arr[0]))
1625
+ if src_unit == "sum":
1626
+ combined = float(np.sum(child_inches))
1627
+ elif src_unit == "min":
1628
+ combined = float(np.min(child_inches))
1629
+ else:
1630
+ combined = float(np.max(child_inches))
1631
+ combined *= float(x._values[i])
1632
+ if target in _ABSOLUTE_UNIT_TYPES:
1633
+ result_vals[i] = combined / _INCHES_PER[target]
1634
+ else:
1635
+ result_vals[i] = combined
1636
+ converted = False
1637
+ else:
1638
+ result_vals[i] = x._values[i]
1639
+ converted = False
1577
1640
  elif src_unit in _STR_METRIC_TYPES:
1578
1641
  # Fallback without renderer: string metric → inches → target
1579
1642
  inches_val = _eval_str_metric(src_unit, x._data[i], x._values[i])
@@ -1583,18 +1646,34 @@ def convert_unit(
1583
1646
  result_vals[i] = inches_val
1584
1647
  converted = False
1585
1648
  elif src_unit in _GROB_METRIC_TYPES:
1586
- # Fallback: grob metric → inches → target
1649
+ # Fallback: grob metric → inches → target.
1650
+ # Mirrors R ``grid/src/unit.c``:
1651
+ # evaluateGrobUnit(..., evalType=2)
1652
+ # unitx <- widthDetails(grob)
1653
+ # result = transformWidthtoINCHES(unitx, 0, ...)
1654
+ # R takes *only index 0* of ``widthDetails``'s return,
1655
+ # relying on its methods (e.g. ``widthDetails.titleGrob``
1656
+ # ``<- sum(x$widths)``) to wrap multi-element units as
1657
+ # a single sum.unit. The recursive ``transformWidthtoINCHES``
1658
+ # then unwraps L_SUM via the compound branch above.
1587
1659
  metric_unit = _eval_grob_metric(src_unit, x._data[i])
1588
- if (
1589
- metric_unit is not None
1590
- and len(metric_unit) > 0
1591
- and metric_unit._units[0] in _ABSOLUTE_UNIT_TYPES
1592
- ):
1593
- src_inches = (
1594
- metric_unit._values[0]
1595
- * _INCHES_PER[metric_unit._units[0]]
1660
+ if metric_unit is not None and len(metric_unit) > 0:
1661
+ head = Unit.__new__(Unit)
1662
+ head._values = np.array(
1663
+ [metric_unit._values[0]], dtype=np.float64,
1664
+ )
1665
+ head._units = [metric_unit._units[0]]
1666
+ head._data = [metric_unit._data[0]]
1667
+ head._is_absolute = (
1668
+ metric_unit._units[0] in _ABSOLUTE_UNIT_TYPES
1669
+ )
1670
+ inches_arr = convert_unit(
1671
+ head, "inches",
1672
+ axisFrom=axisFrom, typeFrom=typeFrom,
1673
+ axisTo=axisTo, typeTo=typeTo,
1674
+ valueOnly=True,
1596
1675
  )
1597
- src_inches *= x._values[i]
1676
+ src_inches = float(inches_arr[0]) * float(x._values[i])
1598
1677
  if target in _ABSOLUTE_UNIT_TYPES:
1599
1678
  result_vals[i] = src_inches / _INCHES_PER[target]
1600
1679
  else:
@@ -336,12 +336,14 @@ class CairoRenderer(GridRenderer):
336
336
 
337
337
  col = gp.get("col", None)
338
338
  # R semantics:
339
- # * col=NULL (unset) → inherit parent, default "black"
340
- # * col=NA (explicit) no stroke (transparent)
341
- # In Python Gpar, None-scalar is dropped at construction, so
342
- # ``gp.get("col") is None`` NULL. A sequence whose entries
343
- # are None (coming from ggplot2 ``colour=NA`` data) must be
344
- # treated as NA, matching R's ``gpar(col=NA)``.
339
+ # * col absent from gp → inherit parent, default "black"
340
+ # * Gpar(col=None) stored as [None] (NA sentinel)
341
+ # transparent (≡ R gpar(col=NA))
342
+ # * sequence with first=None transparent (ggplot2 colour=NA)
343
+ # * scalar string "NA"/"none"/ → transparent (resolved by
344
+ # "transparent" _parse_colour)
345
+ # The [None] sentinel is produced by Gpar(col=None) — see
346
+ # _gpar.py Gpar.__init__.
345
347
  _is_seq = hasattr(col, "__len__") and not isinstance(col, str)
346
348
  if _is_seq:
347
349
  col_val = col[0]
@@ -12,7 +12,7 @@ name = "rgrid-python"
12
12
  # a corresponding R update, bump a PEP 440 post-release suffix:
13
13
  # "4.5.3" → "4.5.3.post1" → "4.5.3.post2" → ...
14
14
  # When R grid itself ships a new upstream version, move to e.g. "4.5.4".
15
- version = "4.5.3"
15
+ version = "4.5.3.post2"
16
16
  description = "Python port of the R grid package (tracks R grid 4.5.3)"
17
17
  readme = "README.md"
18
18
  requires-python = ">=3.10"
File without changes