rgrid-python 4.5.3.post1__py3-none-any.whl → 4.5.3.post3__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.post1"
9
+ __version__ = "4.5.3.post3"
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
grid_py/_curve.py CHANGED
@@ -1383,6 +1383,8 @@ def grid_curve(
1383
1383
  def xspline_grob(
1384
1384
  x: Optional[Any] = None,
1385
1385
  y: Optional[Any] = None,
1386
+ id: Optional[Any] = None,
1387
+ id_lengths: Optional[Any] = None,
1386
1388
  default_units: str = "npc",
1387
1389
  shape: Union[float, Sequence[float]] = 0.0,
1388
1390
  open_: bool = True,
@@ -1402,6 +1404,15 @@ def xspline_grob(
1402
1404
  x, y : Unit, numeric, sequence, or None
1403
1405
  Control-point coordinates. Defaults to ``Unit([0, 1], "npc")``
1404
1406
  when ``None``.
1407
+ id : array-like of int or None
1408
+ Group label for each control point. Points sharing an ``id`` are
1409
+ rendered as one X-spline; the grob therefore renders one spline
1410
+ per unique ``id`` value. Mirrors R ``xsplineGrob(id=...)``.
1411
+ Mutually meaningful with ``id_lengths``: pass at most one.
1412
+ id_lengths : array-like of int or None
1413
+ Run-length encoding of ``id``: the n-th entry is the number of
1414
+ consecutive control points belonging to spline n. Mirrors R's
1415
+ ``xsplineGrob(id.lengths=...)``.
1405
1416
  default_units : str
1406
1417
  Unit type for bare numerics.
1407
1418
  shape : float or sequence of float
@@ -1439,9 +1450,16 @@ def xspline_grob(
1439
1450
  if np.any((shape_arr < -1) | (shape_arr > 1)):
1440
1451
  raise ValueError("all 'shape' values must be between -1 and 1")
1441
1452
 
1453
+ id_arr = None if id is None else np.asarray(id, dtype=np.int64)
1454
+ id_lengths_arr = (
1455
+ None if id_lengths is None else np.asarray(id_lengths, dtype=np.int64)
1456
+ )
1457
+
1442
1458
  return Grob(
1443
1459
  x=x,
1444
1460
  y=y,
1461
+ id=id_arr,
1462
+ id_lengths=id_lengths_arr,
1445
1463
  shape=shape_arr,
1446
1464
  open_=bool(open_),
1447
1465
  arrow=arrow,
@@ -1456,6 +1474,8 @@ def xspline_grob(
1456
1474
  def grid_xspline(
1457
1475
  x: Optional[Any] = None,
1458
1476
  y: Optional[Any] = None,
1477
+ id: Optional[Any] = None,
1478
+ id_lengths: Optional[Any] = None,
1459
1479
  default_units: str = "npc",
1460
1480
  shape: Union[float, Sequence[float]] = 0.0,
1461
1481
  open_: bool = True,
@@ -1497,7 +1517,8 @@ def grid_xspline(
1497
1517
  The xspline grob.
1498
1518
  """
1499
1519
  grob = xspline_grob(
1500
- x=x, y=y, default_units=default_units,
1520
+ x=x, y=y, id=id, id_lengths=id_lengths,
1521
+ default_units=default_units,
1501
1522
  shape=shape, open_=open_, arrow=arrow,
1502
1523
  repEnds=repEnds, name=name, gp=gp, vp=vp,
1503
1524
  )
grid_py/_draw.py CHANGED
@@ -412,7 +412,15 @@ def _render_grob(
412
412
  xs, ys = _calc_xspline_points(
413
413
  x, y, shape=shape_arr, open_=open_, repEnds=rep_ends,
414
414
  )
415
- renderer.draw_polyline(xs, ys, id_=None, gp=gp)
415
+ # R's ``xsplineGrob(open=FALSE)`` is a *filled closed* shape, not
416
+ # a stroked path; route through ``draw_polygon`` so ``gp$fill``
417
+ # actually paints (R/grid: drawDetails.xspline → C_xspline,
418
+ # filled when ``open == FALSE``). ``open=TRUE`` stays a stroked
419
+ # polyline.
420
+ if open_:
421
+ renderer.draw_polyline(xs, ys, id_=None, gp=gp)
422
+ else:
423
+ renderer.draw_polygon(xs, ys, gp=gp)
416
424
  if arr is not None and len(xs) >= 2:
417
425
  _draw_arrow_heads(xs, ys, arr, renderer, gp)
418
426
  else:
@@ -436,12 +444,24 @@ def _render_grob(
436
444
  out_id += 1
437
445
  per_group.append((xs_g, ys_g))
438
446
  if all_xs:
439
- renderer.draw_polyline(
440
- np.asarray(all_xs, dtype=float),
441
- np.asarray(all_ys, dtype=float),
442
- id_=np.asarray(all_ids, dtype=int),
443
- gp=gp,
444
- )
447
+ # Multi-id closed splines render as N separate filled
448
+ # subpolygons (R: xsplineGrob(id=..., open=FALSE)); use
449
+ # ``draw_path`` with the path_id array. Open splines stay
450
+ # multi-stroke polylines.
451
+ if open_:
452
+ renderer.draw_polyline(
453
+ np.asarray(all_xs, dtype=float),
454
+ np.asarray(all_ys, dtype=float),
455
+ id_=np.asarray(all_ids, dtype=int),
456
+ gp=gp,
457
+ )
458
+ else:
459
+ renderer.draw_path(
460
+ np.asarray(all_xs, dtype=float),
461
+ np.asarray(all_ys, dtype=float),
462
+ path_id=np.asarray(all_ids, dtype=int),
463
+ gp=gp,
464
+ )
445
465
  if arr is not None:
446
466
  for xs_g, ys_g in per_group:
447
467
  if len(xs_g) >= 2:
@@ -564,15 +584,18 @@ def _render_grob(
564
584
  if image is None:
565
585
  image = getattr(grob, "image", None)
566
586
  if image is not None:
567
- # Apply justification (same as rect_grob)
568
587
  hj, vj = _resolve_just(grob)
569
588
  raw_x = renderer.resolve_x(getattr(grob, "x", 0.0), gp=gp)
570
589
  raw_y = renderer.resolve_y(getattr(grob, "y", 0.0), gp=gp)
571
590
  raw_w = renderer.resolve_w(getattr(grob, "width", 1.0), gp=gp)
572
591
  raw_h = renderer.resolve_h(getattr(grob, "height", 1.0), gp=gp)
573
- # Compute bottom-left corner from anchor + justification
592
+ # `renderer.draw_raster` expects the *top-left* corner in y-down
593
+ # device pixels. `resolve_y` already returns y-down coords, so the
594
+ # y component of justification must be flipped relative to R's
595
+ # `justifyY(y, h, vjust) = y - h*vjust` (which assumes y-up NPC).
596
+ # Same correction `draw_rect` applies (renderer.py:815).
574
597
  x0 = raw_x - raw_w * hj
575
- y0 = raw_y - raw_h * vj
598
+ y0 = raw_y - raw_h * (1.0 - vj)
576
599
  renderer.draw_raster(
577
600
  image=image,
578
601
  x=x0,
@@ -690,24 +713,44 @@ def _pop_grob_vp(vp: Any) -> None:
690
713
  def _vp_depth(vp: Any) -> int:
691
714
  """Return the depth of a viewport (number of levels it adds).
692
715
 
716
+ Mirrors R's ``depth()`` generic (grid R/grid.R) for every viewport
717
+ container: VpStack pushes ``sum(depth(child))`` levels, VpList
718
+ pushes ``depth(last)``, VpTree pushes ``depth(parent) + depth(last
719
+ child)``, plain Viewport pushes 1, VpPath pushes ``n``.
720
+
721
+ Recognised types are routed through :func:`._viewport.depth` (the
722
+ canonical R-faithful dispatch); this is what guarantees
723
+ ``_pop_grob_vp(grob.vp)`` pops the right count when a Gtable's
724
+ ``make_context`` returns ``VpStack(orig_vp, layout_vp)``. The
725
+ prior implementation only checked ``hasattr(vp, "depth")`` —
726
+ VpStack/VpList don't expose a method, so it fell through to
727
+ ``return 1`` and left every nested layout vp on the layout_stack
728
+ after the gtable rendered, breaking every grob drawn after.
729
+
730
+ Duck-typed objects with a ``.depth()`` method (used by tests and
731
+ user-defined viewport-like objects) are still honoured as a
732
+ fallback for unknown types.
733
+
693
734
  Parameters
694
735
  ----------
695
736
  vp : Any
696
- A viewport, VpPath, VpStack, VpList, or VpTree.
737
+ A viewport, VpPath, VpStack, VpList, VpTree, or any
738
+ depth-bearing duck-typed object.
697
739
 
698
740
  Returns
699
741
  -------
700
742
  int
701
743
  The depth.
702
744
  """
745
+ from ._viewport import (
746
+ Viewport, VpList, VpStack, VpTree, depth as _depth,
747
+ )
703
748
  from ._path import VpPath
704
749
 
705
- if isinstance(vp, VpPath):
706
- # VpPath stores the number of path components
707
- return getattr(vp, "n", 1)
750
+ if isinstance(vp, (Viewport, VpList, VpStack, VpTree, VpPath)):
751
+ return _depth(vp)
708
752
  if hasattr(vp, "depth"):
709
753
  return vp.depth()
710
- # Default single viewport depth
711
754
  return 1
712
755
 
713
756
 
grid_py/_gpar.py CHANGED
@@ -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)
grid_py/_layout.py CHANGED
@@ -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
grid_py/_renderer_base.py CHANGED
@@ -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,14 +413,27 @@ 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):
428
+ # Use device-units-per-inch (= dpi for raster ImageSurface, 72 for
429
+ # vector PDF/SVG/PS surfaces) so that absolute units (cm/mm/in/pt
430
+ # …) get converted to the SAME unit as ``parent_w``/``parent_h``.
431
+ # Passing ``self.dpi`` blindly would, for vector surfaces, scale
432
+ # absolute widths by dpi while the parent is measured in points,
433
+ # over-allocating fixed cells by ``dpi/72`` and shrinking the
434
+ # null-unit panel by the same factor.
397
435
  col_widths, row_heights = _calc_layout_sizes(
398
- layout, parent_w, parent_h, self.dpi,
436
+ layout, parent_w, parent_h, self._dev_units_per_inch,
399
437
  )
400
438
  else:
401
439
  nrow = getattr(layout, "nrow", 1)
@@ -412,9 +450,24 @@ class GridRenderer(ABC):
412
450
  col_starts = [sum(col_widths[:i]) for i in range(ncol)]
413
451
  row_starts = [sum(row_heights[:i]) for i in range(nrow)]
414
452
 
453
+ # Layout justification (R: layout.c::subRegion lines 433-450).
454
+ # When the cells' total size is less than the parent vp, hjust /
455
+ # vjust shift the layout block within the parent (default 0.5 →
456
+ # centred). ``GridLayout`` carries this on ``_valid_just`` as
457
+ # (hjust, vjust); fall back to (0, 1) for non-GridLayout
458
+ # containers (matching the prior top-left alignment).
459
+ valid_just = getattr(layout, "_valid_just", None)
460
+ if valid_just is not None:
461
+ hjust = float(valid_just[0])
462
+ vjust = float(valid_just[1])
463
+ else:
464
+ hjust, vjust = 0.0, 1.0
465
+
415
466
  return {
416
467
  "col_starts": col_starts, "col_widths": col_widths,
417
468
  "row_starts": row_starts, "row_heights": row_heights,
469
+ "parent_w": parent_w, "parent_h": parent_h,
470
+ "hjust": hjust, "vjust": vjust,
418
471
  }
419
472
 
420
473
  def _resolve_sizes(self, unit_obj: Any, n: int, total: float,
@@ -492,6 +545,28 @@ class GridRenderer(ABC):
492
545
  # evaluateGrobUnit -- port of R unit.c:325-590 #
493
546
  # ===================================================================== #
494
547
 
548
+ # Per-renderer cycle guard — populated with id(grob) for every grob
549
+ # currently being measured. ``_evaluate_grob_unit`` consults this
550
+ # before pushing the grob's vp; re-entry for the same grob means
551
+ # the vp itself references its own grob*Details (e.g.
552
+ # ``viewport(width = grobWidth(self))``), which would otherwise
553
+ # infinite-recurse through calcViewportTransform → grob_metric_fn
554
+ # → _evaluate_grob_unit → push vp → ...
555
+ #
556
+ # R bounds the same recursion implicitly because its
557
+ # widthDetails.gtable returns an absolute mm sum, and R's
558
+ # setviewport/calcViewportTransform doesn't re-trigger the metric
559
+ # path during the recursive pushViewport (verified empirically:
560
+ # preDraw.gtable runs exactly 3 times for one toplevel grid.draw —
561
+ # not unbounded). We mirror the bound explicitly.
562
+ @property
563
+ def _measuring_grobs(self) -> set:
564
+ s = getattr(self, "_measuring_grobs_set", None)
565
+ if s is None:
566
+ s = set()
567
+ self._measuring_grobs_set = s
568
+ return s
569
+
495
570
  def _evaluate_grob_unit(
496
571
  self,
497
572
  grob: Any,
@@ -545,6 +620,46 @@ class GridRenderer(ABC):
545
620
  if not isinstance(grob, Grob):
546
621
  return 0.0
547
622
 
623
+ # --- Cycle break for self-referential grobwidth/grobheight ---
624
+ # When we're already measuring this grob and the grob's vp
625
+ # references its own width/height (e.g. patchwork's
626
+ # ``as_patch.GT`` builds ``viewport(width=grobWidth(grob))``),
627
+ # the recursive vp push would trigger _evaluate_grob_unit for
628
+ # the same grob again, etc. Resolve widthDetails/heightDetails
629
+ # directly without the recursive push — for non-context-
630
+ # dependent grobs (gtable widthDetails returns absolute_size of
631
+ # the widths sum) this gives the same answer as the full path,
632
+ # matching R's behaviour where preDraw.gtable runs a bounded
633
+ # number of times rather than unboundedly.
634
+ if id(grob) in self._measuring_grobs:
635
+ if unit_type == "grobwidth":
636
+ ru = width_details(grob)
637
+ axis = "x"
638
+ elif unit_type == "grobheight":
639
+ ru = height_details(grob)
640
+ axis = "y"
641
+ elif unit_type == "grobascent":
642
+ ru = ascent_details(grob)
643
+ axis = "y"
644
+ elif unit_type == "grobdescent":
645
+ ru = descent_details(grob)
646
+ axis = "y"
647
+ else:
648
+ return 0.0
649
+ if ru is None:
650
+ return 0.0
651
+ from ._units import Unit as _U
652
+ if not isinstance(ru, _U):
653
+ return 0.0
654
+ if len(ru) == 1 and ru._units[0] == "null":
655
+ return 0.0
656
+ return float(self._resolve_to_inches(ru, axis, True))
657
+
658
+ # Capture id BEFORE make_context() rebinds ``grob`` to the
659
+ # context-wrapped copy — we need to release the same id we added.
660
+ _measuring_id = id(grob)
661
+ self._measuring_grobs.add(_measuring_id)
662
+
548
663
  # --- Save state (R unit.c:355-377) ---
549
664
  saved_dl_on = state._dl_on
550
665
  state.set_display_list_on(False)
@@ -620,6 +735,7 @@ class GridRenderer(ABC):
620
735
  state.replace_gpar(saved_gpar)
621
736
  state._current_grob = saved_current_grob
622
737
  state.set_display_list_on(saved_dl_on)
738
+ self._measuring_grobs.discard(_measuring_id)
623
739
 
624
740
  return result
625
741
 
grid_py/_size.py CHANGED
@@ -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
grid_py/_state.py CHANGED
@@ -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).
grid_py/_units.py CHANGED
@@ -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
grid_py/renderer.py CHANGED
@@ -141,6 +141,17 @@ class CairoRenderer(GridRenderer):
141
141
  if filename is None:
142
142
  raise ValueError("filename is required for SVG surface")
143
143
  self._surface = cairo.SVGSurface(filename, width_pt, height_pt)
144
+ # Cairo's SVGSurface measures user-space in points; emit explicit
145
+ # ``pt`` units in the resulting <svg width="...pt" height="...pt">
146
+ # so SVG renderers (browsers, cairosvg, Inkscape) interpret the
147
+ # canvas at the intended physical size instead of treating the
148
+ # raw numbers as user-units (= pixels by SVG default).
149
+ try:
150
+ self._surface.set_document_unit(cairo.SVG_UNIT_PT)
151
+ except (AttributeError, TypeError):
152
+ # Older pycairo / cairo without set_document_unit — file is
153
+ # still well-formed, just unit-less.
154
+ pass
144
155
  elif surface_type == "ps":
145
156
  if filename is None:
146
157
  raise ValueError("filename is required for PS surface")
@@ -322,6 +333,34 @@ class CairoRenderer(GridRenderer):
322
333
 
323
334
  # ---- gpar application --------------------------------------------------
324
335
 
336
+ def _lwd_to_user(self, lwd_pt: float) -> float:
337
+ """Convert R-grid lwd (points) to a Cairo user-space line width.
338
+
339
+ R's grid measures ``lwd`` in 1/72 inch (points) regardless of the
340
+ active viewport scale — a value of 1 should always produce a stroke
341
+ 1pt wide on the output medium. Cairo's ``set_line_width`` takes a
342
+ user-space distance, so we have to:
343
+
344
+ 1. Map points → device units. Raster ``ImageSurface`` has 1 user
345
+ unit = 1 pixel, so device units per point = ``dpi/72``. Vector
346
+ surfaces (PDF/SVG/PS) have 1 user unit = 1 pt, so the conversion
347
+ factor is 1.0. Equivalently, use ``_dev_units_per_inch / 72``.
348
+ 2. Undo any active CTM scaling via ``device_to_user_distance`` so
349
+ that nested viewports (which scale the CTM) do not also scale
350
+ the stroke.
351
+
352
+ This is the analogue of the font-size handling in ``_set_font``.
353
+ """
354
+ if self._surface_type == "image":
355
+ lw_dev = lwd_pt * self.dpi / 72.0
356
+ else:
357
+ lw_dev = lwd_pt
358
+ try:
359
+ ux, uy = self._ctx.device_to_user_distance(lw_dev, lw_dev)
360
+ return max(abs(ux), abs(uy))
361
+ except Exception:
362
+ return lw_dev
363
+
325
364
  def _apply_stroke(self, gp: Optional[Gpar]) -> Tuple[float, float, float, float]:
326
365
  """Set stroke colour, line width, dash, caps, joins from Gpar.
327
366
 
@@ -331,7 +370,7 @@ class CairoRenderer(GridRenderer):
331
370
  ctx = self._ctx
332
371
  if gp is None:
333
372
  ctx.set_source_rgba(0, 0, 0, 1)
334
- ctx.set_line_width(1.0)
373
+ ctx.set_line_width(self._lwd_to_user(1.0))
335
374
  return (0.0, 0.0, 0.0, 1.0)
336
375
 
337
376
  col = gp.get("col", None)
@@ -367,24 +406,7 @@ class CairoRenderer(GridRenderer):
367
406
  # R semantics: lwd=0 means invisible line
368
407
  if lw <= 0:
369
408
  return (0.0, 0.0, 0.0, 0.0)
370
- # R grid semantics: ``lwd`` is always in **points** (1/72 inch)
371
- # regardless of the current viewport's scale. Cairo's
372
- # ``set_line_width`` takes a user-space distance, which the
373
- # viewport CTM has scaled down to NPC-like units — so a value
374
- # of 0.5 user-space becomes sub-pixel after ``scale(w, h)``.
375
- # Convert ``lw`` from points → device pixels using the
376
- # renderer's DPI, then back to user-space via
377
- # ``device_to_user_distance`` so the stroke width stays at
378
- # 0.5pt on the output device no matter how deep the
379
- # viewport stack is (matches R grid's device-unit lwd).
380
- dpi = getattr(self, "dpi", None) or getattr(self, "_dpi", 72.0) or 72.0
381
- lw_px = lw * dpi / 72.0
382
- try:
383
- ux, uy = ctx.device_to_user_distance(lw_px, lw_px)
384
- lw_user = max(abs(ux), abs(uy))
385
- except Exception:
386
- lw_user = lw
387
- ctx.set_line_width(lw_user)
409
+ ctx.set_line_width(self._lwd_to_user(lw))
388
410
 
389
411
  lty = gp.get("lty", None)
390
412
  if lty is not None:
@@ -1197,7 +1219,7 @@ class CairoRenderer(GridRenderer):
1197
1219
  """
1198
1220
  ctx.save()
1199
1221
  ctx.new_path() # clear any residual path from prior draws
1200
- ctx.set_line_width(lwd)
1222
+ ctx.set_line_width(self._lwd_to_user(lwd))
1201
1223
 
1202
1224
  if pch_val <= 14:
1203
1225
  # --- Group 0-14: stroke-only (use col for outline, no fill) ---
@@ -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.post3
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,42 +1,42 @@
1
- grid_py/__init__.py,sha256=VdXdkrNMRm9SmgzSnxFUa2RPcUY-OTkfvGToCE96RiQ,10506
1
+ grid_py/__init__.py,sha256=tSRqBr3SxkmX3f3x0tT6G-kMFeApA6cjrhD1CGiKWNs,10520
2
2
  grid_py/_arrow.py,sha256=pgn4OCgF6TZY2yXl7ML-kt08DbwqlvT1ulApDcmRz4k,10867
3
3
  grid_py/_clippath.py,sha256=p6fUAkcEc5Bg8chv-lIZwaOjEUbLK57duwjtIpDjAkw,4015
4
4
  grid_py/_colour.py,sha256=WqaxGop-SM1LYZG4uMPmMX3Zjh6Y_ip0HcanP5wEij8,22872
5
5
  grid_py/_coords.py,sha256=9cDD3MWHmw9TnT1I8QKHQA96SCG06OxRXNqYGFZsfwg,47210
6
- grid_py/_curve.py,sha256=MV7hl1MR-4FZfKBKEm4MCSvHgJLYP9UaWLp8eHXPhv0,54041
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=PgvuOM3nAvGCh1LFOY1oYbSOQU6rTh8NFu_aHTbl1Ok,48620
8
+ grid_py/_draw.py,sha256=WcbxIX9GPEZBf5L8KRZpYiFNWyHVttGBrroKO4m6LUY,50953
9
9
  grid_py/_edit.py,sha256=vQDZGBTTYy6DmlW33l94s1KJLrzPNym21NR4Od4qIFw,22263
10
10
  grid_py/_font_metrics.py,sha256=XC_dgIN3eB72VPXIRFU-tynnS3wJl3bPugGHrwVzyeo,11086
11
- grid_py/_gpar.py,sha256=N8DoESq2nBiGdIcdshdsRXWjmEC8CwBCL4yndAJwaPQ,19392
11
+ grid_py/_gpar.py,sha256=AW3PNlD7UWOozzZvY5Y3f7ZGTWCTRnOs2YqOvlFD1_A,19757
12
12
  grid_py/_grab.py,sha256=XAPdYG2gyl9Px29xM_rByIPA2NwuvkbFaLcHGrqpaUQ,15423
13
13
  grid_py/_grob.py,sha256=kHSxkPHWqHHyMVum7ueKS-ddDqFF2E-8tUQOM-azEXU,39992
14
14
  grid_py/_group.py,sha256=jmPQPcy_lrVjurF8w5ovhCXvwKLBeGVkOwAvX0X0gA0,22828
15
15
  grid_py/_highlevel.py,sha256=KVcFc0aUm6WBGyNKlYJfZ16qC4bRolng8ZKb86lSLpc,61803
16
16
  grid_py/_just.py,sha256=lh9ar8lw6JRqZE5qHP4Wx7GHPIW0y7Wp0EgJ7vhFjek,10590
17
- grid_py/_layout.py,sha256=fYomioHrI--nHi8tC1X_fsv91QCRZ4E-g0F9hl6XW4c,19307
17
+ grid_py/_layout.py,sha256=opOEvxTO2gjwuLEt8sbKjp4yexjnx0Gik6d95yoL3aI,19721
18
18
  grid_py/_ls.py,sha256=RhUGZJCZkcTUjUK97fft_G4D9KxuyBr3OMFhzC5IShc,25547
19
19
  grid_py/_mask.py,sha256=d2Wm2z-RJnfEKHdAHcjteL7VubimcaK1_f9Us9MfrUk,4902
20
20
  grid_py/_path.py,sha256=Tr5bNNcGwPpOsxWKqEp65MRri-KV8IqplUh2Z90sxnk,11421
21
21
  grid_py/_patterns.py,sha256=PBwV2b3oJkbueTLCku6cxeZ1PThREjiPcA6lmrwH3pE,32691
22
22
  grid_py/_primitives.py,sha256=dpX5_q5CS8p1vhuVzTa77MVbtaxigGBA5mmUzfFfHks,58861
23
- grid_py/_renderer_base.py,sha256=kY_mdcldRR94OBBcgEFg9cmIM3fUlbnUPdGiy-ulaSM,48701
23
+ grid_py/_renderer_base.py,sha256=UD24cRzJlknMSSs2sl27XaDZ1e7XdIuhRDVhDxjiW-o,54770
24
24
  grid_py/_scene_graph.py,sha256=mKhNEMkUolbWt4_CFgGhrGUudrYenxFNXaBp6GLN9_Y,7412
25
- grid_py/_size.py,sha256=NuzLD567ywRYyhSPWna9CA2ScxtCWgV1BWpt_ot0Txs,41962
26
- grid_py/_state.py,sha256=GiOMFQozol_SInRoxUZ5TXsFLOBwuNs7EWWi19WFiWM,20598
25
+ grid_py/_size.py,sha256=q9yYdhO7dcp-RtQzJIIsXssFH5oFbsm5_P6UxZlFKjg,43312
26
+ grid_py/_state.py,sha256=SsEzcicTmxWaGExDeLLNNVXT-G38aUFtgSuZTJEYBbw,22800
27
27
  grid_py/_transforms.py,sha256=mQvs_Qm6icti66peLXTsjgmSmvIUCCphf_-D-tiE1O4,11675
28
28
  grid_py/_typeset.py,sha256=J_PJ5YcOAOnnFkBfo8IW86utJdkdsrnGZi3MvnHc8io,11516
29
- grid_py/_units.py,sha256=bbpzFW1iJjxvZi3J9nFwcB9XnnDcpPkmqHh0IeIRIjs,61141
29
+ grid_py/_units.py,sha256=bGrHV23Vr2A6lO15LJvcP7Qs1Yx_uQJLmyQ6bRB3yjc,61893
30
30
  grid_py/_utils.py,sha256=QaWNkCF3BbPHyrqPPRxN7Ji2vB5ethNwVT3SJ1ad9Lc,8335
31
31
  grid_py/_viewport.py,sha256=AOrYmv05Y4QJbsTksn86RLStfHdbOHERc2YDHdZAekE,48477
32
32
  grid_py/_vp_calc.py,sha256=v0MJc-YmWohO5t7LHFopk5ogH-Sink_A_zNFV_1769w,32935
33
33
  grid_py/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
34
- grid_py/renderer.py,sha256=hzCRgHMrR8tFpsdqT1al09gnZhgShoe_kIEO4MTYZNY,61620
34
+ grid_py/renderer.py,sha256=6L6Rb-2AfjmvaeQYhWQKlU1L1HYHkKAma03v6YdZcTk,62658
35
35
  grid_py/renderer_web.py,sha256=pbOijES1eUUqkeAadXVyZPCBRIUIgzs3HIOgAElTYmo,27958
36
36
  grid_py/resources/d3.v7.min.js,sha256=8glLv2FBs1lyLE_kVOtsSw8OQswQzHr5IfwVj864ZTk,279706
37
37
  grid_py/resources/gridpy.css,sha256=tR5LF2rLvi_bGRWuH9CnmLQk-aG2f-jlZjQZqS7_4uY,1351
38
38
  grid_py/resources/gridpy.js,sha256=l8KGgK-qZbJPVvrMAmLUu57RhpSAo_LMibs6rx3RnvA,28340
39
- rgrid_python-4.5.3.post1.dist-info/METADATA,sha256=zIT-8WOTDRcuOiWBEQz8a8R4821YTJ4_6ajLaGYTkwE,19910
40
- rgrid_python-4.5.3.post1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
41
- rgrid_python-4.5.3.post1.dist-info/licenses/LICENSE,sha256=8zNQKZlkc4JOaW7br_a8aALg4baZwZkLKLTQx3kuHkc,48
42
- rgrid_python-4.5.3.post1.dist-info/RECORD,,
39
+ rgrid_python-4.5.3.post3.dist-info/METADATA,sha256=U2Lx9ccpNvIWTBozFmqItTD-AWHPY9Wq9zdeKQelK68,19910
40
+ rgrid_python-4.5.3.post3.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
41
+ rgrid_python-4.5.3.post3.dist-info/licenses/LICENSE,sha256=8zNQKZlkc4JOaW7br_a8aALg4baZwZkLKLTQx3kuHkc,48
42
+ rgrid_python-4.5.3.post3.dist-info/RECORD,,