ggplot2-python 4.0.2.9000.post4__py3-none-any.whl → 4.0.2.9000.post6__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.
ggplot2_py/__init__.py CHANGED
@@ -7,7 +7,7 @@ approach to creating statistical visualizations.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- __version__ = "4.0.2.9000.post4"
10
+ __version__ = "4.0.2.9000.post6"
11
11
  __r_commit__ = "c02c05a"
12
12
 
13
13
  # ---------------------------------------------------------------------------
@@ -23,7 +23,10 @@ from ggplot2_py.ggproto import (
23
23
  ggproto,
24
24
  ggproto_parent,
25
25
  is_ggproto,
26
+ fetch_ggproto,
27
+ bind_method,
26
28
  )
29
+ from ggplot2_py._env import PlotEnv
27
30
 
28
31
  # ---------------------------------------------------------------------------
29
32
  # Aesthetics
@@ -62,6 +65,8 @@ from ggplot2_py.plot import (
62
65
  ggplotGrob,
63
66
  ggplot_add,
64
67
  add_gg,
68
+ register_pre_add_hook,
69
+ unregister_pre_add_hook,
65
70
  get_last_plot,
66
71
  set_last_plot,
67
72
  last_plot,
@@ -118,6 +123,7 @@ from ggplot2_py.geom import (
118
123
  GeomRaster,
119
124
  GeomText,
120
125
  GeomLabel,
126
+ GeomAbsText,
121
127
  GeomBoxplot,
122
128
  GeomViolin,
123
129
  GeomDotplot,
@@ -168,6 +174,7 @@ from ggplot2_py.geom import (
168
174
  geom_raster,
169
175
  geom_text,
170
176
  geom_label,
177
+ geom_abs_text,
171
178
  geom_boxplot,
172
179
  geom_violin,
173
180
  geom_dotplot,
@@ -561,6 +568,7 @@ from ggplot2_py.guide import (
561
568
  is_guide,
562
569
  is_guides,
563
570
  new_guide,
571
+ register_guide,
564
572
  old_guide,
565
573
  guide_geom,
566
574
  guide_train,
@@ -734,7 +742,8 @@ __all__ = [
734
742
  # Version
735
743
  "__version__",
736
744
  # Core
737
- "GGProto", "ggproto", "ggproto_parent", "is_ggproto",
745
+ "GGProto", "ggproto", "ggproto_parent", "is_ggproto", "fetch_ggproto",
746
+ "bind_method", "PlotEnv",
738
747
  "Waiver", "waiver", "is_waiver",
739
748
  # Aesthetics
740
749
  "aes", "after_stat", "after_scale", "stage", "vars",
@@ -745,7 +754,9 @@ __all__ = [
745
754
  "Layer", "layer", "is_layer",
746
755
  # Plot
747
756
  "ggplot", "is_ggplot", "ggplot_build", "ggplot_gtable", "ggplotGrob",
748
- "ggplot_add", "add_gg", "get_last_plot", "set_last_plot", "last_plot",
757
+ "ggplot_add", "add_gg",
758
+ "register_pre_add_hook", "unregister_pre_add_hook",
759
+ "get_last_plot", "set_last_plot", "last_plot",
749
760
  "print_plot", "get_alt_text", "update_ggplot",
750
761
  # Introspection
751
762
  "get_layer_data", "get_layer_grob", "get_panel_scales",
@@ -756,7 +767,7 @@ __all__ = [
756
767
  # Geom classes
757
768
  "Geom", "GeomPoint", "GeomPath", "GeomLine", "GeomStep",
758
769
  "GeomBar", "GeomCol", "GeomRect", "GeomTile", "GeomRaster",
759
- "GeomText", "GeomLabel", "GeomBoxplot", "GeomViolin", "GeomDotplot",
770
+ "GeomText", "GeomLabel", "GeomAbsText", "GeomBoxplot", "GeomViolin", "GeomDotplot",
760
771
  "GeomRibbon", "GeomArea", "GeomSmooth", "GeomPolygon",
761
772
  "GeomErrorbar", "GeomErrorbarh", "GeomCrossbar", "GeomLinerange", "GeomPointrange",
762
773
  "GeomSegment", "GeomCurve", "GeomSpoke",
@@ -767,7 +778,7 @@ __all__ = [
767
778
  # Geom constructors
768
779
  "geom_point", "geom_path", "geom_line", "geom_step",
769
780
  "geom_bar", "geom_col", "geom_rect", "geom_tile", "geom_raster",
770
- "geom_text", "geom_label", "geom_boxplot", "geom_violin", "geom_dotplot",
781
+ "geom_text", "geom_label", "geom_abs_text", "geom_boxplot", "geom_violin", "geom_dotplot",
771
782
  "geom_ribbon", "geom_area", "geom_smooth", "geom_polygon",
772
783
  "geom_errorbar", "geom_errorbarh", "geom_crossbar", "geom_linerange", "geom_pointrange",
773
784
  "geom_segment", "geom_curve", "geom_spoke",
@@ -897,7 +908,7 @@ __all__ = [
897
908
  # Guides
898
909
  "Guide", "GuideAxis", "GuideAxisLogticks", "GuideAxisStack",
899
910
  "GuideAxisTheta", "GuideBins", "GuideColourbar", "GuideColoursteps",
900
- "GuideCustom", "GuideLegend", "GuideNone",
911
+ "GuideCustom", "GuideLegend", "GuideNone", "register_guide",
901
912
  "guide_axis", "guide_legend", "guide_colourbar", "guide_colorbar",
902
913
  "guide_old_colourbar", "guide_old_colorbar",
903
914
  "guide_coloursteps", "guide_colorsteps", "guide_bins",
ggplot2_py/_compat.py CHANGED
@@ -8,6 +8,8 @@ ggplot2 relies on, adapted for idiomatic Python usage.
8
8
  from __future__ import annotations
9
9
 
10
10
  import importlib
11
+ import logging
12
+ import sys
11
13
  import warnings
12
14
  from typing import Any, NoReturn, Optional
13
15
 
@@ -41,6 +43,40 @@ __all__ = [
41
43
  # ---------------------------------------------------------------------------
42
44
  # CLI messaging helpers (rlang / cli replacements)
43
45
  # ---------------------------------------------------------------------------
46
+ #
47
+ # R distinguishes three output channels:
48
+ #
49
+ # * ``cli::cli_abort`` — error stream (``stop``) → Python ``raise``
50
+ # * ``cli::cli_warn`` — warning stream (``warning``) → ``warnings.warn(..., UserWarning)``
51
+ # * ``cli::cli_inform``— message stream (``message``) → ``logging.INFO``
52
+ #
53
+ # In R these are three separate streams: ``suppressWarnings`` does not
54
+ # silence messages, ``suppressMessages`` does not silence warnings, and
55
+ # the test framework's ``expect_message`` / ``expect_warning`` discriminate
56
+ # between them. This Python port matches that split — informational
57
+ # output (e.g. ``ggsave``'s "Saving …", ``stat_bin``'s "using bins = 30")
58
+ # routes through the standard :mod:`logging` module at INFO level so
59
+ # pytest does not count it as a warning, ``warnings.catch_warnings``
60
+ # does not capture it, and ``logging.getLogger("ggplot2_py").setLevel(
61
+ # logging.WARNING)`` mirrors R's ``suppressMessages``.
62
+
63
+ #: Package-level logger. Library convention says "do not configure the
64
+ #: root logger" — instead we attach a single :class:`logging.StreamHandler`
65
+ #: to *this* logger and set :attr:`Logger.propagate` to ``False``, so the
66
+ #: handler runs even when the user has not called
67
+ #: :func:`logging.basicConfig` and ours never duplicates onto theirs.
68
+ _logger: logging.Logger = logging.getLogger("ggplot2_py")
69
+ if not _logger.handlers:
70
+ _handler = logging.StreamHandler(stream=sys.stderr)
71
+ # R's ``message()`` prints the bare message text (no level/timestamp
72
+ # prefix); mirror that to keep ggplot2_py output indistinguishable
73
+ # from the R original.
74
+ _handler.setFormatter(logging.Formatter("%(message)s"))
75
+ _handler.setLevel(logging.INFO)
76
+ _logger.addHandler(_handler)
77
+ _logger.setLevel(logging.INFO)
78
+ _logger.propagate = False
79
+
44
80
 
45
81
  def cli_abort(
46
82
  message: str,
@@ -81,7 +117,13 @@ def cli_warn(
81
117
  call: Optional[str] = None,
82
118
  **kwargs: Any,
83
119
  ) -> None:
84
- """Issue a ``UserWarning`` with a formatted message.
120
+ """Issue a ``UserWarning`` R *warning* stream equivalent.
121
+
122
+ Counterpart of :func:`cli_inform`: this function targets the
123
+ Python warning stream (matching R ``cli::cli_warn`` / ``warning()``),
124
+ while informational output goes through :mod:`logging` at INFO
125
+ level. Keeping the two streams separate preserves R's
126
+ ``suppressWarnings`` / ``suppressMessages`` granularity.
85
127
 
86
128
  Parameters
87
129
  ----------
@@ -105,24 +147,39 @@ def cli_inform(
105
147
  call: Optional[str] = None,
106
148
  **kwargs: Any,
107
149
  ) -> None:
108
- """Emit an informational message via Python's :mod:`warnings`.
150
+ """Emit an informational message at ``logging.INFO`` level.
151
+
152
+ R analogue: ``cli::cli_inform`` / ``rlang::inform`` write to R's
153
+ *message* stream — informational output that is **distinct from**
154
+ R's *warning* stream (see :func:`cli_warn`). Python's natural
155
+ equivalent is the :mod:`logging` module at INFO level:
109
156
 
110
- Mirrors R ``cli::cli_inform`` / ``rlang::inform`` which always write
111
- to stderr regardless of session type. Routing through ``warnings``
112
- lets pytest, ``warnings.catch_warnings``, and user filters capture
113
- or silence the message matching the way R lets users wrap
114
- ``suppressMessages({...})`` around an expression.
157
+ * pytest does not count INFO records as warnings (its
158
+ ``--strict-warnings`` flag and "warnings summary" only track
159
+ :mod:`warnings`);
160
+ * ``warnings.catch_warnings`` does not capture them;
161
+ * the user opt-out parallels R's ``suppressMessages``
162
+ ``logging.getLogger("ggplot2_py").setLevel(logging.WARNING)``
163
+ silences cli_inform output without affecting cli_warn.
164
+
165
+ By default the package logger has a :class:`logging.StreamHandler`
166
+ attached at INFO level (R-faithful: ``cli_inform`` messages are
167
+ visible to stderr unless explicitly suppressed).
115
168
 
116
169
  Parameters
117
170
  ----------
118
171
  message : str
119
- Informational message.
172
+ Informational message. May contain ``{name}``-style placeholders.
120
173
  call : str, optional
121
174
  Name of the calling function (unused; matches R signature).
122
175
  **kwargs : Any
123
176
  Substitution values for placeholders in *message*.
124
177
  """
125
- warnings.warn(message, UserWarning, stacklevel=2)
178
+ try:
179
+ formatted = message.format(**kwargs) if kwargs else message
180
+ except (KeyError, IndexError):
181
+ formatted = message
182
+ _logger.info(formatted)
126
183
 
127
184
 
128
185
  # ---------------------------------------------------------------------------
ggplot2_py/_env.py ADDED
@@ -0,0 +1,160 @@
1
+ """
2
+ PlotEnv — layered namespace lookup for scale/guide constructors.
3
+
4
+ Port of R's ``find_global(name, env, mode = "function")`` (R ref:
5
+ ``ggplot2/R/scale-type.R:39-54``). Quoting the R source:
6
+
7
+ Look for object first in parent environment and if not found, then in
8
+ ggplot2 namespace environment. This makes it possible to override
9
+ default scales by setting them in the parent environment.
10
+
11
+ In R, ``plot@plot_env`` is the environment where the plot was constructed
12
+ (typically ``parent.frame()`` at ``ggplot()`` call time), and a chain of
13
+ environments terminates in ``asNamespace("ggplot2")``. ggnewscale
14
+ exploits this by **injecting** ``scale_<bumped_aes>_<type>`` functions
15
+ into ``plot@plot_env`` (R ref:
16
+ ``ggnewscale/R/rename-aes.R:87-104``), so that when build-time
17
+ default-scale resolution runs, the injected constructor wins.
18
+
19
+ In Python, the closest equivalent is a list-of-namespaces walked in
20
+ order, with the ``ggplot2_py.scales`` module as the implicit fallback
21
+ layer. :class:`PlotEnv` encapsulates that — call sites pass a
22
+ ``PlotEnv`` to :func:`find_scale` / ``ScalesList.add_defaults`` /
23
+ ``ScalesList.add_missing``, and lookups walk the user-pushed layers
24
+ first.
25
+
26
+ A *namespace* is anything that responds to attribute access **or**
27
+ mapping ``__getitem__``. ``dict``, ``types.ModuleType``,
28
+ ``types.SimpleNamespace``, ``argparse.Namespace``, and arbitrary
29
+ objects implementing ``__getattr__`` all work.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from typing import Any, Callable, List, Optional
35
+
36
+ __all__ = ["PlotEnv"]
37
+
38
+
39
+ class PlotEnv:
40
+ """Layered scale/guide-constructor lookup table for a plot.
41
+
42
+ Mirrors the *effective* behaviour of R's ``plot@plot_env`` + the
43
+ ``find_global`` walk: lookups proceed through the user-pushed
44
+ layers in **last-pushed-first-checked** order (LIFO), then fall
45
+ back to the ``ggplot2_py.scales`` module.
46
+
47
+ Each layer may be any object that supports either attribute access
48
+ (``getattr(layer, name)``) or mapping access (``layer[name]``); the
49
+ first that yields a non-``None`` value wins.
50
+
51
+ Examples
52
+ --------
53
+ >>> env = PlotEnv()
54
+ >>> env.push({"scale_colour_continuous": my_factory})
55
+ >>> env.lookup("scale_colour_continuous") is my_factory
56
+ True
57
+
58
+ The implicit fallback to ``ggplot2_py.scales`` is intentionally
59
+ **not** included in :attr:`layers` so that :meth:`clone` produces
60
+ an env with the same user-visible chain.
61
+ """
62
+
63
+ def __init__(self, *layers: Any) -> None:
64
+ # Layers are stored in **push order**. Lookup walks them in
65
+ # reverse so that the most recently pushed layer wins, matching
66
+ # R's environment-chain semantics (the immediate parent is
67
+ # checked before more ancestral ones).
68
+ self._layers: List[Any] = [l for l in layers if l is not None]
69
+
70
+ # -- Mutation ----------------------------------------------------------
71
+
72
+ def push(self, layer: Any) -> None:
73
+ """Add a new lookup layer. Last-pushed wins on conflicts.
74
+
75
+ Parameters
76
+ ----------
77
+ layer : object
78
+ Any dict-like or attribute-bearing namespace.
79
+ """
80
+ if layer is None:
81
+ return
82
+ self._layers.append(layer)
83
+
84
+ def pop(self) -> Optional[Any]:
85
+ """Remove and return the most-recently-pushed layer, or ``None``
86
+ if the chain is empty.
87
+ """
88
+ if not self._layers:
89
+ return None
90
+ return self._layers.pop()
91
+
92
+ # -- Query -------------------------------------------------------------
93
+
94
+ def lookup(self, name: str) -> Optional[Callable]:
95
+ """Resolve *name* through the layer chain, then the
96
+ ``ggplot2_py.scales`` fallback module.
97
+
98
+ Returns the first hit, or ``None`` if nothing matches.
99
+
100
+ Notes
101
+ -----
102
+ The fallback layer is searched **last** even when the lookup
103
+ chain is non-empty — matching R's
104
+ ``c(env, list(as_namespace("ggplot2")))`` (R ref:
105
+ ``scale-type.R:44``).
106
+ """
107
+ for layer in reversed(self._layers):
108
+ val = _ns_get(layer, name)
109
+ if val is not None:
110
+ return val
111
+ # Fallback: the package's own scale-constructor module. Import
112
+ # lazily to avoid an import cycle with scale.py at load time —
113
+ # the cycle only matters during module construction; at call
114
+ # time every module is loaded so the import never raises.
115
+ from ggplot2_py import scales as _scales_mod
116
+ return getattr(_scales_mod, name, None)
117
+
118
+ def __contains__(self, name: str) -> bool:
119
+ return self.lookup(name) is not None
120
+
121
+ # -- Plumbing ----------------------------------------------------------
122
+
123
+ def clone(self) -> "PlotEnv":
124
+ """Return a new :class:`PlotEnv` with the same layer chain.
125
+
126
+ The layers themselves are **not** deep-copied — mirrors R env
127
+ semantics where ``plot_clone`` does not snapshot the
128
+ environment contents.
129
+ """
130
+ new = PlotEnv()
131
+ new._layers = list(self._layers)
132
+ return new
133
+
134
+ @property
135
+ def layers(self) -> List[Any]:
136
+ """Read-only view of the layer chain (in push order)."""
137
+ return list(self._layers)
138
+
139
+ def __repr__(self) -> str:
140
+ return f"<PlotEnv layers={len(self._layers)}>"
141
+
142
+
143
+ def _ns_get(layer: Any, name: str) -> Any:
144
+ """Resolve *name* on *layer*, trying mapping then attribute access.
145
+
146
+ Returns the found value, or ``None`` if absent. Catches only the
147
+ natural "key absent" / "unsupported indexing" cases — anything else
148
+ propagates so real bugs surface (per the project rule against
149
+ over-broad fallback logic).
150
+ """
151
+ # Mapping path (dict and dict-like). ``str``/``bytes`` are
152
+ # excluded because their ``__getitem__`` is positional and would
153
+ # raise ``TypeError`` for non-integer keys.
154
+ if hasattr(layer, "__getitem__") and not isinstance(layer, (str, bytes)):
155
+ try:
156
+ return layer[name]
157
+ except (KeyError, TypeError):
158
+ pass
159
+ # Attribute path (modules, SimpleNamespace, regular objects).
160
+ return getattr(layer, name, None)
@@ -55,8 +55,8 @@ _PT: float = 72.27 / 25.4
55
55
  # Helpers
56
56
  # ---------------------------------------------------------------------------
57
57
 
58
- def _unit_to_cm(u: Unit) -> float:
59
- """Convert a Unit (possibly compound/sum) to cm.
58
+ def _unit_to_cm(u: Unit, axis: str = "height") -> float:
59
+ """Convert a Unit (possibly compound/sum) to cm along *axis*.
60
60
 
61
61
  ``convert_height/width`` can't handle compound ``"sum"`` units
62
62
  without a viewport context. This helper decomposes them into
@@ -70,8 +70,17 @@ def _unit_to_cm(u: Unit) -> float:
70
70
 
71
71
  The ``data`` element is itself a multi-element Unit with the
72
72
  individual operands.
73
+
74
+ Parameters
75
+ ----------
76
+ u : Unit
77
+ Unit to convert.
78
+ axis : {"height", "width"}
79
+ Axis to measure along — needed for viewport-relative units
80
+ (``"npc"``) which resolve differently per axis in non-square
81
+ viewports. Compound sums propagate the axis to children.
73
82
  """
74
- from grid_py import convert_height
83
+ from grid_py import convert_height, convert_width
75
84
 
76
85
  units = getattr(u, "_units", None)
77
86
  values = getattr(u, "_values", None)
@@ -79,6 +88,8 @@ def _unit_to_cm(u: Unit) -> float:
79
88
  if units is None or values is None:
80
89
  return 0.0
81
90
 
91
+ fn = convert_width if axis == "width" else convert_height
92
+
82
93
  total_cm = 0.0
83
94
  n = len(u)
84
95
  for i in range(n):
@@ -88,7 +99,7 @@ def _unit_to_cm(u: Unit) -> float:
88
99
  # The operands are stored as a multi-element Unit in data[i]
89
100
  inner = data[i] if data and i < len(data) else None
90
101
  if inner is not None and isinstance(inner, Unit):
91
- total_cm += _unit_to_cm(inner)
102
+ total_cm += _unit_to_cm(inner, axis)
92
103
  continue
93
104
 
94
105
  # Skip context-dependent units we can't resolve statically
@@ -99,7 +110,7 @@ def _unit_to_cm(u: Unit) -> float:
99
110
  val = float(values[i]) if i < len(values) else 0.0
100
111
  leaf = Unit(val, unit_type)
101
112
  try:
102
- cm = convert_height(leaf, "cm", valueOnly=True)
113
+ cm = fn(leaf, "cm", valueOnly=True)
103
114
  total_cm += float(np.sum(cm))
104
115
  except Exception:
105
116
  pass
@@ -125,12 +136,12 @@ def _width_cm(x: Any) -> float:
125
136
  # For compound (sum) units, convert_width returns bogus results;
126
137
  # decompose and convert leaf-by-leaf instead.
127
138
  if _has_sum_unit(u):
128
- return _unit_to_cm(u)
139
+ return _unit_to_cm(u, "width")
129
140
  try:
130
141
  result = convert_width(u, "cm", valueOnly=True)
131
142
  return float(np.sum(result))
132
143
  except Exception:
133
- return _unit_to_cm(u)
144
+ return _unit_to_cm(u, "width")
134
145
 
135
146
 
136
147
  def _height_cm(x: Any) -> float:
@@ -143,12 +154,12 @@ def _height_cm(x: Any) -> float:
143
154
  else:
144
155
  return 0.0
145
156
  if _has_sum_unit(u):
146
- return _unit_to_cm(u)
157
+ return _unit_to_cm(u, "height")
147
158
  try:
148
159
  result = convert_height(u, "cm", valueOnly=True)
149
160
  return float(np.sum(result))
150
161
  except Exception:
151
- return _unit_to_cm(u)
162
+ return _unit_to_cm(u, "height")
152
163
 
153
164
 
154
165
  # ---------------------------------------------------------------------------
@@ -243,6 +254,15 @@ def draw_axis(
243
254
  minor_tick_length = tick_length * 0.5 # R: axis.minor.ticks.length = rel(0.75)
244
255
 
245
256
  # --- Build axis line (R: GuideAxis$build_decor, lines 313-322) -----
257
+ # R: the line is ``element_grob(elements$line, ...)``; when ``axis.line``
258
+ # is ``element_blank()`` (the default in theme_grey/theme_bw) this yields a
259
+ # zeroGrob — i.e. *no* line. The previous fallback dict drew a spurious
260
+ # grey20 line regardless, which is invisible at a panel edge but crosses
261
+ # the interior for a rotated radial axis. Honour the blank element.
262
+ from ggplot2_py.theme_elements import calc_element as _calc_el
263
+ _raw_line_el = _calc_el(f"axis.line.{aes}", theme)
264
+ _line_blank = _raw_line_el is None or _is_blank(_raw_line_el)
265
+
246
266
  if cap == "none" or len(breaks) == 0:
247
267
  line_start, line_end = 0.0, 1.0
248
268
  else:
@@ -250,7 +270,9 @@ def draw_axis(
250
270
  line_end = max(breaks) if cap in ("both", "upper") else 1.0
251
271
 
252
272
  line_lwd = float(line_el.get("linewidth", 0.5)) * _PT
253
- if is_horizontal:
273
+ if _line_blank:
274
+ axis_line = null_grob(name="axis.line")
275
+ elif is_horizontal:
254
276
  axis_line = segments_grob(
255
277
  x0=[line_start], y0=[orth_side],
256
278
  x1=[line_end], y1=[orth_side],
@@ -681,7 +681,7 @@ def assemble_colourbar(
681
681
  gt = gtable_add_grob(gt, label_tree, t=1, l=1, clip="off", name="labels")
682
682
 
683
683
  # Add title
684
- from ggplot2_py.guide_legend import add_legend_title
684
+ from ggplot2_py._guide_legend import add_legend_title
685
685
  gt = add_legend_title(gt, title_grob, position="top")
686
686
 
687
687
  # Add padding
@@ -25,6 +25,8 @@ from grid_py import (
25
25
  Gpar,
26
26
  Unit,
27
27
  Viewport,
28
+ convert_height,
29
+ convert_width,
28
30
  null_grob,
29
31
  rect_grob,
30
32
  text_grob,
@@ -930,8 +932,8 @@ def package_legend_box(
930
932
  max_width_cm = 0.0
931
933
  heights_cm: List[float] = []
932
934
  for lg in legends:
933
- max_width_cm = max(max_width_cm, _gtable_total_cm(lg.widths))
934
- heights_cm.append(_gtable_total_cm(lg.heights))
935
+ max_width_cm = max(max_width_cm, _gtable_total_cm(lg.widths, "width"))
936
+ heights_cm.append(_gtable_total_cm(lg.heights, "height"))
935
937
  guides = gtable_col(
936
938
  name="guides",
937
939
  grobs=legends,
@@ -944,8 +946,8 @@ def package_legend_box(
944
946
  max_height_cm = 0.0
945
947
  widths_cm: List[float] = []
946
948
  for lg in legends:
947
- max_height_cm = max(max_height_cm, _gtable_total_cm(lg.heights))
948
- widths_cm.append(_gtable_total_cm(lg.widths))
949
+ max_height_cm = max(max_height_cm, _gtable_total_cm(lg.heights, "height"))
950
+ widths_cm.append(_gtable_total_cm(lg.widths, "width"))
949
951
  guides = gtable_row(
950
952
  name="guides",
951
953
  grobs=legends,
@@ -1040,30 +1042,23 @@ def _interleave(
1040
1042
  return result
1041
1043
 
1042
1044
 
1043
- def _gtable_total_cm(unit: Optional[Unit]) -> float:
1044
- """Sum a Unit vector, returning cm as a float.
1045
+ def _gtable_total_cm(unit: Optional[Unit], axis: str = "height") -> float:
1046
+ """Sum a Unit vector along *axis* (``"height"`` or ``"width"``),
1047
+ returning cm as a float.
1045
1048
 
1046
- Falls back to simple sum of values for "cm" units; returns a
1047
- reasonable estimate for mixed/null units.
1049
+ Delegates to :func:`grid_py.convert_height` / :func:`grid_py.convert_width`
1050
+ so font-relative units (``"lines"``, ``"char"``, ``"strwidth"``,
1051
+ ``"strheight"``) resolve against the active gp / device — matching R's
1052
+ ``sum(convertHeight(unit, "cm", valueOnly=TRUE))`` / ``convertWidth``.
1053
+
1054
+ Axis matters for viewport-relative units (``"npc"``): for a
1055
+ non-square viewport, ``convert_height`` and ``convert_width`` resolve
1056
+ ``unit(0.5, "npc")`` to different cm values. Callers must pass the
1057
+ axis the unit is being measured along — typically ``"width"`` for
1058
+ gtable widths, ``"height"`` for gtable heights.
1048
1059
  """
1049
1060
  if unit is None or len(unit) == 0:
1050
1061
  return 0.0
1051
- total = 0.0
1052
- for i in range(len(unit)):
1053
- part = unit[i: i + 1]
1054
- vals = part.values if hasattr(part, "values") else [0.0]
1055
- units = part.units if hasattr(part, "units") else ["cm"]
1056
- v = vals[0] if vals else 0.0
1057
- u = units[0] if units else "cm"
1058
- if u == "cm":
1059
- total += v
1060
- elif u == "mm":
1061
- total += v / 10.0
1062
- elif u == "inches":
1063
- total += v * 2.54
1064
- elif u == "pt" or u == "points":
1065
- total += v / 72.27 * 2.54
1066
- else:
1067
- # null, npc, etc. — use the numeric value as a rough estimate
1068
- total += v
1069
- return total
1062
+ fn = convert_width if axis == "width" else convert_height
1063
+ cm = fn(unit, "cm", valueOnly=True)
1064
+ return float(np.sum(np.asarray(cm)))