rgrid-python 4.5.3__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/_units.py ADDED
@@ -0,0 +1,1924 @@
1
+ """
2
+ Unit system for grid_py -- Python port of R's grid unit infrastructure.
3
+
4
+ This module provides the fundamental ``Unit`` class and associated helper
5
+ functions that mirror R's ``grid::unit()`` family. A ``Unit`` stores one or
6
+ more scalar values together with their unit types and optional reference data
7
+ (used by contextual units such as ``"strwidth"`` or ``"grobwidth"``).
8
+
9
+ Arithmetic on units follows R semantics:
10
+
11
+ * ``unit + unit`` produces a compound *sum* unit.
12
+ * ``scalar * unit`` (or ``unit * scalar``) scales the numeric values.
13
+ * ``-unit`` negates the numeric values.
14
+ * ``unit / scalar`` divides the numeric values.
15
+
16
+ Absolute-unit conversions (cm, inches, mm, points, picas, ...) are carried
17
+ out eagerly. Context-dependent conversions (npc, native, lines, ...) are
18
+ deferred -- the unit is returned unchanged when no viewport context is
19
+ available.
20
+
21
+ Notes
22
+ -----
23
+ The module is intentionally self-contained so that it can be imported very
24
+ early during package initialisation without circular-dependency issues.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import copy
30
+ import math
31
+ from numbers import Number
32
+ from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
33
+
34
+ import numpy as np
35
+
36
+ __all__ = [
37
+ "Unit",
38
+ "is_unit",
39
+ "unit_type",
40
+ "unit_c",
41
+ "unit_length",
42
+ "unit_pmax",
43
+ "unit_pmin",
44
+ "unit_psum",
45
+ "unit_rep",
46
+ "string_width",
47
+ "string_height",
48
+ "string_ascent",
49
+ "string_descent",
50
+ "absolute_size",
51
+ "convert_unit",
52
+ "convert_x",
53
+ "convert_y",
54
+ "convert_width",
55
+ "convert_height",
56
+ "device_loc",
57
+ "device_dim",
58
+ "convert_theta",
59
+ "unit_summary_min",
60
+ "unit_summary_max",
61
+ "unit_summary_sum",
62
+ ]
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Valid unit type strings
66
+ # ---------------------------------------------------------------------------
67
+
68
+ VALID_UNIT_TYPES: Tuple[str, ...] = (
69
+ "npc",
70
+ "cm",
71
+ "inches",
72
+ "mm",
73
+ "points",
74
+ "picas",
75
+ "bigpts",
76
+ "dida",
77
+ "cicero",
78
+ "scaledpts",
79
+ "lines",
80
+ "char",
81
+ "native",
82
+ "null",
83
+ "snpc",
84
+ "strwidth",
85
+ "strheight",
86
+ "strdescent",
87
+ "strascent",
88
+ "vplayoutwidth",
89
+ "vplayoutheight",
90
+ "grobx",
91
+ "groby",
92
+ "grobwidth",
93
+ "grobheight",
94
+ "grobascent",
95
+ "grobdescent",
96
+ "mylines",
97
+ "mychar",
98
+ "mystrwidth",
99
+ "mystrheight",
100
+ "sum",
101
+ "min",
102
+ "max",
103
+ )
104
+
105
+ # Convenient lookup set for O(1) membership tests
106
+ _VALID_UNIT_SET: frozenset = frozenset(VALID_UNIT_TYPES)
107
+
108
+ # Aliases accepted on input (mapped to canonical names)
109
+ _UNIT_ALIASES: Dict[str, str] = {
110
+ "in": "inches",
111
+ "inch": "inches",
112
+ "centimetre": "cm",
113
+ "centimetres": "cm",
114
+ "centimeter": "cm",
115
+ "centimeters": "cm",
116
+ "millimetre": "mm",
117
+ "millimetres": "mm",
118
+ "millimeter": "mm",
119
+ "millimeters": "mm",
120
+ "point": "points",
121
+ "pt": "points",
122
+ "line": "lines",
123
+ }
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # Absolute-unit conversion factors (everything relative to inches)
127
+ # ---------------------------------------------------------------------------
128
+ # Reference:
129
+ # 1 inch = 2.54 cm = 25.4 mm = 72.27 pt (TeX point)
130
+ # 1 pica = 12 pt
131
+ # 1 bigpt = 1/72 inch (PostScript point)
132
+ # 1 dida = 1238/1157 pt
133
+ # 1 cicero = 12 dida
134
+ # 1 scaledpt = 1/65536 pt
135
+
136
+ _INCHES_PER: Dict[str, float] = {
137
+ "inches": 1.0,
138
+ "cm": 1.0 / 2.54,
139
+ "mm": 1.0 / 25.4,
140
+ "points": 1.0 / 72.27,
141
+ "picas": 12.0 / 72.27,
142
+ "bigpts": 1.0 / 72.0,
143
+ "dida": (1238.0 / 1157.0) / 72.27,
144
+ "cicero": 12.0 * (1238.0 / 1157.0) / 72.27,
145
+ "scaledpts": 1.0 / (72.27 * 65536.0),
146
+ }
147
+
148
+ # Set of unit types that can be converted without a viewport context
149
+ _ABSOLUTE_UNIT_TYPES: frozenset = frozenset(_INCHES_PER.keys())
150
+
151
+ # Unit types resolved by measuring a string or querying a grob
152
+ _STR_METRIC_TYPES: frozenset = frozenset(
153
+ {"strwidth", "strheight", "strascent", "strdescent"}
154
+ )
155
+ _GROB_METRIC_TYPES: frozenset = frozenset(
156
+ {"grobwidth", "grobheight", "grobascent", "grobdescent"}
157
+ )
158
+
159
+
160
+ def _eval_str_metric(unit_type: str, data: Any, scale: float = 1.0) -> float:
161
+ """Evaluate a string-metric unit to an inch value.
162
+
163
+ Mirrors R's ``GEStrWidth`` / ``GEStrHeight`` (src/main/engine.c), which
164
+ back ``stringWidth`` / ``stringHeight`` for text units:
165
+
166
+ - split on ``\\n`` into lines,
167
+ - width = max(per-line widths),
168
+ - height = ink(first line) + (n - 1) × cex × lineheight × fontsize × 1.2 / 72
169
+
170
+ Uses the current viewport's gpar (fontsize, cex, lineheight) to match
171
+ R's behaviour where ``stringWidth`` inherits typography from the
172
+ enclosing gpar context.
173
+
174
+ Uses a lazy import of :func:`._size.calc_string_metric` to avoid
175
+ circular dependencies (``_size`` imports ``Unit`` from this module).
176
+
177
+ Parameters
178
+ ----------
179
+ unit_type : str
180
+ One of ``"strwidth"``, ``"strheight"``, ``"strascent"``,
181
+ ``"strdescent"``.
182
+ data : object
183
+ The string stored as auxiliary data in the unit.
184
+ scale : float
185
+ Multiplicative factor (the unit's numeric value).
186
+
187
+ Returns
188
+ -------
189
+ float
190
+ Measurement in inches, scaled by *scale*.
191
+ """
192
+ from ._size import calc_string_metric # lazy – avoids circular import
193
+
194
+ text = str(data) if data is not None else ""
195
+
196
+ # Inherit fontsize / cex / lineheight from the current viewport gpar
197
+ # stack, matching R's ``stringWidth`` which uses the enclosing gpar.
198
+ try:
199
+ from ._gpar import get_gpar
200
+ gp = get_gpar()
201
+ except Exception:
202
+ gp = None
203
+
204
+ fontsize = 12.0
205
+ cex = 1.0
206
+ lineheight = 1.2
207
+ if gp is not None:
208
+ fs = gp.get("fontsize", None)
209
+ if fs is not None:
210
+ fontsize = float(fs[0] if isinstance(fs, (list, tuple)) else fs)
211
+ cx = gp.get("cex", None)
212
+ if cx is not None:
213
+ cex = float(cx[0] if isinstance(cx, (list, tuple)) else cx)
214
+ lh = gp.get("lineheight", None)
215
+ if lh is not None:
216
+ lineheight = float(lh[0] if isinstance(lh, (list, tuple)) else lh)
217
+
218
+ lines = text.split("\n") if text else [""]
219
+ n = len(lines)
220
+ m0 = calc_string_metric(lines[0], gp=gp)
221
+
222
+ if unit_type == "strwidth":
223
+ w = max(calc_string_metric(ln, gp=gp)["width"] for ln in lines)
224
+ return w * scale
225
+ elif unit_type == "strheight":
226
+ ink_first = m0["ascent"] + m0["descent"]
227
+ inter_line_gap = cex * lineheight * fontsize * 1.2 / 72.0
228
+ return (ink_first + (n - 1) * inter_line_gap) * scale
229
+ elif unit_type == "strascent":
230
+ return m0["ascent"] * scale
231
+ elif unit_type == "strdescent":
232
+ return m0["descent"] * scale
233
+ return 0.0 # pragma: no cover
234
+
235
+
236
+ def _eval_grob_metric(unit_type: str, grob: Any) -> Optional["Unit"]:
237
+ """Evaluate a grob-metric unit by calling the appropriate detail dispatcher.
238
+
239
+ Uses a lazy import of dispatchers from :mod:`._size` to avoid
240
+ circular dependencies.
241
+
242
+ Parameters
243
+ ----------
244
+ unit_type : str
245
+ One of ``"grobwidth"``, ``"grobheight"``, ``"grobascent"``,
246
+ ``"grobdescent"``.
247
+ grob : object
248
+ The grob stored as auxiliary data in the unit.
249
+
250
+ Returns
251
+ -------
252
+ Unit or None
253
+ The measured dimension as a :class:`Unit`, or ``None`` if the
254
+ grob is ``None``.
255
+ """
256
+ from ._size import ( # lazy – avoids circular import
257
+ width_details,
258
+ height_details,
259
+ ascent_details,
260
+ descent_details,
261
+ )
262
+
263
+ if grob is None:
264
+ return None
265
+ _dispatch = {
266
+ "grobwidth": width_details,
267
+ "grobheight": height_details,
268
+ "grobascent": ascent_details,
269
+ "grobdescent": descent_details,
270
+ }
271
+ return _dispatch[unit_type](grob)
272
+
273
+
274
+ def _try_resolve_with_renderer(
275
+ x: "Unit",
276
+ i: int,
277
+ src_unit: str,
278
+ target: str,
279
+ axis: str,
280
+ type_: str,
281
+ ) -> Optional[float]:
282
+ """Resolve a context-dependent unit via the active renderer.
283
+
284
+ Implements R's ``L_convert`` two-stage pipeline (grid.c:1384-1575):
285
+ Stage 1: any unit → inches (via renderer._resolve_to_inches_idx)
286
+ Stage 2: inches → target (via _vp_calc inverse transforms)
287
+
288
+ Returns the converted value in *target* units, or ``None`` if no
289
+ renderer is available.
290
+ """
291
+ from ._state import get_state
292
+ from ._vp_calc import (
293
+ _transform_xy_from_inches,
294
+ _transform_wh_from_inches,
295
+ _transform_xy_to_npc,
296
+ _transform_wh_to_npc,
297
+ _transform_xy_from_npc,
298
+ _transform_wh_from_npc,
299
+ )
300
+
301
+ state = get_state()
302
+ renderer = state.get_renderer()
303
+
304
+ if renderer is None or not hasattr(renderer, "_resolve_to_inches_idx"):
305
+ return None
306
+
307
+ # Build a single-element Unit for the source
308
+ elem = Unit(x._values[i], src_unit, data=x._data[i])
309
+ is_dim = type_ in ("dimension",)
310
+
311
+ # Get viewport context from renderer
312
+ vtr = renderer._vp_transform_stack[-1]
313
+ vpc = vtr.vpc
314
+
315
+ # Determine axis parameters (R grid.c:1426-1427)
316
+ # axis encoding: 0=x-loc, 1=y-loc, 2=x-dim, 3=y-dim
317
+ if axis == "x":
318
+ scalemin, scalemax = vpc.xscalemin, vpc.xscalemax
319
+ this_cm = vtr.width_cm
320
+ other_cm = vtr.height_cm
321
+ else:
322
+ scalemin, scalemax = vpc.yscalemin, vpc.yscalemax
323
+ this_cm = vtr.height_cm
324
+ other_cm = vtr.width_cm
325
+
326
+ # R grid.c:1438-1444 -- special case: relative-to-relative with zero dim
327
+ from_is_relative = src_unit in ("native", "npc")
328
+ to_is_relative = target in ("native", "npc")
329
+ rel_convert = (from_is_relative and to_is_relative
330
+ and this_cm < 1e-6)
331
+
332
+ # Stage 1: convert source → inches (or NPC for relConvert)
333
+ if rel_convert:
334
+ if is_dim:
335
+ stage1 = _transform_wh_to_npc(
336
+ float(x._values[i]), src_unit, scalemin, scalemax)
337
+ else:
338
+ stage1 = _transform_xy_to_npc(
339
+ float(x._values[i]), src_unit, scalemin, scalemax)
340
+ else:
341
+ # Use renderer's full pipeline to get inches
342
+ gp = state.get_gpar()
343
+ stage1 = renderer._resolve_to_inches_idx(elem, 0, axis, is_dim, gp)
344
+
345
+ # Stage 2: inches (or NPC) → target unit
346
+ if rel_convert:
347
+ if is_dim:
348
+ return _transform_wh_from_npc(stage1, target, scalemin, scalemax)
349
+ else:
350
+ return _transform_xy_from_npc(stage1, target, scalemin, scalemax)
351
+ else:
352
+ fontsize, cex, lineheight = renderer._gpar_font_params(state.get_gpar())
353
+ scale = renderer._get_scale()
354
+ if is_dim:
355
+ return _transform_wh_from_inches(
356
+ stage1, target, scalemin, scalemax,
357
+ fontsize, cex, lineheight,
358
+ this_cm, other_cm, scale,
359
+ )
360
+ else:
361
+ return _transform_xy_from_inches(
362
+ stage1, target, scalemin, scalemax,
363
+ fontsize, cex, lineheight,
364
+ this_cm, other_cm, scale,
365
+ )
366
+
367
+
368
+ def _resolve_alias(unit_str: str) -> str:
369
+ """Return the canonical unit-type string, resolving common aliases.
370
+
371
+ Parameters
372
+ ----------
373
+ unit_str : str
374
+ Raw unit name (e.g. ``"in"``, ``"pt"``, ``"centimeters"``).
375
+
376
+ Returns
377
+ -------
378
+ str
379
+ Canonical unit name (e.g. ``"inches"``, ``"points"``, ``"cm"``).
380
+
381
+ Raises
382
+ ------
383
+ ValueError
384
+ If *unit_str* is not a recognised unit type or alias.
385
+ """
386
+ low = unit_str.strip().lower()
387
+ if low in _VALID_UNIT_SET:
388
+ return low
389
+ if low in _UNIT_ALIASES:
390
+ return _UNIT_ALIASES[low]
391
+ raise ValueError(
392
+ f"Unknown unit type {unit_str!r}. "
393
+ f"Valid types: {', '.join(sorted(VALID_UNIT_TYPES))}"
394
+ )
395
+
396
+
397
+ # ---------------------------------------------------------------------------
398
+ # The Unit class
399
+ # ---------------------------------------------------------------------------
400
+
401
+
402
+ class Unit:
403
+ """Representation of one or more grid unit values.
404
+
405
+ A ``Unit`` bundles numeric *values* with their *unit types* and optional
406
+ *data* references (e.g. a string for ``"strwidth"`` or a grob for
407
+ ``"grobwidth"``). It mirrors R's ``grid::unit`` objects.
408
+
409
+ Parameters
410
+ ----------
411
+ x : float, int, Sequence[float], or np.ndarray
412
+ Numeric value(s). Scalars are promoted to length-1 arrays.
413
+ units : str or Sequence[str]
414
+ Unit type(s). A single string is recycled to match the length of *x*.
415
+ data : Any or Sequence[Any], optional
416
+ Auxiliary data attached to each element (used by contextual units
417
+ such as ``"strwidth"``). ``None`` entries are allowed.
418
+
419
+ Raises
420
+ ------
421
+ ValueError
422
+ If *x* or *units* are empty, or if *units* contains an unknown type.
423
+
424
+ Examples
425
+ --------
426
+ >>> u = Unit(1, "cm")
427
+ >>> u
428
+ Unit([1.0], ['cm'])
429
+ >>> Unit([0.5, 1.0], ["npc", "cm"])
430
+ Unit([0.5, 1.0], ['npc', 'cm'])
431
+ """
432
+
433
+ # ---- internal slots ----------------------------------------------------
434
+ __slots__ = ("_values", "_units", "_data", "_is_absolute")
435
+
436
+ # ------------------------------------------------------------------ init
437
+ def __init__(
438
+ self,
439
+ x: Union[float, int, Sequence[float], np.ndarray],
440
+ units: Union[str, Sequence[str]],
441
+ data: Optional[Union[Any, Sequence[Any]]] = None,
442
+ ) -> None:
443
+ # If *x* is already a Unit, return a shallow copy (mirrors R behaviour
444
+ # where ``unit(u)`` simply returns *u*).
445
+ if isinstance(x, Unit):
446
+ self._values = x._values.copy()
447
+ self._units = list(x._units)
448
+ self._data = list(x._data)
449
+ self._is_absolute = x._is_absolute
450
+ return
451
+
452
+ # Coerce values --------------------------------------------------
453
+ if isinstance(x, np.ndarray):
454
+ vals = x.astype(np.float64).ravel()
455
+ elif isinstance(x, (list, tuple)):
456
+ vals = np.asarray(x, dtype=np.float64)
457
+ else:
458
+ vals = np.asarray([x], dtype=np.float64)
459
+
460
+ if vals.size == 0:
461
+ raise ValueError("'x' must have length > 0")
462
+
463
+ # Coerce units ---------------------------------------------------
464
+ if isinstance(units, str):
465
+ resolved = _resolve_alias(units)
466
+ unit_list = [resolved] * len(vals)
467
+ else:
468
+ unit_list = [_resolve_alias(u) for u in units]
469
+ if len(unit_list) == 0:
470
+ raise ValueError("'units' must have length > 0")
471
+ # Recycle to match length of vals
472
+ if len(unit_list) < len(vals):
473
+ reps = math.ceil(len(vals) / len(unit_list))
474
+ unit_list = (unit_list * reps)[: len(vals)]
475
+ elif len(unit_list) > len(vals):
476
+ # Recycle values to match units length
477
+ reps = math.ceil(len(unit_list) / len(vals))
478
+ vals = np.tile(vals, reps)[: len(unit_list)]
479
+
480
+ # Coerce data ----------------------------------------------------
481
+ if data is None:
482
+ data_list: List[Any] = [None] * len(unit_list)
483
+ elif isinstance(data, (list, tuple)):
484
+ data_list = list(data)
485
+ if len(data_list) < len(unit_list):
486
+ reps = math.ceil(len(unit_list) / max(len(data_list), 1))
487
+ data_list = (data_list * reps)[: len(unit_list)]
488
+ else:
489
+ data_list = [data] * len(unit_list)
490
+
491
+ self._values: np.ndarray = vals
492
+ self._units: List[str] = unit_list
493
+ self._data: List[Any] = data_list
494
+ self._is_absolute: bool = all(u in _ABSOLUTE_UNIT_TYPES for u in unit_list)
495
+
496
+ # ---------------------------------------------------------------- properties
497
+ @property
498
+ def values(self) -> np.ndarray:
499
+ """Numeric values as a 1-D ``numpy.float64`` array."""
500
+ return self._values
501
+
502
+ @property
503
+ def units_list(self) -> List[str]:
504
+ """List of unit-type strings (one per element)."""
505
+ return self._units
506
+
507
+ @property
508
+ def data(self) -> List[Any]:
509
+ """Auxiliary data list (one entry per element; may contain ``None``)."""
510
+ return self._data
511
+
512
+ # ---------------------------------------------------------------- length
513
+ def __len__(self) -> int:
514
+ return len(self._values)
515
+
516
+ # ---------------------------------------------------------------- repr / str
517
+ def __repr__(self) -> str:
518
+ vals = [float(v) for v in self._values]
519
+ return f"Unit({vals}, {self._units})"
520
+
521
+ def __str__(self) -> str:
522
+ return self.as_character()
523
+
524
+ def as_character(self) -> str:
525
+ """Return an R-compatible character representation.
526
+
527
+ Returns
528
+ -------
529
+ str
530
+ A string such as ``"1cm"`` or ``"0.5npc+1cm"`` when the unit
531
+ contains a compound *sum* / *min* / *max* type.
532
+
533
+ Examples
534
+ --------
535
+ >>> Unit(2.5, "cm").as_character()
536
+ '2.5cm'
537
+ """
538
+ parts: List[str] = []
539
+ for i in range(len(self._values)):
540
+ parts.append(self._desc_element(i))
541
+ return ", ".join(parts)
542
+
543
+ def _desc_element(self, idx: int) -> str:
544
+ """Format a single element as an R-style string."""
545
+ val = self._values[idx]
546
+ utype = self._units[idx]
547
+ d = self._data[idx]
548
+
549
+ if utype in ("sum", "min", "max"):
550
+ # Compound unit -- data should be a Unit
551
+ if isinstance(d, Unit):
552
+ inner = ", ".join(d._desc_element(j) for j in range(len(d)))
553
+ prefix = "" if val == 1.0 else f"{val}*"
554
+ return f"{prefix}{utype}({inner})"
555
+ # Fallback
556
+ return f"{val}{utype}"
557
+
558
+ # String-based units include data in the representation
559
+ if utype in (
560
+ "strwidth",
561
+ "strheight",
562
+ "strascent",
563
+ "strdescent",
564
+ "mystrwidth",
565
+ "mystrheight",
566
+ ) and d is not None:
567
+ return f"{val}{utype}({d!r})"
568
+
569
+ # Grob-based units
570
+ if utype in (
571
+ "grobx",
572
+ "groby",
573
+ "grobwidth",
574
+ "grobheight",
575
+ "grobascent",
576
+ "grobdescent",
577
+ ) and d is not None:
578
+ return f"{val}{utype}({d!r})"
579
+
580
+ return f"{val}{utype}"
581
+
582
+ # ---------------------------------------------------------------- indexing
583
+ def __getitem__(self, index: Union[int, slice, Sequence[int]]) -> "Unit":
584
+ """Return a new ``Unit`` containing the selected element(s).
585
+
586
+ Parameters
587
+ ----------
588
+ index : int, slice, or sequence of int
589
+ Element selector.
590
+
591
+ Returns
592
+ -------
593
+ Unit
594
+ A new unit with the selected elements.
595
+
596
+ Raises
597
+ ------
598
+ IndexError
599
+ If *index* is out of range.
600
+ """
601
+ if isinstance(index, (int, np.integer)):
602
+ if index < 0:
603
+ index = len(self) + index
604
+ if index < 0 or index >= len(self):
605
+ raise IndexError(
606
+ f"index {index} is out of bounds for Unit of length {len(self)}"
607
+ )
608
+ new = Unit.__new__(Unit)
609
+ new._values = self._values[index : index + 1].copy()
610
+ new._units = [self._units[index]]
611
+ new._data = [self._data[index]]
612
+ new._is_absolute = self._units[index] in _ABSOLUTE_UNIT_TYPES
613
+ return new
614
+
615
+ if isinstance(index, slice):
616
+ indices = range(*index.indices(len(self)))
617
+ else:
618
+ indices = list(index)
619
+
620
+ vals = self._values[index] if isinstance(index, slice) else self._values[list(indices)]
621
+ u_list = [self._units[i] for i in indices]
622
+ d_list = [self._data[i] for i in indices]
623
+
624
+ new = Unit.__new__(Unit)
625
+ new._values = vals.copy() if isinstance(vals, np.ndarray) else np.asarray(vals, dtype=np.float64)
626
+ new._units = u_list
627
+ new._data = d_list
628
+ new._is_absolute = all(u in _ABSOLUTE_UNIT_TYPES for u in u_list)
629
+ return new
630
+
631
+ def __setitem__(
632
+ self, index: Union[int, slice], value: "Unit"
633
+ ) -> None:
634
+ """Set element(s) of this unit in-place.
635
+
636
+ Parameters
637
+ ----------
638
+ index : int or slice
639
+ Element selector.
640
+ value : Unit
641
+ Replacement unit value(s).
642
+ """
643
+ if not isinstance(value, Unit):
644
+ raise TypeError("replacement value must be a Unit")
645
+
646
+ if isinstance(index, (int, np.integer)):
647
+ if index < 0:
648
+ index = len(self) + index
649
+ self._values[index] = value._values[0]
650
+ self._units[index] = value._units[0]
651
+ self._data[index] = value._data[0]
652
+ elif isinstance(index, slice):
653
+ indices = range(*index.indices(len(self)))
654
+ for j, i in enumerate(indices):
655
+ src = j % len(value)
656
+ self._values[i] = value._values[src]
657
+ self._units[i] = value._units[src]
658
+ self._data[i] = value._data[src]
659
+ else:
660
+ raise TypeError(f"unsupported index type {type(index)}")
661
+
662
+ self._is_absolute = all(u in _ABSOLUTE_UNIT_TYPES for u in self._units)
663
+
664
+ # ================================================================
665
+ # Arithmetic operators
666
+ # ================================================================
667
+
668
+ # ---- addition: unit + unit -> compound sum -------------------------
669
+ def __add__(self, other: Any) -> "Unit":
670
+ if isinstance(other, Unit):
671
+ return _make_compound("sum", self, other)
672
+ return NotImplemented
673
+
674
+ def __radd__(self, other: Any) -> "Unit":
675
+ if isinstance(other, Unit):
676
+ return _make_compound("sum", other, self)
677
+ if other == 0:
678
+ # Supports sum() on iterables of Units
679
+ return self.copy()
680
+ return NotImplemented
681
+
682
+ # ---- subtraction: unit - unit -> compound sum (with negated rhs) ---
683
+ def __sub__(self, other: Any) -> "Unit":
684
+ if isinstance(other, Unit):
685
+ return _make_compound("sum", self, -other)
686
+ return NotImplemented
687
+
688
+ def __rsub__(self, other: Any) -> "Unit":
689
+ if isinstance(other, Unit):
690
+ return _make_compound("sum", other, -self)
691
+ return NotImplemented
692
+
693
+ # ---- negation ------------------------------------------------------
694
+ def __neg__(self) -> "Unit":
695
+ new = self.copy()
696
+ new._values = -new._values
697
+ return new
698
+
699
+ def __pos__(self) -> "Unit":
700
+ return self.copy()
701
+
702
+ # ---- multiplication: scalar * unit or unit * scalar ----------------
703
+ def __mul__(self, other: Any) -> "Unit":
704
+ if isinstance(other, Number) and not isinstance(other, bool):
705
+ new = self.copy()
706
+ new._values = new._values * float(other)
707
+ return new
708
+ if isinstance(other, Unit):
709
+ raise TypeError("Cannot multiply two Unit objects; one operand must be numeric")
710
+ return NotImplemented
711
+
712
+ def __rmul__(self, other: Any) -> "Unit":
713
+ if isinstance(other, Number) and not isinstance(other, bool):
714
+ return self.__mul__(other)
715
+ return NotImplemented
716
+
717
+ # ---- division: unit / scalar ---------------------------------------
718
+ def __truediv__(self, other: Any) -> "Unit":
719
+ if isinstance(other, Number) and not isinstance(other, bool):
720
+ if float(other) == 0.0:
721
+ raise ZeroDivisionError("division by zero")
722
+ new = self.copy()
723
+ new._values = new._values / float(other)
724
+ return new
725
+ if isinstance(other, Unit):
726
+ raise TypeError("Cannot divide by a Unit object")
727
+ return NotImplemented
728
+
729
+ def __rtruediv__(self, other: Any) -> "Unit":
730
+ raise TypeError("Cannot divide by a Unit object")
731
+
732
+ # ---- equality (element-wise, mainly for testing) -------------------
733
+ def __eq__(self, other: object) -> bool:
734
+ if not isinstance(other, Unit):
735
+ return NotImplemented
736
+ if len(self) != len(other):
737
+ return False
738
+ return (
739
+ np.allclose(self._values, other._values)
740
+ and self._units == other._units
741
+ )
742
+
743
+ def __ne__(self, other: object) -> bool:
744
+ eq = self.__eq__(other)
745
+ if eq is NotImplemented:
746
+ return NotImplemented # type: ignore[return-value]
747
+ return not eq
748
+
749
+ # ================================================================
750
+ # Helpers
751
+ # ================================================================
752
+
753
+ def copy(self) -> "Unit":
754
+ """Return a shallow copy of this unit.
755
+
756
+ Returns
757
+ -------
758
+ Unit
759
+ Independent copy sharing no mutable state with the original.
760
+ """
761
+ new = Unit.__new__(Unit)
762
+ new._values = self._values.copy()
763
+ new._units = list(self._units)
764
+ new._data = list(self._data)
765
+ new._is_absolute = self._is_absolute
766
+ return new
767
+
768
+ def is_absolute(self) -> bool:
769
+ """Return ``True`` if every element is an absolute (physical) unit.
770
+
771
+ Returns
772
+ -------
773
+ bool
774
+ """
775
+ return self._is_absolute
776
+
777
+ # Allow hashing to fail (mutable container)
778
+ __hash__ = None # type: ignore[assignment]
779
+
780
+
781
+ # ---------------------------------------------------------------------------
782
+ # Compound-unit helper
783
+ # ---------------------------------------------------------------------------
784
+
785
+
786
+ def _make_compound(op: str, lhs: Unit, rhs: Unit) -> Unit:
787
+ """Create a compound unit (*sum*, *min*, or *max*) from two operands.
788
+
789
+ If both operands share identical simple absolute unit types the operation
790
+ is performed eagerly (e.g. ``1cm + 2cm -> 3cm``).
791
+
792
+ Parameters
793
+ ----------
794
+ op : str
795
+ One of ``"sum"``, ``"min"``, ``"max"``.
796
+ lhs : Unit
797
+ Left-hand operand.
798
+ rhs : Unit
799
+ Right-hand operand.
800
+
801
+ Returns
802
+ -------
803
+ Unit
804
+ Resulting (possibly compound) unit.
805
+ """
806
+ # Fast path: identical simple unit types -- compute eagerly
807
+ if (
808
+ len(set(lhs._units)) == 1
809
+ and len(set(rhs._units)) == 1
810
+ and lhs._units[0] == rhs._units[0]
811
+ and lhs._units[0] not in ("sum", "min", "max")
812
+ ):
813
+ utype = lhs._units[0]
814
+ # Recycle to common length
815
+ n = max(len(lhs), len(rhs))
816
+ lv = np.resize(lhs._values, n)
817
+ rv = np.resize(rhs._values, n)
818
+ if op == "sum":
819
+ vals = lv + rv
820
+ elif op == "min":
821
+ vals = np.minimum(lv, rv)
822
+ else:
823
+ vals = np.maximum(lv, rv)
824
+ new = Unit.__new__(Unit)
825
+ new._values = vals
826
+ new._units = [utype] * n
827
+ new._data = [None] * n
828
+ new._is_absolute = utype in _ABSOLUTE_UNIT_TYPES
829
+ return new
830
+
831
+ # General path: build a compound unit for each parallel pair
832
+ n = max(len(lhs), len(rhs))
833
+ compound_vals = np.ones(n, dtype=np.float64)
834
+ compound_units: List[str] = [op] * n
835
+ compound_data: List[Any] = []
836
+
837
+ for i in range(n):
838
+ li = i % len(lhs)
839
+ ri = i % len(rhs)
840
+ pair = unit_c(lhs[li], rhs[ri])
841
+ compound_data.append(pair)
842
+
843
+ new = Unit.__new__(Unit)
844
+ new._values = compound_vals
845
+ new._units = compound_units
846
+ new._data = compound_data
847
+ new._is_absolute = False
848
+ return new
849
+
850
+
851
+ # ===================================================================
852
+ # Module-level helper functions
853
+ # ===================================================================
854
+
855
+
856
+ def is_unit(x: Any) -> bool:
857
+ """Check whether *x* is a ``Unit`` instance.
858
+
859
+ Parameters
860
+ ----------
861
+ x : Any
862
+ Object to test.
863
+
864
+ Returns
865
+ -------
866
+ bool
867
+ ``True`` if *x* is a ``Unit``.
868
+
869
+ Examples
870
+ --------
871
+ >>> is_unit(Unit(1, "cm"))
872
+ True
873
+ >>> is_unit(42)
874
+ False
875
+ """
876
+ return isinstance(x, Unit)
877
+
878
+
879
+ def unit_type(x: Unit, recurse: bool = False) -> Union[str, List[str], List[Any]]:
880
+ """Return the unit type(s) of *x*.
881
+
882
+ Port of R ``unitType()`` (unit.R:197-226).
883
+
884
+ Parameters
885
+ ----------
886
+ x : Unit
887
+ A unit object.
888
+ recurse : bool
889
+ If ``True``, compound units (sum/min/max) are recursively
890
+ expanded to reveal the underlying unit types. Returns a list
891
+ of lists for compound elements. Default ``False``.
892
+
893
+ Returns
894
+ -------
895
+ str or list
896
+ When *recurse* is ``False``: a single string (length 1) or
897
+ list of strings.
898
+ When *recurse* is ``True``: a list where compound elements
899
+ are themselves lists of their constituent unit types.
900
+
901
+ Raises
902
+ ------
903
+ TypeError
904
+ If *x* is not a ``Unit``.
905
+
906
+ Examples
907
+ --------
908
+ >>> unit_type(Unit(1, "cm"))
909
+ 'cm'
910
+ >>> unit_type(Unit([1, 2], ["cm", "inches"]))
911
+ ['cm', 'inches']
912
+ """
913
+ if not isinstance(x, Unit):
914
+ raise TypeError("x must be a Unit")
915
+
916
+ if not recurse:
917
+ if len(x) == 1:
918
+ return x._units[0]
919
+ return list(x._units)
920
+
921
+ # recurse=True: expand compound units (R unit.R:211-224)
922
+ result = []
923
+ for i in range(len(x)):
924
+ utype = x._units[i]
925
+ if utype in ("sum", "min", "max"):
926
+ # Compound unit: recurse into the child Unit stored in _data
927
+ child = x._data[i]
928
+ if isinstance(child, Unit):
929
+ result.append(unit_type(child, recurse=True))
930
+ else:
931
+ result.append(utype)
932
+ else:
933
+ result.append(utype)
934
+ return result
935
+
936
+
937
+ def unit_c(*args: Unit) -> Unit:
938
+ """Concatenate one or more ``Unit`` objects into a single ``Unit``.
939
+
940
+ Parameters
941
+ ----------
942
+ *args : Unit
943
+ Units to concatenate.
944
+
945
+ Returns
946
+ -------
947
+ Unit
948
+ A new unit containing all elements in order.
949
+
950
+ Raises
951
+ ------
952
+ TypeError
953
+ If any argument is not a ``Unit``.
954
+ ValueError
955
+ If no arguments are provided.
956
+
957
+ Examples
958
+ --------
959
+ >>> unit_c(Unit(1, "cm"), Unit(2, "inches"))
960
+ Unit([1.0, 2.0], ['cm', 'inches'])
961
+ """
962
+ if len(args) == 0:
963
+ raise ValueError("unit_c requires at least one argument")
964
+
965
+ all_vals: List[np.ndarray] = []
966
+ all_units: List[str] = []
967
+ all_data: List[Any] = []
968
+
969
+ for a in args:
970
+ if not isinstance(a, Unit):
971
+ raise TypeError(f"All arguments must be Unit objects, got {type(a)}")
972
+ all_vals.append(a._values)
973
+ all_units.extend(a._units)
974
+ all_data.extend(a._data)
975
+
976
+ new = Unit.__new__(Unit)
977
+ new._values = np.concatenate(all_vals)
978
+ new._units = all_units
979
+ new._data = all_data
980
+ new._is_absolute = all(u in _ABSOLUTE_UNIT_TYPES for u in all_units)
981
+ return new
982
+
983
+
984
+ def unit_length(x: Unit) -> int:
985
+ """Return the number of elements in a ``Unit``.
986
+
987
+ Parameters
988
+ ----------
989
+ x : Unit
990
+ A unit object.
991
+
992
+ Returns
993
+ -------
994
+ int
995
+ Number of unit values.
996
+
997
+ Examples
998
+ --------
999
+ >>> unit_length(Unit([1, 2, 3], "cm"))
1000
+ 3
1001
+ """
1002
+ if not isinstance(x, Unit):
1003
+ raise TypeError("x must be a Unit")
1004
+ return len(x)
1005
+
1006
+
1007
+ def unit_pmax(*args: Unit) -> Unit:
1008
+ """Parallel (element-wise) maximum of units.
1009
+
1010
+ Parameters
1011
+ ----------
1012
+ *args : Unit
1013
+ Two or more units of the same length.
1014
+
1015
+ Returns
1016
+ -------
1017
+ Unit
1018
+ Element-wise maximum (compound *max* units when types differ).
1019
+
1020
+ Examples
1021
+ --------
1022
+ >>> unit_pmax(Unit([1, 4], "cm"), Unit([3, 2], "cm"))
1023
+ Unit([3.0, 4.0], ['cm', 'cm'])
1024
+ """
1025
+ return _parallel_op("max", *args)
1026
+
1027
+
1028
+ def unit_pmin(*args: Unit) -> Unit:
1029
+ """Parallel (element-wise) minimum of units.
1030
+
1031
+ Parameters
1032
+ ----------
1033
+ *args : Unit
1034
+ Two or more units of the same length.
1035
+
1036
+ Returns
1037
+ -------
1038
+ Unit
1039
+ Element-wise minimum (compound *min* units when types differ).
1040
+
1041
+ Examples
1042
+ --------
1043
+ >>> unit_pmin(Unit([1, 4], "cm"), Unit([3, 2], "cm"))
1044
+ Unit([1.0, 2.0], ['cm', 'cm'])
1045
+ """
1046
+ return _parallel_op("min", *args)
1047
+
1048
+
1049
+ def unit_psum(*args: Unit) -> Unit:
1050
+ """Parallel (element-wise) sum of units.
1051
+
1052
+ Parameters
1053
+ ----------
1054
+ *args : Unit
1055
+ Two or more units of the same length.
1056
+
1057
+ Returns
1058
+ -------
1059
+ Unit
1060
+ Element-wise sum (compound *sum* units when types differ).
1061
+
1062
+ Examples
1063
+ --------
1064
+ >>> unit_psum(Unit([1, 2], "cm"), Unit([3, 4], "cm"))
1065
+ Unit([4.0, 6.0], ['cm', 'cm'])
1066
+ """
1067
+ return _parallel_op("sum", *args)
1068
+
1069
+
1070
+ def _parallel_op(op: str, *args: Unit) -> Unit:
1071
+ """Internal implementation for parallel min / max / sum.
1072
+
1073
+ Parameters
1074
+ ----------
1075
+ op : str
1076
+ ``"sum"``, ``"min"``, or ``"max"``.
1077
+ *args : Unit
1078
+ Units to combine element-wise.
1079
+
1080
+ Returns
1081
+ -------
1082
+ Unit
1083
+ """
1084
+ if len(args) == 0:
1085
+ raise ValueError(f"unit_p{op} requires at least one argument")
1086
+ if len(args) == 1:
1087
+ return args[0].copy()
1088
+
1089
+ result = args[0]
1090
+ for a in args[1:]:
1091
+ result = _make_compound(op, result, a)
1092
+ return result
1093
+
1094
+
1095
+ def unit_rep(
1096
+ x: Unit,
1097
+ times: int = 1,
1098
+ length_out: Optional[int] = None,
1099
+ each: int = 1,
1100
+ ) -> Unit:
1101
+ """Repeat a ``Unit`` object.
1102
+
1103
+ Port of R ``rep.unit()`` (unit.R:539-542). Mirrors the semantics
1104
+ of R's ``rep(x, times, length.out, each)``.
1105
+
1106
+ Parameters
1107
+ ----------
1108
+ x : Unit
1109
+ The unit to repeat.
1110
+ times : int
1111
+ Number of times to repeat the (possibly each-expanded) unit.
1112
+ length_out : int or None
1113
+ If given, truncate or recycle the result to this length.
1114
+ each : int
1115
+ Replicate each element *each* times before tiling.
1116
+
1117
+ Returns
1118
+ -------
1119
+ Unit
1120
+ A unit whose elements are *x* repeated.
1121
+
1122
+ Examples
1123
+ --------
1124
+ >>> unit_rep(Unit(1, "cm"), 3)
1125
+ Unit([1.0, 1.0, 1.0], ['cm', 'cm', 'cm'])
1126
+ >>> unit_rep(Unit([1, 2], "cm"), each=2)
1127
+ Unit([1.0, 1.0, 2.0, 2.0], ['cm', 'cm', 'cm', 'cm'])
1128
+ >>> unit_rep(Unit([1, 2, 3], "cm"), length_out=5)
1129
+ Unit([1.0, 2.0, 3.0, 1.0, 2.0], ['cm', 'cm', 'cm', 'cm', 'cm'])
1130
+ """
1131
+ if not isinstance(x, Unit):
1132
+ raise TypeError("x must be a Unit")
1133
+
1134
+ # Build index vector mirroring R's rep(seq_along(x), times, length.out, each)
1135
+ n = len(x)
1136
+ base = list(range(n))
1137
+
1138
+ # Apply each: replicate each element
1139
+ if each > 1:
1140
+ base = [i for i in base for _ in range(each)]
1141
+
1142
+ # Apply times: tile the whole sequence (times=0 → empty)
1143
+ if times == 0:
1144
+ base = []
1145
+ elif times > 1:
1146
+ base = base * times
1147
+
1148
+ # Apply length_out: truncate or recycle
1149
+ if length_out is not None:
1150
+ if length_out <= 0:
1151
+ base = []
1152
+ elif len(base) == 0:
1153
+ # Recycle from original if times made it empty
1154
+ base = list(range(n))
1155
+ full_cycles = length_out // len(base)
1156
+ remainder = length_out % len(base)
1157
+ base = base * full_cycles + base[:remainder]
1158
+ else:
1159
+ full_cycles = length_out // len(base)
1160
+ remainder = length_out % len(base)
1161
+ base = base * full_cycles + base[:remainder]
1162
+
1163
+ if len(base) == 0:
1164
+ # Return empty Unit
1165
+ new = Unit.__new__(Unit)
1166
+ new._values = np.array([], dtype=np.float64)
1167
+ new._units = []
1168
+ new._data = []
1169
+ new._is_absolute = False
1170
+ return new
1171
+
1172
+ return x[base]
1173
+
1174
+
1175
+ # ===================================================================
1176
+ # Summary.unit: min / max / sum (port of R unit.R:300-347)
1177
+ # ===================================================================
1178
+
1179
+
1180
+ def unit_summary_min(*args: Unit) -> Unit:
1181
+ """Return the minimum across all elements of all input units.
1182
+
1183
+ Port of R ``Summary.unit`` for ``min`` (unit.R:300-347).
1184
+ Unlike :func:`unit_pmin` (element-wise), this returns a single
1185
+ scalar unit representing the global minimum.
1186
+
1187
+ Parameters
1188
+ ----------
1189
+ *args : Unit
1190
+ One or more unit objects.
1191
+
1192
+ Returns
1193
+ -------
1194
+ Unit
1195
+ A single-element unit with the minimum value.
1196
+
1197
+ Examples
1198
+ --------
1199
+ >>> unit_summary_min(Unit([3, 1, 4], "cm"))
1200
+ Unit([1.0], ['cm'])
1201
+ """
1202
+ return _summary_op("min", *args)
1203
+
1204
+
1205
+ def unit_summary_max(*args: Unit) -> Unit:
1206
+ """Return the maximum across all elements of all input units.
1207
+
1208
+ Port of R ``Summary.unit`` for ``max`` (unit.R:300-347).
1209
+
1210
+ Parameters
1211
+ ----------
1212
+ *args : Unit
1213
+ One or more unit objects.
1214
+
1215
+ Returns
1216
+ -------
1217
+ Unit
1218
+ A single-element unit with the maximum value.
1219
+
1220
+ Examples
1221
+ --------
1222
+ >>> unit_summary_max(Unit([3, 1, 4], "cm"))
1223
+ Unit([4.0], ['cm'])
1224
+ """
1225
+ return _summary_op("max", *args)
1226
+
1227
+
1228
+ def unit_summary_sum(*args: Unit) -> Unit:
1229
+ """Return the sum of all elements of all input units.
1230
+
1231
+ Port of R ``Summary.unit`` for ``sum`` (unit.R:300-347).
1232
+
1233
+ Parameters
1234
+ ----------
1235
+ *args : Unit
1236
+ One or more unit objects.
1237
+
1238
+ Returns
1239
+ -------
1240
+ Unit
1241
+ A single-element unit with the total sum.
1242
+
1243
+ Examples
1244
+ --------
1245
+ >>> unit_summary_sum(Unit([1, 2, 3], "cm"))
1246
+ Unit([6.0], ['cm'])
1247
+ """
1248
+ return _summary_op("sum", *args)
1249
+
1250
+
1251
+ def _summary_op(op: str, *args: Unit) -> Unit:
1252
+ """Internal implementation for Summary.unit (min/max/sum).
1253
+
1254
+ Port of R ``Summary.unit`` (unit.R:300-347).
1255
+
1256
+ Optimisation: if all elements across all inputs share the same
1257
+ simple unit type, the operation is applied directly on the numeric
1258
+ values. Otherwise, a compound unit is created.
1259
+ """
1260
+ if len(args) == 0:
1261
+ raise ValueError(f"unit {op} requires at least one argument")
1262
+
1263
+ # Filter out None args (R: units[!vapply(units, is.null, ...)])
1264
+ units = [a for a in args if a is not None and isinstance(a, Unit)]
1265
+ if len(units) == 0:
1266
+ raise ValueError(f"unit {op} requires at least one Unit argument")
1267
+
1268
+ # Concatenate all elements
1269
+ combined = unit_c(*units)
1270
+
1271
+ # Optimisation: identical simple unit types (R unit.R:308-320)
1272
+ all_types = set(combined._units)
1273
+ if len(all_types) == 1 and list(all_types)[0] not in ("sum", "min", "max"):
1274
+ utype = combined._units[0]
1275
+ vals = combined._values
1276
+ if op == "sum":
1277
+ result_val = float(np.sum(vals))
1278
+ elif op == "min":
1279
+ result_val = float(np.min(vals))
1280
+ else: # max
1281
+ result_val = float(np.max(vals))
1282
+ return Unit(result_val, utype)
1283
+
1284
+ # General case: create compound unit (R unit.R:321-347)
1285
+ # The compound wraps all elements under a single min/max/sum operation
1286
+ op_code = {"sum": "sum", "min": "min", "max": "max"}[op]
1287
+ return Unit(1.0, op_code, data=combined)
1288
+
1289
+
1290
+ # ===================================================================
1291
+ # String-metric convenience constructors
1292
+ # ===================================================================
1293
+
1294
+
1295
+ def string_width(string: Union[str, Sequence[str]]) -> Unit:
1296
+ """Create a ``"strwidth"`` unit for the given string(s).
1297
+
1298
+ Parameters
1299
+ ----------
1300
+ string : str or sequence of str
1301
+ The string(s) whose rendered width the unit represents.
1302
+
1303
+ Returns
1304
+ -------
1305
+ Unit
1306
+ A unit of type ``"strwidth"`` with value 1 for each string.
1307
+
1308
+ Examples
1309
+ --------
1310
+ >>> string_width("hello")
1311
+ Unit([1.0], ['strwidth'])
1312
+ """
1313
+ if isinstance(string, str):
1314
+ strings = [string]
1315
+ else:
1316
+ strings = list(string)
1317
+ n = len(strings)
1318
+ return Unit(np.ones(n), ["strwidth"] * n, data=strings)
1319
+
1320
+
1321
+ def string_height(string: Union[str, Sequence[str]]) -> Unit:
1322
+ """Create a ``"strheight"`` unit for the given string(s).
1323
+
1324
+ Parameters
1325
+ ----------
1326
+ string : str or sequence of str
1327
+ The string(s) whose rendered height the unit represents.
1328
+
1329
+ Returns
1330
+ -------
1331
+ Unit
1332
+ A unit of type ``"strheight"`` with value 1 for each string.
1333
+ """
1334
+ if isinstance(string, str):
1335
+ strings = [string]
1336
+ else:
1337
+ strings = list(string)
1338
+ n = len(strings)
1339
+ return Unit(np.ones(n), ["strheight"] * n, data=strings)
1340
+
1341
+
1342
+ def string_ascent(string: Union[str, Sequence[str]]) -> Unit:
1343
+ """Create a ``"strascent"`` unit for the given string(s).
1344
+
1345
+ Parameters
1346
+ ----------
1347
+ string : str or sequence of str
1348
+ The string(s) whose rendered ascent the unit represents.
1349
+
1350
+ Returns
1351
+ -------
1352
+ Unit
1353
+ A unit of type ``"strascent"`` with value 1 for each string.
1354
+ """
1355
+ if isinstance(string, str):
1356
+ strings = [string]
1357
+ else:
1358
+ strings = list(string)
1359
+ n = len(strings)
1360
+ return Unit(np.ones(n), ["strascent"] * n, data=strings)
1361
+
1362
+
1363
+ def string_descent(string: Union[str, Sequence[str]]) -> Unit:
1364
+ """Create a ``"strdescent"`` unit for the given string(s).
1365
+
1366
+ Parameters
1367
+ ----------
1368
+ string : str or sequence of str
1369
+ The string(s) whose rendered descent the unit represents.
1370
+
1371
+ Returns
1372
+ -------
1373
+ Unit
1374
+ A unit of type ``"strdescent"`` with value 1 for each string.
1375
+ """
1376
+ if isinstance(string, str):
1377
+ strings = [string]
1378
+ else:
1379
+ strings = list(string)
1380
+ n = len(strings)
1381
+ return Unit(np.ones(n), ["strdescent"] * n, data=strings)
1382
+
1383
+
1384
+ # ===================================================================
1385
+ # Absolute-size helper
1386
+ # ===================================================================
1387
+
1388
+
1389
+ def _is_absolute_unit_type(utype: str) -> bool:
1390
+ """Check whether a unit type is "absolute" in R's sense.
1391
+
1392
+ R's ``isAbsolute()`` (grid.h:218) treats these as absolute:
1393
+ cm, inches, mm, points, lines, null, char, strwidth, strheight,
1394
+ strascent, strdescent, and all ``my*`` variants (>1000).
1395
+
1396
+ NON-absolute (context-dependent on parent size): npc, native, snpc.
1397
+ Also non-absolute: grobwidth, grobheight, grobx, groby, grobascent,
1398
+ grobdescent (depend on grob measurement in context).
1399
+
1400
+ Note: ``"null"`` IS absolute (it's resolved by GridLayout, not parent size).
1401
+ Note: ``"lines"`` IS absolute (depends on fontsize only, not parent size).
1402
+ """
1403
+ _NON_ABSOLUTE = frozenset({
1404
+ "npc", "native", "snpc",
1405
+ "grobwidth", "grobheight", "grobx", "groby",
1406
+ "grobascent", "grobdescent",
1407
+ # Compound types need recursion — not absolute in isolation.
1408
+ "sum", "min", "max",
1409
+ })
1410
+ return utype not in _NON_ABSOLUTE
1411
+
1412
+
1413
+ def absolute_size(x: Unit) -> Unit:
1414
+ """Convert a Unit to its absolute form.
1415
+
1416
+ Absolute units (cm, inches, lines, null, points, etc.) pass through
1417
+ unchanged. Non-absolute units (npc, native, snpc, grobwidth, etc.)
1418
+ are replaced with ``unit(1, "null")``.
1419
+
1420
+ For compound (sum/min/max) units, the function recurses into the
1421
+ operands, preserving absolute leaves and replacing non-absolute
1422
+ leaves with null.
1423
+
1424
+ This matches R's ``absolute.size()`` / ``absolute.units()``
1425
+ (grid/R/size.R:130, grid/src/unit.c:1777-1831).
1426
+
1427
+ Parameters
1428
+ ----------
1429
+ x : Unit
1430
+ A unit object.
1431
+
1432
+ Returns
1433
+ -------
1434
+ Unit
1435
+ A copy of *x* with non-absolute elements replaced by ``null``.
1436
+
1437
+ Examples
1438
+ --------
1439
+ >>> absolute_size(Unit(2, "cm"))
1440
+ Unit([2.0], ['cm'])
1441
+ >>> absolute_size(Unit(0.5, "npc"))
1442
+ Unit([1.0], ['null'])
1443
+ """
1444
+ if not isinstance(x, Unit):
1445
+ raise TypeError("x must be a Unit")
1446
+
1447
+ n = len(x)
1448
+
1449
+ # Fast path: all absolute → return as-is (R: line 1803)
1450
+ if all(_is_absolute_unit_type(x._units[i]) for i in range(n)):
1451
+ return x
1452
+
1453
+ new = x.copy()
1454
+ for i in range(n):
1455
+ utype = new._units[i]
1456
+ if utype in ("sum", "min", "max"):
1457
+ # Arithmetic compound: recurse into operands (R: lines 1814-1818)
1458
+ data = new._data[i]
1459
+ if data is not None and isinstance(data, Unit):
1460
+ new._data[i] = absolute_size(data)
1461
+ elif not _is_absolute_unit_type(utype):
1462
+ # Non-absolute scalar: replace with unit(1, "null") (R: lines 1819-1820)
1463
+ new._values[i] = 1.0
1464
+ new._units[i] = "null"
1465
+ new._data[i] = None
1466
+ return new
1467
+
1468
+
1469
+ # ===================================================================
1470
+ # Unit conversion
1471
+ # ===================================================================
1472
+
1473
+
1474
+ def _convert_absolute(value: float, unit_from: str, unit_to: str) -> float:
1475
+ """Convert a single value between two absolute unit types.
1476
+
1477
+ Parameters
1478
+ ----------
1479
+ value : float
1480
+ Numeric value in *unit_from* units.
1481
+ unit_from : str
1482
+ Source unit type (must be in ``_ABSOLUTE_UNIT_TYPES``).
1483
+ unit_to : str
1484
+ Target unit type (must be in ``_ABSOLUTE_UNIT_TYPES``).
1485
+
1486
+ Returns
1487
+ -------
1488
+ float
1489
+ The value expressed in *unit_to* units.
1490
+ """
1491
+ if unit_from == unit_to:
1492
+ return value
1493
+ inches = value * _INCHES_PER[unit_from]
1494
+ return inches / _INCHES_PER[unit_to]
1495
+
1496
+
1497
+ def convert_unit(
1498
+ x: Unit,
1499
+ unitTo: str,
1500
+ axisFrom: str = "x",
1501
+ typeFrom: str = "location",
1502
+ axisTo: Optional[str] = None,
1503
+ typeTo: Optional[str] = None,
1504
+ valueOnly: bool = False,
1505
+ ) -> Union[Unit, np.ndarray]:
1506
+ """Convert a ``Unit`` to a different unit type.
1507
+
1508
+ Absolute-to-absolute conversions (e.g. cm to inches) are performed
1509
+ immediately. Context-dependent conversions (involving npc, native,
1510
+ lines, etc.) use the active renderer's viewport context when
1511
+ available; otherwise a warning is issued and the unit is returned
1512
+ unchanged.
1513
+
1514
+ Parameters
1515
+ ----------
1516
+ x : Unit
1517
+ The unit to convert.
1518
+ unitTo : str
1519
+ Target unit type string.
1520
+ axisFrom : str, optional
1521
+ Source axis (``"x"`` or ``"y"``). Default ``"x"``.
1522
+ typeFrom : str, optional
1523
+ Source type (``"location"`` or ``"dimension"``). Default
1524
+ ``"location"``.
1525
+ axisTo : str, optional
1526
+ Target axis. Defaults to *axisFrom*.
1527
+ typeTo : str, optional
1528
+ Target type. Defaults to *typeFrom*.
1529
+ valueOnly : bool, optional
1530
+ If ``True`` return a bare ``numpy`` array instead of a ``Unit``.
1531
+
1532
+ Returns
1533
+ -------
1534
+ Unit or numpy.ndarray
1535
+ Converted unit, or numeric array when *valueOnly* is ``True``.
1536
+
1537
+ Examples
1538
+ --------
1539
+ >>> convert_unit(Unit(1, "inches"), "cm")
1540
+ Unit([2.54], ['cm'])
1541
+ >>> convert_unit(Unit(2.54, "cm"), "inches", valueOnly=True)
1542
+ array([1.])
1543
+ """
1544
+ if not isinstance(x, Unit):
1545
+ raise TypeError("x must be a Unit")
1546
+
1547
+ if axisTo is None:
1548
+ axisTo = axisFrom
1549
+ if typeTo is None:
1550
+ typeTo = typeFrom
1551
+
1552
+ target = _resolve_alias(unitTo)
1553
+ n = len(x)
1554
+ result_vals = np.empty(n, dtype=np.float64)
1555
+ converted = True
1556
+
1557
+ for i in range(n):
1558
+ src_unit = x._units[i]
1559
+ if src_unit in _ABSOLUTE_UNIT_TYPES and target in _ABSOLUTE_UNIT_TYPES:
1560
+ # Fast path: absolute → absolute (no context needed)
1561
+ result_vals[i] = _convert_absolute(x._values[i], src_unit, target)
1562
+ elif src_unit == target:
1563
+ # Same unit type -- no conversion needed
1564
+ result_vals[i] = x._values[i]
1565
+ else:
1566
+ # All other conversions go through the full two-stage pipeline
1567
+ # (R grid.c:1384-1575 L_convert):
1568
+ # Stage 1: source → inches (via renderer context)
1569
+ # Stage 2: inches → target (via inverse transforms)
1570
+ # This handles: npc, native, lines, char, snpc, strwidth,
1571
+ # grobwidth, compound units, absolute→context, context→absolute
1572
+ resolved = _try_resolve_with_renderer(
1573
+ x, i, src_unit, target, axisFrom, typeFrom,
1574
+ )
1575
+ if resolved is not None:
1576
+ result_vals[i] = resolved
1577
+ elif src_unit in _STR_METRIC_TYPES:
1578
+ # Fallback without renderer: string metric → inches → target
1579
+ inches_val = _eval_str_metric(src_unit, x._data[i], x._values[i])
1580
+ if target in _ABSOLUTE_UNIT_TYPES:
1581
+ result_vals[i] = inches_val / _INCHES_PER[target]
1582
+ else:
1583
+ result_vals[i] = inches_val
1584
+ converted = False
1585
+ elif src_unit in _GROB_METRIC_TYPES:
1586
+ # Fallback: grob metric → inches → target
1587
+ 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]]
1596
+ )
1597
+ src_inches *= x._values[i]
1598
+ if target in _ABSOLUTE_UNIT_TYPES:
1599
+ result_vals[i] = src_inches / _INCHES_PER[target]
1600
+ else:
1601
+ result_vals[i] = src_inches
1602
+ converted = False
1603
+ else:
1604
+ result_vals[i] = x._values[i]
1605
+ converted = False
1606
+ elif src_unit in _ABSOLUTE_UNIT_TYPES:
1607
+ # Absolute source → context-dependent target (no renderer)
1608
+ result_vals[i] = x._values[i]
1609
+ converted = False
1610
+ else:
1611
+ result_vals[i] = x._values[i]
1612
+ converted = False
1613
+
1614
+ if valueOnly:
1615
+ return result_vals
1616
+
1617
+ if converted:
1618
+ return Unit(result_vals, target)
1619
+ else:
1620
+ # Return original unchanged when full conversion is not possible
1621
+ import warnings
1622
+
1623
+ warnings.warn(
1624
+ f"Cannot convert between {set(x._units)} and {target!r} "
1625
+ "without a viewport context; returning unit unchanged.",
1626
+ stacklevel=2,
1627
+ )
1628
+ return x.copy()
1629
+
1630
+
1631
+ def convert_x(x: Unit, unitTo: str, valueOnly: bool = False) -> Union[Unit, np.ndarray]:
1632
+ """Convert an x-axis location unit.
1633
+
1634
+ Parameters
1635
+ ----------
1636
+ x : Unit
1637
+ Source unit.
1638
+ unitTo : str
1639
+ Target unit type.
1640
+ valueOnly : bool, optional
1641
+ Return bare numeric array if ``True``.
1642
+
1643
+ Returns
1644
+ -------
1645
+ Unit or numpy.ndarray
1646
+ """
1647
+ return convert_unit(x, unitTo, "x", "location", "x", "location", valueOnly=valueOnly)
1648
+
1649
+
1650
+ def convert_y(x: Unit, unitTo: str, valueOnly: bool = False) -> Union[Unit, np.ndarray]:
1651
+ """Convert a y-axis location unit.
1652
+
1653
+ Parameters
1654
+ ----------
1655
+ x : Unit
1656
+ Source unit.
1657
+ unitTo : str
1658
+ Target unit type.
1659
+ valueOnly : bool, optional
1660
+ Return bare numeric array if ``True``.
1661
+
1662
+ Returns
1663
+ -------
1664
+ Unit or numpy.ndarray
1665
+ """
1666
+ return convert_unit(x, unitTo, "y", "location", "y", "location", valueOnly=valueOnly)
1667
+
1668
+
1669
+ def convert_width(x: Unit, unitTo: str, valueOnly: bool = False) -> Union[Unit, np.ndarray]:
1670
+ """Convert a width (x-axis dimension) unit.
1671
+
1672
+ Parameters
1673
+ ----------
1674
+ x : Unit
1675
+ Source unit.
1676
+ unitTo : str
1677
+ Target unit type.
1678
+ valueOnly : bool, optional
1679
+ Return bare numeric array if ``True``.
1680
+
1681
+ Returns
1682
+ -------
1683
+ Unit or numpy.ndarray
1684
+ """
1685
+ return convert_unit(
1686
+ x, unitTo, "x", "dimension", "x", "dimension", valueOnly=valueOnly
1687
+ )
1688
+
1689
+
1690
+ def convert_height(x: Unit, unitTo: str, valueOnly: bool = False) -> Union[Unit, np.ndarray]:
1691
+ """Convert a height (y-axis dimension) unit.
1692
+
1693
+ Parameters
1694
+ ----------
1695
+ x : Unit
1696
+ Source unit.
1697
+ unitTo : str
1698
+ Target unit type.
1699
+ valueOnly : bool, optional
1700
+ Return bare numeric array if ``True``.
1701
+
1702
+ Returns
1703
+ -------
1704
+ Unit or numpy.ndarray
1705
+ """
1706
+ return convert_unit(
1707
+ x, unitTo, "y", "dimension", "y", "dimension", valueOnly=valueOnly
1708
+ )
1709
+
1710
+
1711
+ # ---------------------------------------------------------------------------
1712
+ # convertTheta -- port of R unit.R:617-629
1713
+ # ---------------------------------------------------------------------------
1714
+
1715
+
1716
+ _THETA_ALIASES: Dict[str, float] = {
1717
+ "east": 0.0,
1718
+ "north": 90.0,
1719
+ "west": 180.0,
1720
+ "south": 270.0,
1721
+ }
1722
+
1723
+
1724
+ def convert_theta(theta: Any) -> float:
1725
+ """Convert a theta angle to numeric degrees in [0, 360).
1726
+
1727
+ Port of R ``convertTheta()`` (unit.R:617-629).
1728
+ Accepts character shortcuts ``"east"`` (0), ``"north"`` (90),
1729
+ ``"west"`` (180), ``"south"`` (270) or numeric values.
1730
+
1731
+ Parameters
1732
+ ----------
1733
+ theta : str or float
1734
+ Angle specification.
1735
+
1736
+ Returns
1737
+ -------
1738
+ float
1739
+ Angle in degrees, normalised to [0, 360).
1740
+
1741
+ Raises
1742
+ ------
1743
+ ValueError
1744
+ If *theta* is an unrecognised string.
1745
+
1746
+ Examples
1747
+ --------
1748
+ >>> convert_theta("north")
1749
+ 90.0
1750
+ >>> convert_theta(450)
1751
+ 90.0
1752
+ """
1753
+ if isinstance(theta, str):
1754
+ val = _THETA_ALIASES.get(theta.lower())
1755
+ if val is None:
1756
+ raise ValueError(f"invalid theta: {theta!r}")
1757
+ return val
1758
+ return float(theta) % 360.0
1759
+
1760
+
1761
+ # ---------------------------------------------------------------------------
1762
+ # deviceLoc / deviceDim -- port of R unit.R:117-151 + grid.c:1580-1677
1763
+ # ---------------------------------------------------------------------------
1764
+
1765
+
1766
+ def device_loc(
1767
+ x: Unit,
1768
+ y: Unit,
1769
+ value_only: bool = False,
1770
+ device: bool = False,
1771
+ ) -> dict:
1772
+ """Convert grid locations to absolute device coordinates.
1773
+
1774
+ Port of R ``deviceLoc()`` (unit.R:117-133) + ``L_devLoc`` (grid.c:1580-1628).
1775
+ For each (x[i], y[i]) pair:
1776
+ 1. Convert x to inches via transformXtoINCHES
1777
+ 2. Convert y to inches via transformYtoINCHES
1778
+ 3. Apply the viewport 3×3 transform (transformLocn: location → trans)
1779
+ 4. Optionally convert to device coordinates
1780
+
1781
+ Parameters
1782
+ ----------
1783
+ x, y : Unit
1784
+ Location units.
1785
+ value_only : bool
1786
+ If True, return raw numeric arrays. Otherwise return Unit objects.
1787
+ device : bool
1788
+ If True, return in device-native coordinates (pixels).
1789
+ If False, return in absolute inches.
1790
+
1791
+ Returns
1792
+ -------
1793
+ dict
1794
+ ``{'x': ..., 'y': ...}`` — each is either a Unit or ndarray.
1795
+ """
1796
+ from ._state import get_state
1797
+ from ._vp_calc import location, trans
1798
+
1799
+ state = get_state()
1800
+ renderer = state.get_renderer()
1801
+
1802
+ if renderer is None:
1803
+ raise RuntimeError("deviceLoc requires an active renderer")
1804
+
1805
+ vtr = renderer._vp_transform_stack[-1]
1806
+ gp = state.get_gpar()
1807
+
1808
+ nx = len(x)
1809
+ ny = len(y)
1810
+ maxn = max(nx, ny)
1811
+
1812
+ out_x = np.empty(maxn, dtype=np.float64)
1813
+ out_y = np.empty(maxn, dtype=np.float64)
1814
+
1815
+ for i in range(maxn):
1816
+ # Stage 1: resolve to inches within viewport
1817
+ # R grid.c:1612-1616 transformLocn()
1818
+ xx = renderer._resolve_to_inches_idx(x, i % nx, "x", False, gp)
1819
+ yy = renderer._resolve_to_inches_idx(y, i % ny, "y", False, gp)
1820
+
1821
+ # Stage 2: apply viewport 3×3 transform to get absolute inches
1822
+ # R unit.c:1168-1171 location→trans
1823
+ loc = location(xx, yy)
1824
+ abs_loc = trans(loc, vtr.transform)
1825
+ xx = abs_loc[0]
1826
+ yy = abs_loc[1]
1827
+
1828
+ if device:
1829
+ # Convert absolute inches to device pixels
1830
+ # R grid.c:1618-1619 toDeviceX/Y
1831
+ xx = renderer.inches_to_dev_x(xx)
1832
+ yy = renderer.inches_to_dev_y(yy)
1833
+
1834
+ out_x[i] = xx
1835
+ out_y[i] = yy
1836
+
1837
+ if value_only:
1838
+ return {"x": out_x, "y": out_y}
1839
+ else:
1840
+ if device:
1841
+ return {"x": Unit(out_x, "native"), "y": Unit(out_y, "native")}
1842
+ else:
1843
+ return {"x": Unit(out_x, "inches"), "y": Unit(out_y, "inches")}
1844
+
1845
+
1846
+ def device_dim(
1847
+ w: Unit,
1848
+ h: Unit,
1849
+ value_only: bool = False,
1850
+ device: bool = False,
1851
+ ) -> dict:
1852
+ """Convert grid dimensions to absolute device dimensions.
1853
+
1854
+ Port of R ``deviceDim()`` (unit.R:135-151) + ``L_devDim`` (grid.c:1630-1677).
1855
+ For each (w[i], h[i]) pair:
1856
+ 1. Convert w to inches via transformWidthtoINCHES
1857
+ 2. Convert h to inches via transformHeighttoINCHES
1858
+ 3. Apply rotation transform (transformDimn)
1859
+ 4. Optionally convert to device units
1860
+
1861
+ Parameters
1862
+ ----------
1863
+ w, h : Unit
1864
+ Dimension units.
1865
+ value_only : bool
1866
+ If True, return raw numeric arrays. Otherwise return Unit objects.
1867
+ device : bool
1868
+ If True, return in device-native units (pixels).
1869
+ If False, return in absolute inches.
1870
+
1871
+ Returns
1872
+ -------
1873
+ dict
1874
+ ``{'w': ..., 'h': ...}`` — each is either a Unit or ndarray.
1875
+ """
1876
+ import math
1877
+ from ._state import get_state
1878
+ from ._vp_calc import location, rotation, trans
1879
+
1880
+ state = get_state()
1881
+ renderer = state.get_renderer()
1882
+
1883
+ if renderer is None:
1884
+ raise RuntimeError("deviceDim requires an active renderer")
1885
+
1886
+ vtr = renderer._vp_transform_stack[-1]
1887
+ gp = state.get_gpar()
1888
+ rotation_angle = vtr.rotation_angle
1889
+
1890
+ nw = len(w)
1891
+ nh = len(h)
1892
+ maxn = max(nw, nh)
1893
+
1894
+ out_w = np.empty(maxn, dtype=np.float64)
1895
+ out_h = np.empty(maxn, dtype=np.float64)
1896
+
1897
+ for i in range(maxn):
1898
+ # Stage 1: resolve to inches within viewport
1899
+ ww = renderer._resolve_to_inches_idx(w, i % nw, "x", True, gp)
1900
+ hh = renderer._resolve_to_inches_idx(h, i % nh, "y", True, gp)
1901
+
1902
+ # Stage 2: apply rotation (R unit.c:1208-1212 transformDimn)
1903
+ # R: location(ww,hh,din); rotation(angle,r); trans(din,r,dout);
1904
+ din = location(ww, hh)
1905
+ rot = rotation(rotation_angle)
1906
+ dout = trans(din, rot)
1907
+ ww = dout[0]
1908
+ hh = dout[1]
1909
+
1910
+ if device:
1911
+ # Convert absolute inches to device pixels
1912
+ ww = renderer.inches_to_dev_w(ww)
1913
+ hh = renderer.inches_to_dev_h(hh)
1914
+
1915
+ out_w[i] = ww
1916
+ out_h[i] = hh
1917
+
1918
+ if value_only:
1919
+ return {"w": out_w, "h": out_h}
1920
+ else:
1921
+ if device:
1922
+ return {"w": Unit(out_w, "native"), "h": Unit(out_h, "native")}
1923
+ else:
1924
+ return {"w": Unit(out_w, "inches"), "h": Unit(out_h, "inches")}