rgrid-python 4.5.3.post1__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.post1 → rgrid_python-4.5.3.post2}/PKG-INFO +1 -1
  2. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/__init__.py +3 -3
  3. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/_draw.py +25 -5
  4. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/_gpar.py +12 -1
  5. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/_layout.py +9 -2
  6. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/_renderer_base.py +119 -10
  7. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/_size.py +36 -10
  8. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/_state.py +42 -0
  9. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/_units.py +14 -1
  10. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/pyproject.toml +1 -1
  11. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/.gitattributes +0 -0
  12. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/.gitignore +0 -0
  13. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/LICENSE +0 -0
  14. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/README.md +0 -0
  15. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/_arrow.py +0 -0
  16. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/_clippath.py +0 -0
  17. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/_colour.py +0 -0
  18. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/_coords.py +0 -0
  19. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/_curve.py +0 -0
  20. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/_display_list.py +0 -0
  21. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/_edit.py +0 -0
  22. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/_font_metrics.py +0 -0
  23. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/_grab.py +0 -0
  24. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/_grob.py +0 -0
  25. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/_group.py +0 -0
  26. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/_highlevel.py +0 -0
  27. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/_just.py +0 -0
  28. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/_ls.py +0 -0
  29. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/_mask.py +0 -0
  30. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/_path.py +0 -0
  31. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/_patterns.py +0 -0
  32. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/_primitives.py +0 -0
  33. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/_scene_graph.py +0 -0
  34. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/_transforms.py +0 -0
  35. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/_typeset.py +0 -0
  36. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/_utils.py +0 -0
  37. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/_viewport.py +0 -0
  38. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/_vp_calc.py +0 -0
  39. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/py.typed +0 -0
  40. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/renderer.py +0 -0
  41. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/renderer_web.py +0 -0
  42. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/resources/d3.v7.min.js +0 -0
  43. {rgrid_python-4.5.3.post1 → rgrid_python-4.5.3.post2}/grid_py/resources/gridpy.css +0 -0
  44. {rgrid_python-4.5.3.post1 → 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.post1
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
@@ -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.post1"
9
+ __version__ = "4.5.3.post2"
10
10
 
11
11
  # --- Utilities ---
12
12
  from grid_py._utils import depth, explode, grid_pretty, n2mfrow
@@ -25,7 +25,7 @@ from grid_py._units import (
25
25
  )
26
26
 
27
27
  # --- Graphical Parameters ---
28
- from grid_py._gpar import Gpar, get_gpar
28
+ from grid_py._gpar import Gpar, get_gpar, gpar
29
29
 
30
30
  # --- Arrow ---
31
31
  from grid_py._arrow import Arrow, arrow
@@ -232,7 +232,7 @@ __all__ = [
232
232
  "absolute_size",
233
233
  "convert_unit", "convert_x", "convert_y", "convert_width", "convert_height",
234
234
  # Gpar
235
- "Gpar", "get_gpar",
235
+ "Gpar", "gpar", "get_gpar",
236
236
  # Arrow
237
237
  "Arrow", "arrow",
238
238
  # Path
@@ -690,24 +690,44 @@ def _pop_grob_vp(vp: Any) -> None:
690
690
  def _vp_depth(vp: Any) -> int:
691
691
  """Return the depth of a viewport (number of levels it adds).
692
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
+
693
711
  Parameters
694
712
  ----------
695
713
  vp : Any
696
- A viewport, VpPath, VpStack, VpList, or VpTree.
714
+ A viewport, VpPath, VpStack, VpList, VpTree, or any
715
+ depth-bearing duck-typed object.
697
716
 
698
717
  Returns
699
718
  -------
700
719
  int
701
720
  The depth.
702
721
  """
722
+ from ._viewport import (
723
+ Viewport, VpList, VpStack, VpTree, depth as _depth,
724
+ )
703
725
  from ._path import VpPath
704
726
 
705
- if isinstance(vp, VpPath):
706
- # VpPath stores the number of path components
707
- return getattr(vp, "n", 1)
727
+ if isinstance(vp, (Viewport, VpList, VpStack, VpTree, VpPath)):
728
+ return _depth(vp)
708
729
  if hasattr(vp, "depth"):
709
730
  return vp.depth()
710
- # Default single viewport depth
711
731
  return 1
712
732
 
713
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
@@ -586,3 +586,14 @@ def get_gpar(names: Optional[Sequence[str]] = None) -> Gpar:
586
586
  gp = object.__new__(Gpar)
587
587
  gp._params = subset
588
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
@@ -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.post1"
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"