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/_vp_calc.py ADDED
@@ -0,0 +1,970 @@
1
+ """Viewport coordinate transform calculations -- port of R's matrix.c + viewport.c.
2
+
3
+ This module provides the 3×3 affine transform matrix operations and viewport
4
+ transform computation that form the core of R grid's coordinate pipeline.
5
+
6
+ All transforms use the **row-vector** convention from R's grid:
7
+ point_out = point_in @ matrix
8
+ where point = [x, y, 1]. Translation terms live in row 2 (m[2,0], m[2,1]).
9
+
10
+ References
11
+ ----------
12
+ R source: ``grid/src/matrix.c`` (identity, translation, rotation, scaling,
13
+ multiply, location, trans), ``grid/src/viewport.c`` (calcViewportTransform).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import math
19
+ from typing import Any, Dict, Optional, Tuple
20
+
21
+ import numpy as np
22
+
23
+ __all__ = [
24
+ # Matrix operations (port of matrix.c)
25
+ "identity",
26
+ "translation",
27
+ "rotation",
28
+ "scaling",
29
+ "multiply",
30
+ "location",
31
+ "trans",
32
+ "inv_transform",
33
+ "copy_transform",
34
+ # Viewport transform (port of viewport.c:calcViewportTransform)
35
+ "calc_viewport_transform",
36
+ # Inverse transforms (port of unit.c:1226-1475)
37
+ "_transform_from_inches",
38
+ "_transform_xy_from_inches",
39
+ "_transform_wh_from_inches",
40
+ "_transform_xy_to_npc",
41
+ "_transform_wh_to_npc",
42
+ "_transform_xy_from_npc",
43
+ "_transform_wh_from_npc",
44
+ # Viewport context
45
+ "ViewportContext",
46
+ "ViewportTransformResult",
47
+ ]
48
+
49
+
50
+ # ============================================================================
51
+ # Type aliases
52
+ # ============================================================================
53
+
54
+ # LTransform = double[3][3] --> np.ndarray shape (3,3) float64
55
+ # LLocation = double[3] --> np.ndarray shape (3,) float64
56
+
57
+
58
+ # ============================================================================
59
+ # Matrix operations -- direct port of R grid/src/matrix.c
60
+ # ============================================================================
61
+ # Each function mirrors the corresponding C function exactly.
62
+ # Comments reference R source line numbers for traceability.
63
+
64
+
65
+ def identity() -> np.ndarray:
66
+ """Return 3×3 identity matrix.
67
+
68
+ Port of ``matrix.c:62 identity()``.
69
+ """
70
+ return np.eye(3, dtype=np.float64)
71
+
72
+
73
+ def translation(tx: float, ty: float) -> np.ndarray:
74
+ """Return 3×3 translation matrix.
75
+
76
+ Port of ``matrix.c:73 translation()``:
77
+ ``identity(m); m[2][0] = tx; m[2][1] = ty;``
78
+ """
79
+ m = np.eye(3, dtype=np.float64)
80
+ m[2, 0] = tx
81
+ m[2, 1] = ty
82
+ return m
83
+
84
+
85
+ def scaling(sx: float, sy: float) -> np.ndarray:
86
+ """Return 3×3 scaling matrix.
87
+
88
+ Port of ``matrix.c:80 scaling()``:
89
+ ``identity(m); m[0][0] = sx; m[1][1] = sy;``
90
+ """
91
+ m = np.eye(3, dtype=np.float64)
92
+ m[0, 0] = sx
93
+ m[1, 1] = sy
94
+ return m
95
+
96
+
97
+ def rotation(theta_degrees: float) -> np.ndarray:
98
+ """Return 3×3 rotation matrix for *theta_degrees* degrees.
99
+
100
+ Port of ``matrix.c:87 rotation()``:
101
+ ``thetarad = theta/180*PI; m[0][0] = cos; m[0][1] = sin;
102
+ m[1][0] = -sin; m[1][1] = cos;``
103
+ """
104
+ rad = theta_degrees / 180.0 * math.pi
105
+ c = math.cos(rad)
106
+ s = math.sin(rad)
107
+ m = np.eye(3, dtype=np.float64)
108
+ m[0, 0] = c
109
+ m[0, 1] = s
110
+ m[1, 0] = -s
111
+ m[1, 1] = c
112
+ return m
113
+
114
+
115
+ def multiply(m1: np.ndarray, m2: np.ndarray) -> np.ndarray:
116
+ """Multiply two 3×3 matrices: ``result = m1 @ m2``.
117
+
118
+ Port of ``matrix.c:99 multiply()``.
119
+ Uses numpy matmul for clarity; result is identical to the
120
+ hand-unrolled R code.
121
+ """
122
+ return m1 @ m2
123
+
124
+
125
+ def location(x: float, y: float) -> np.ndarray:
126
+ """Create a homogeneous location vector [x, y, 1].
127
+
128
+ Port of ``matrix.c:112 location()``.
129
+ """
130
+ return np.array([x, y, 1.0], dtype=np.float64)
131
+
132
+
133
+ def trans(vin: np.ndarray, m: np.ndarray) -> np.ndarray:
134
+ """Transform a location vector by a matrix: ``vout = vin @ m``.
135
+
136
+ Port of ``matrix.c:119 trans()``:
137
+ ``vout[0] = vin[0]*m[0][0] + vin[1]*m[1][0] + vin[2]*m[2][0]; ...``
138
+ """
139
+ return vin @ m
140
+
141
+
142
+ def inv_transform(t: np.ndarray) -> np.ndarray:
143
+ """Compute the inverse of a 3×3 transform matrix.
144
+
145
+ Port of ``matrix.c:44 invTransform()`` using the explicit
146
+ cofactor/determinant formula.
147
+ """
148
+ det = (t[0, 0] * (t[2, 2] * t[1, 1] - t[2, 1] * t[1, 2])
149
+ - t[1, 0] * (t[2, 2] * t[0, 1] - t[2, 1] * t[0, 2])
150
+ + t[2, 0] * (t[1, 2] * t[0, 1] - t[1, 1] * t[0, 2]))
151
+ if det == 0:
152
+ raise ValueError("singular transformation matrix")
153
+ inv = np.empty((3, 3), dtype=np.float64)
154
+ inv[0, 0] = (t[2, 2] * t[1, 1] - t[2, 1] * t[1, 2]) / det
155
+ inv[0, 1] = -(t[2, 2] * t[0, 1] - t[2, 1] * t[0, 2]) / det
156
+ inv[0, 2] = (t[1, 2] * t[0, 1] - t[1, 1] * t[0, 2]) / det
157
+ inv[1, 0] = -(t[2, 2] * t[1, 0] - t[2, 0] * t[1, 2]) / det
158
+ inv[1, 1] = (t[2, 2] * t[0, 0] - t[2, 0] * t[0, 2]) / det
159
+ inv[1, 2] = -(t[1, 2] * t[0, 0] - t[1, 0] * t[0, 2]) / det
160
+ inv[2, 0] = (t[2, 1] * t[1, 0] - t[2, 0] * t[1, 1]) / det
161
+ inv[2, 1] = -(t[2, 1] * t[0, 0] - t[2, 0] * t[0, 1]) / det
162
+ inv[2, 2] = (t[1, 1] * t[0, 0] - t[1, 0] * t[0, 1]) / det
163
+ return inv
164
+
165
+
166
+ def copy_transform(t: np.ndarray) -> np.ndarray:
167
+ """Copy a 3×3 transform matrix.
168
+
169
+ Port of ``matrix.c:36 copyTransform()``.
170
+ """
171
+ return t.copy()
172
+
173
+
174
+ # ============================================================================
175
+ # Justification helper -- port of R grid/src/viewport.c justification()
176
+ # ============================================================================
177
+
178
+ def justification(width: float, height: float,
179
+ hjust: float, vjust: float) -> Tuple[float, float]:
180
+ """Compute justification offsets in the same units as width/height.
181
+
182
+ Port of R's ``justification()`` (grid.h / viewport.c).
183
+ Returns (xadj, yadj) where:
184
+ xadj = -hjust * width
185
+ yadj = -vjust * height
186
+ """
187
+ return (-hjust * width, -vjust * height)
188
+
189
+
190
+ # ============================================================================
191
+ # Viewport context (port of LViewportContext)
192
+ # ============================================================================
193
+
194
+ class ViewportContext:
195
+ """Stores xscale/yscale ranges for native unit resolution.
196
+
197
+ Port of ``grid.h:260 LViewportContext``.
198
+ """
199
+ __slots__ = ("xscalemin", "xscalemax", "yscalemin", "yscalemax")
200
+
201
+ def __init__(self, xscale: Tuple[float, float] = (0.0, 1.0),
202
+ yscale: Tuple[float, float] = (0.0, 1.0)):
203
+ self.xscalemin = float(xscale[0])
204
+ self.xscalemax = float(xscale[1])
205
+ self.yscalemin = float(yscale[0])
206
+ self.yscalemax = float(yscale[1])
207
+
208
+
209
+ class ViewportTransformResult:
210
+ """Result of calcViewportTransform: the computed viewport state.
211
+
212
+ Stores the same values that R writes back to the viewport SEXP
213
+ (viewport.c:370-380).
214
+ """
215
+ __slots__ = ("width_cm", "height_cm", "rotation_angle",
216
+ "transform", "vpc")
217
+
218
+ def __init__(self, width_cm: float, height_cm: float,
219
+ rotation_angle: float, transform: np.ndarray,
220
+ vpc: ViewportContext):
221
+ self.width_cm = width_cm
222
+ self.height_cm = height_cm
223
+ self.rotation_angle = rotation_angle
224
+ self.transform = transform
225
+ self.vpc = vpc
226
+
227
+
228
+ # ============================================================================
229
+ # Unit-to-inches conversion -- port of unit.c:transform() switch
230
+ # ============================================================================
231
+ # This is the key function that converts any grid unit value to inches
232
+ # within a viewport context, WITHOUT requiring an R graphics device.
233
+
234
+ _INCHES_PER = {
235
+ "inches": 1.0,
236
+ "cm": 1.0 / 2.54,
237
+ "mm": 1.0 / 25.4,
238
+ "points": 1.0 / 72.27, # TeX points
239
+ "picas": 12.0 / 72.27,
240
+ "bigpts": 1.0 / 72.0, # PostScript/CSS points
241
+ "dida": (1238.0 / 1157.0) / 72.27,
242
+ "cicero": 12.0 * (1238.0 / 1157.0) / 72.27,
243
+ "scaledpts": 1.0 / (72.27 * 65536.0),
244
+ }
245
+
246
+
247
+ def transform_x_to_inches(
248
+ unit_obj: Any,
249
+ index: int,
250
+ vpc: ViewportContext,
251
+ gc_fontsize: float,
252
+ gc_cex: float,
253
+ gc_lineheight: float,
254
+ parent_width_cm: float,
255
+ parent_height_cm: float,
256
+ str_metric_fn: Any = None,
257
+ grob_metric_fn: Any = None,
258
+ ) -> float:
259
+ """Convert a Unit element to inches along the X axis.
260
+
261
+ Port of ``unit.c:transformXtoINCHES`` which calls
262
+ ``transform()`` with ``thisCM = parentWidthCM``.
263
+
264
+ Parameters
265
+ ----------
266
+ unit_obj : Unit
267
+ The unit object (must have _values, _units, _data).
268
+ index : int
269
+ Element index within the unit vector.
270
+ vpc : ViewportContext
271
+ The parent viewport's native scale ranges.
272
+ gc_fontsize, gc_cex, gc_lineheight : float
273
+ Font metrics from the gpar context (gc->ps, gc->cex, gc->lineheight).
274
+ parent_width_cm, parent_height_cm : float
275
+ Parent viewport dimensions in CM.
276
+ str_metric_fn : callable or None
277
+ Function(text, gp) -> dict with 'width','ascent','descent' in inches.
278
+ grob_metric_fn : callable or None
279
+ Function(grob, what) -> Unit for grob-based metrics.
280
+
281
+ Returns
282
+ -------
283
+ float
284
+ The value in inches.
285
+ """
286
+ return _transform_to_inches(
287
+ unit_obj, index, vpc,
288
+ gc_fontsize, gc_cex, gc_lineheight,
289
+ this_cm=parent_width_cm,
290
+ other_cm=parent_height_cm,
291
+ axis="x", is_dim=False,
292
+ str_metric_fn=str_metric_fn,
293
+ grob_metric_fn=grob_metric_fn,
294
+ )
295
+
296
+
297
+ def transform_y_to_inches(
298
+ unit_obj: Any, index: int, vpc: ViewportContext,
299
+ gc_fontsize: float, gc_cex: float, gc_lineheight: float,
300
+ parent_width_cm: float, parent_height_cm: float,
301
+ str_metric_fn: Any = None, grob_metric_fn: Any = None,
302
+ ) -> float:
303
+ """Convert a Unit element to inches along the Y axis.
304
+
305
+ Port of ``unit.c:transformYtoINCHES``.
306
+ """
307
+ return _transform_to_inches(
308
+ unit_obj, index, vpc,
309
+ gc_fontsize, gc_cex, gc_lineheight,
310
+ this_cm=parent_height_cm,
311
+ other_cm=parent_width_cm,
312
+ axis="y", is_dim=False,
313
+ str_metric_fn=str_metric_fn,
314
+ grob_metric_fn=grob_metric_fn,
315
+ )
316
+
317
+
318
+ def transform_width_to_inches(
319
+ unit_obj: Any, index: int, vpc: ViewportContext,
320
+ gc_fontsize: float, gc_cex: float, gc_lineheight: float,
321
+ parent_width_cm: float, parent_height_cm: float,
322
+ str_metric_fn: Any = None, grob_metric_fn: Any = None,
323
+ ) -> float:
324
+ """Convert a Unit element to inches as an X-axis dimension.
325
+
326
+ Port of ``unit.c:transformWidthtoINCHES``.
327
+ """
328
+ return _transform_to_inches(
329
+ unit_obj, index, vpc,
330
+ gc_fontsize, gc_cex, gc_lineheight,
331
+ this_cm=parent_width_cm,
332
+ other_cm=parent_height_cm,
333
+ axis="x", is_dim=True,
334
+ str_metric_fn=str_metric_fn,
335
+ grob_metric_fn=grob_metric_fn,
336
+ )
337
+
338
+
339
+ def transform_height_to_inches(
340
+ unit_obj: Any, index: int, vpc: ViewportContext,
341
+ gc_fontsize: float, gc_cex: float, gc_lineheight: float,
342
+ parent_width_cm: float, parent_height_cm: float,
343
+ str_metric_fn: Any = None, grob_metric_fn: Any = None,
344
+ ) -> float:
345
+ """Convert a Unit element to inches as a Y-axis dimension.
346
+
347
+ Port of ``unit.c:transformHeighttoINCHES``.
348
+ """
349
+ return _transform_to_inches(
350
+ unit_obj, index, vpc,
351
+ gc_fontsize, gc_cex, gc_lineheight,
352
+ this_cm=parent_height_cm,
353
+ other_cm=parent_width_cm,
354
+ axis="y", is_dim=True,
355
+ str_metric_fn=str_metric_fn,
356
+ grob_metric_fn=grob_metric_fn,
357
+ )
358
+
359
+
360
+ def _transform_to_inches(
361
+ unit_obj: Any,
362
+ index: int,
363
+ vpc: ViewportContext,
364
+ gc_fontsize: float,
365
+ gc_cex: float,
366
+ gc_lineheight: float,
367
+ this_cm: float,
368
+ other_cm: float,
369
+ axis: str,
370
+ is_dim: bool,
371
+ str_metric_fn: Any = None,
372
+ grob_metric_fn: Any = None,
373
+ scale: float = 1.0,
374
+ ) -> float:
375
+ """Core unit-to-inches conversion -- port of ``unit.c:transform()``.
376
+
377
+ This implements the big switch statement (unit.c:658-800) that
378
+ converts each unit type to inches, plus the GSS_SCALE post-scaling
379
+ for physical units (unit.c:804-814).
380
+
381
+ Parameters
382
+ ----------
383
+ unit_obj : Unit
384
+ The unit object.
385
+ index : int
386
+ Element index.
387
+ vpc : ViewportContext
388
+ Parent scale context.
389
+ gc_fontsize, gc_cex, gc_lineheight : float
390
+ Font context (gc->ps, gc->cex, gc->lineheight).
391
+ this_cm, other_cm : float
392
+ Viewport dimension in CM for the primary and orthogonal axis.
393
+ axis : str
394
+ ``"x"`` or ``"y"``.
395
+ is_dim : bool
396
+ True if converting a width/height (dimension), False for position.
397
+ str_metric_fn : callable or None
398
+ String metric query function.
399
+ grob_metric_fn : callable or None
400
+ Grob metric query function.
401
+ scale : float
402
+ GSS_SCALE zoom factor (R unit.c:804-814). Default 1.0.
403
+
404
+ Returns
405
+ -------
406
+ float
407
+ Value in inches.
408
+ """
409
+ from ._units import Unit
410
+
411
+ if not isinstance(unit_obj, Unit):
412
+ return float(unit_obj)
413
+
414
+ idx = index % len(unit_obj)
415
+ value = float(unit_obj._values[idx])
416
+ utype = unit_obj._units[idx]
417
+ data = unit_obj._data[idx] if unit_obj._data is not None else None
418
+
419
+ this_inches = this_cm / 2.54
420
+
421
+ # ---- Absolute physical units (unit.c:670-682) ----
422
+ # R unit.c:804-814: physical units are additionally scaled by GSS_SCALE
423
+ if utype in _INCHES_PER:
424
+ return value * _INCHES_PER[utype] * scale
425
+
426
+ # ---- NPC (unit.c:667) ----
427
+ # L_NPC: result * thisCM / 2.54
428
+ if utype == "npc":
429
+ if is_dim:
430
+ return value * this_inches
431
+ else:
432
+ return value * this_inches
433
+
434
+ # ---- SNPC (unit.c:689) ----
435
+ # L_SNPC: result * min(thisCM, otherCM) / 2.54
436
+ if utype == "snpc":
437
+ return value * min(this_cm, other_cm) / 2.54
438
+
439
+ # ---- Native (unit.c:837) ----
440
+ # Maps value from [scalemin, scalemax] range to [0, thisCM/2.54]
441
+ if utype == "native":
442
+ scalemin = vpc.xscalemin if axis == "x" else vpc.yscalemin
443
+ scalemax = vpc.xscalemax if axis == "x" else vpc.yscalemax
444
+ srange = scalemax - scalemin
445
+ if srange == 0:
446
+ return 0.0
447
+ if is_dim:
448
+ return (value / srange) * this_inches
449
+ else:
450
+ return ((value - scalemin) / srange) * this_inches
451
+
452
+ # ---- Char (unit.c:683) ----
453
+ # L_CHAR: result * gc->ps * gc->cex / 72
454
+ # Note: gc->ps = fontsize * GSS_SCALE (gpar.c:395), so char/lines
455
+ # units are implicitly scaled by GSS_SCALE through gc->ps.
456
+ if utype == "char":
457
+ return value * gc_fontsize * scale * gc_cex / 72.0
458
+
459
+ # ---- Lines (unit.c:687) ----
460
+ # L_LINES: result * gc->ps * gc->cex * gc->lineheight / 72
461
+ if utype == "lines":
462
+ return value * gc_fontsize * scale * gc_cex * gc_lineheight / 72.0
463
+
464
+ # ---- Null (unit.c:693) ----
465
+ # L_NULL: contributes 0 inches in this context
466
+ if utype == "null":
467
+ return 0.0
468
+
469
+ # ---- String metrics (unit.c:720-760) ----
470
+ #
471
+ # Mirrors R's ``GEStrWidth`` / ``GEStrHeight`` / ``GEStrMetric``:
472
+ #
473
+ # - split the label on ``\n`` into lines;
474
+ # - ``strwidth`` = max(per-line widths) (GEStrWidth)
475
+ # - ``strheight`` = ink(first line) + (n−1) × cex × lineheight × ps × 1.2 / 72
476
+ # (GEStrHeight)
477
+ # - ``strascent`` / ``strdescent`` = ink metric of the first line
478
+ # (GEStrMetric)
479
+ if utype in ("strwidth", "strheight", "strascent", "strdescent"):
480
+ text = str(data) if data is not None else ""
481
+ lines = text.split("\n") if text else [""]
482
+ n = len(lines)
483
+ if str_metric_fn is not None:
484
+ m0 = str_metric_fn(lines[0], None)
485
+ if utype == "strwidth":
486
+ w = max(str_metric_fn(ln, None).get("width", 0.0) for ln in lines)
487
+ return value * w
488
+ elif utype == "strheight":
489
+ ink_first = m0.get("ascent", 0.0) + m0.get("descent", 0.0)
490
+ inter_line_gap = (
491
+ gc_cex * gc_lineheight * gc_fontsize * 1.2 / 72.0
492
+ )
493
+ return value * (ink_first + (n - 1) * inter_line_gap)
494
+ elif utype == "strascent":
495
+ return value * m0.get("ascent", 0.0)
496
+ else:
497
+ return value * m0.get("descent", 0.0)
498
+ # Fallback: estimate from font size when no measurement callback is
499
+ # available. ``gc->ps = fontsize * GSS_SCALE`` in R.
500
+ effective = gc_fontsize * scale * gc_cex
501
+ char_width = effective * 0.6 / 72.0
502
+ if utype == "strwidth":
503
+ max_line_len = max((len(ln) for ln in lines), default=0)
504
+ return value * max_line_len * char_width
505
+ elif utype == "strheight":
506
+ # First line approximated as one ``effective`` unit of height;
507
+ # add the inter-line gap for extra lines.
508
+ inter_line_gap = (
509
+ gc_cex * gc_lineheight * gc_fontsize * 1.2 / 72.0
510
+ )
511
+ return value * (effective / 72.0 + (n - 1) * inter_line_gap)
512
+ elif utype == "strascent":
513
+ return value * effective * 0.75 / 72.0
514
+ else:
515
+ return value * effective * 0.25 / 72.0
516
+
517
+ # ---- Grob metrics (unit.c:770-800) ----
518
+ # R's evaluateGrobUnit (unit.c:325-590) does a full preDraw/postDraw
519
+ # cycle on the grob, then calls widthDetails/heightDetails to get a
520
+ # result Unit, converts it to inches *within the grob's viewport
521
+ # context*, and returns the inches value.
522
+ #
523
+ # grob_metric_fn(grob, utype, value) must return **inches** directly
524
+ # (it does the full preDraw/eval/postDraw/restore cycle internally).
525
+ if utype in ("grobwidth", "grobheight", "grobascent", "grobdescent",
526
+ "grobx", "groby"):
527
+ if grob_metric_fn is not None and data is not None:
528
+ inches = grob_metric_fn(data, utype, value)
529
+ if inches is not None:
530
+ # For width / height / ascent / descent, ``value`` is a
531
+ # scaling factor (usually 1) — the result scales linearly.
532
+ # For grobx / groby, ``value`` carries the evaluation angle
533
+ # (degrees) and the callback already returned the absolute
534
+ # x/y coordinate in inches, so no extra scaling applies.
535
+ if utype in ("grobx", "groby"):
536
+ return inches
537
+ return value * inches
538
+ return 0.0
539
+
540
+ # ---- Compound units: sum, min, max ----
541
+ if utype in ("sum", "min", "max"):
542
+ child = data
543
+ if isinstance(child, Unit):
544
+ results = []
545
+ for j in range(len(child)):
546
+ r = _transform_to_inches(
547
+ child, j, vpc,
548
+ gc_fontsize, gc_cex, gc_lineheight,
549
+ this_cm, other_cm, axis, is_dim,
550
+ str_metric_fn, grob_metric_fn,
551
+ )
552
+ results.append(r)
553
+ if utype == "sum":
554
+ return value * sum(results)
555
+ elif utype == "min":
556
+ return value * min(results) if results else 0.0
557
+ elif utype == "max":
558
+ return value * max(results) if results else 0.0
559
+ return 0.0
560
+
561
+ # ---- mychar, mylines, mystrwidth, mystrheight (unit.c:804+) ----
562
+ if utype == "mychar":
563
+ return value * gc_fontsize * gc_cex / 72.0
564
+ if utype == "mylines":
565
+ return value * gc_fontsize * gc_cex * gc_lineheight / 72.0
566
+ if utype in ("mystrwidth", "mystrheight"):
567
+ # These use the grob's own gpar rather than the parent's.
568
+ # Without that context, fall back to the str metric path.
569
+ # R marks these as "FIXME: Remove this when I can" (unit.c:721,734).
570
+ if str_metric_fn is not None and data is not None:
571
+ text = str(data)
572
+ m = str_metric_fn(text, None)
573
+ if utype == "mystrwidth":
574
+ return value * m.get("width", 0.0)
575
+ else:
576
+ return value * (m.get("ascent", 0.0) + m.get("descent", 0.0))
577
+ return 0.0
578
+
579
+ # ---- Fallback: treat as NPC ----
580
+ return value * this_inches
581
+
582
+
583
+ # ============================================================================
584
+ # Inverse transform: inches → target unit (port of unit.c:1226-1475)
585
+ # ============================================================================
586
+
587
+
588
+ def _transform_from_inches(
589
+ value: float,
590
+ unit: str,
591
+ gc_fontsize: float,
592
+ gc_cex: float,
593
+ gc_lineheight: float,
594
+ this_cm: float,
595
+ other_cm: float,
596
+ scale: float = 1.0,
597
+ ) -> float:
598
+ """Convert a value in inches to the given unit type.
599
+
600
+ Port of R ``unit.c:1226-1333 transformFromINCHES()``.
601
+ Handles absolute and font-relative units. For physical units,
602
+ applies the inverse of GSS_SCALE (unit.c:1313-1331).
603
+
604
+ Parameters
605
+ ----------
606
+ value : float
607
+ Value in inches.
608
+ unit : str
609
+ Target unit type string.
610
+ gc_fontsize, gc_cex, gc_lineheight : float
611
+ Font context parameters.
612
+ this_cm, other_cm : float
613
+ Viewport dimensions in CM for primary and orthogonal axis.
614
+ scale : float
615
+ GSS_SCALE zoom factor.
616
+
617
+ Returns
618
+ -------
619
+ float
620
+ The value in the target unit.
621
+ """
622
+ result = value
623
+
624
+ if unit == "npc":
625
+ # unit.c:1237 result/(thisCM/2.54)
626
+ this_inches = this_cm / 2.54
627
+ if this_inches < 1e-10:
628
+ if result != 0:
629
+ raise ValueError("Viewport has zero dimension(s)")
630
+ return 0.0
631
+ result = result / this_inches
632
+ elif unit == "inches":
633
+ pass # unit.c:1243
634
+ elif unit == "cm":
635
+ result = result * 2.54 # unit.c:1240
636
+ elif unit == "mm":
637
+ result = result * 25.4 # unit.c:1270
638
+ elif unit == "points":
639
+ result = result * 72.27 # unit.c:1275
640
+ elif unit == "picas":
641
+ result = result / 12.0 * 72.27 # unit.c:1278
642
+ elif unit == "bigpts":
643
+ result = result * 72.0 # unit.c:1281
644
+ elif unit == "dida":
645
+ result = result / 1238.0 * 1157.0 * 72.27 # unit.c:1284
646
+ elif unit == "cicero":
647
+ result = result / 1238.0 * 1157.0 * 72.27 / 12.0 # unit.c:1287
648
+ elif unit == "scaledpts":
649
+ result = result * 65536.0 * 72.27 # unit.c:1290
650
+ elif unit == "char":
651
+ # unit.c:1253 (result*72)/(gc->ps*gc->cex)
652
+ ps_cex = gc_fontsize * gc_cex
653
+ if ps_cex < 1e-10:
654
+ return 0.0
655
+ result = (result * 72.0) / ps_cex
656
+ elif unit == "lines":
657
+ # unit.c:1256 (result*72)/(gc->ps*gc->cex*gc->lineheight)
658
+ ps_cex_lh = gc_fontsize * gc_cex * gc_lineheight
659
+ if ps_cex_lh < 1e-10:
660
+ return 0.0
661
+ result = (result * 72.0) / ps_cex_lh
662
+ elif unit == "snpc":
663
+ # unit.c:1258-1268
664
+ if this_cm < 1e-6 or other_cm < 1e-6:
665
+ if result != 0:
666
+ raise ValueError("Viewport has zero dimension(s)")
667
+ return 0.0
668
+ min_inches = min(this_cm, other_cm) / 2.54
669
+ result = result / min_inches
670
+ else:
671
+ raise ValueError(f"Cannot convert from inches to unit {unit!r}")
672
+
673
+ # For physical units, reverse the GSS_SCALE (unit.c:1313-1331)
674
+ _PHYSICAL_UNITS = {
675
+ "inches", "cm", "mm", "points", "picas",
676
+ "bigpts", "dida", "cicero", "scaledpts",
677
+ }
678
+ if unit in _PHYSICAL_UNITS and scale != 0:
679
+ result = result / scale
680
+
681
+ return result
682
+
683
+
684
+ def _transform_xy_from_inches(
685
+ location_inches: float,
686
+ unit: str,
687
+ scalemin: float,
688
+ scalemax: float,
689
+ gc_fontsize: float,
690
+ gc_cex: float,
691
+ gc_lineheight: float,
692
+ this_cm: float,
693
+ other_cm: float,
694
+ scale: float = 1.0,
695
+ ) -> float:
696
+ """Convert a location in inches to the target unit.
697
+
698
+ Port of R ``unit.c:1348-1377 transformXYFromINCHES()``.
699
+ Handles the special NATIVE case (scale mapping).
700
+ """
701
+ if unit == "native":
702
+ this_inches = this_cm / 2.54
703
+ if this_inches < 1e-10:
704
+ if location_inches != 0:
705
+ raise ValueError("Viewport has zero dimension(s)")
706
+ return 0.0
707
+ # unit.c:1369 scalemin + (result/(thisCM/2.54))*(scalemax - scalemin)
708
+ return scalemin + (location_inches / this_inches) * (scalemax - scalemin)
709
+ return _transform_from_inches(
710
+ location_inches, unit,
711
+ gc_fontsize, gc_cex, gc_lineheight,
712
+ this_cm, other_cm, scale,
713
+ )
714
+
715
+
716
+ def _transform_wh_from_inches(
717
+ dimension_inches: float,
718
+ unit: str,
719
+ scalemin: float,
720
+ scalemax: float,
721
+ gc_fontsize: float,
722
+ gc_cex: float,
723
+ gc_lineheight: float,
724
+ this_cm: float,
725
+ other_cm: float,
726
+ scale: float = 1.0,
727
+ ) -> float:
728
+ """Convert a dimension in inches to the target unit.
729
+
730
+ Port of R ``unit.c:1379-1408 transformWidthHeightFromINCHES()``.
731
+ Handles the special NATIVE case (dimension = range fraction).
732
+ """
733
+ if unit == "native":
734
+ this_inches = this_cm / 2.54
735
+ if this_inches < 1e-10:
736
+ if dimension_inches != 0:
737
+ raise ValueError("Viewport has zero dimension(s)")
738
+ return 0.0
739
+ # unit.c:1400 (result/(thisCM/2.54))*(scalemax - scalemin)
740
+ return (dimension_inches / this_inches) * (scalemax - scalemin)
741
+ return _transform_from_inches(
742
+ dimension_inches, unit,
743
+ gc_fontsize, gc_cex, gc_lineheight,
744
+ this_cm, other_cm, scale,
745
+ )
746
+
747
+
748
+ def _transform_xy_to_npc(
749
+ value: float, from_unit: str,
750
+ scalemin: float, scalemax: float,
751
+ ) -> float:
752
+ """Relative unit to NPC -- port of ``unit.c:1418-1431 transformXYtoNPC()``.
753
+
754
+ Used when viewport has zero width/height to avoid divide-by-zero.
755
+ """
756
+ if from_unit == "npc":
757
+ return value
758
+ if from_unit == "native":
759
+ srange = scalemax - scalemin
760
+ if srange == 0:
761
+ return 0.0
762
+ return (value - scalemin) / srange
763
+ raise ValueError(f"Cannot convert {from_unit!r} to NPC (zero-dimension special case)")
764
+
765
+
766
+ def _transform_wh_to_npc(
767
+ value: float, from_unit: str,
768
+ scalemin: float, scalemax: float,
769
+ ) -> float:
770
+ """Relative dimension to NPC -- port of ``unit.c:1433-1446 transformWHtoNPC()``."""
771
+ if from_unit == "npc":
772
+ return value
773
+ if from_unit == "native":
774
+ srange = scalemax - scalemin
775
+ if srange == 0:
776
+ return 0.0
777
+ return value / srange
778
+ raise ValueError(f"Cannot convert {from_unit!r} to NPC (zero-dimension special case)")
779
+
780
+
781
+ def _transform_xy_from_npc(
782
+ value: float, to_unit: str,
783
+ scalemin: float, scalemax: float,
784
+ ) -> float:
785
+ """NPC to relative unit -- port of ``unit.c:1448-1461 transformXYfromNPC()``."""
786
+ if to_unit == "npc":
787
+ return value
788
+ if to_unit == "native":
789
+ return scalemin + value * (scalemax - scalemin)
790
+ raise ValueError(f"Cannot convert NPC to {to_unit!r} (zero-dimension special case)")
791
+
792
+
793
+ def _transform_wh_from_npc(
794
+ value: float, to_unit: str,
795
+ scalemin: float, scalemax: float,
796
+ ) -> float:
797
+ """NPC to relative dimension -- port of ``unit.c:1463-1475 transformWHfromNPC()``."""
798
+ if to_unit == "npc":
799
+ return value
800
+ if to_unit == "native":
801
+ return value * (scalemax - scalemin)
802
+ raise ValueError(f"Cannot convert NPC to {to_unit!r} (zero-dimension special case)")
803
+
804
+
805
+ # ============================================================================
806
+ # calcViewportTransform -- port of viewport.c:214-382
807
+ # ============================================================================
808
+
809
+ def calc_viewport_transform(
810
+ vp: Any,
811
+ parent_transform: np.ndarray,
812
+ parent_width_cm: float,
813
+ parent_height_cm: float,
814
+ parent_angle: float,
815
+ parent_context: ViewportContext,
816
+ gc_fontsize: float = 10.0,
817
+ gc_cex: float = 1.0,
818
+ gc_lineheight: float = 1.2,
819
+ str_metric_fn: Any = None,
820
+ grob_metric_fn: Any = None,
821
+ ) -> ViewportTransformResult:
822
+ """Compute the viewport's 3×3 transform matrix.
823
+
824
+ This is the core function that mirrors R's ``calcViewportTransform``
825
+ (viewport.c:214-382). It converts the viewport's position and
826
+ dimensions to inches, applies justification + rotation, and
827
+ combines with the parent's transform.
828
+
829
+ Parameters
830
+ ----------
831
+ vp : Viewport
832
+ The viewport being pushed.
833
+ parent_transform : ndarray (3,3)
834
+ The parent viewport's accumulated transform.
835
+ parent_width_cm, parent_height_cm : float
836
+ Parent viewport dimensions in CM.
837
+ parent_angle : float
838
+ Parent's accumulated rotation angle in degrees.
839
+ parent_context : ViewportContext
840
+ Parent's xscale/yscale.
841
+ gc_fontsize, gc_cex, gc_lineheight : float
842
+ Parent gpar font metrics.
843
+ str_metric_fn, grob_metric_fn : callable or None
844
+ Metric query callbacks.
845
+
846
+ Returns
847
+ -------
848
+ ViewportTransformResult
849
+ Contains width_cm, height_cm, rotation_angle, transform, vpc.
850
+ """
851
+ # -- viewport.c:308-313: convert vp location to INCHES --
852
+ vp_x_unit = getattr(vp, "_x", None)
853
+ vp_y_unit = getattr(vp, "_y", None)
854
+ vp_w_unit = getattr(vp, "_width", None)
855
+ vp_h_unit = getattr(vp, "_height", None)
856
+
857
+ # Default units from viewport
858
+ from ._units import Unit
859
+ if vp_x_unit is None:
860
+ vp_x_unit = Unit(0.5, "npc")
861
+ if vp_y_unit is None:
862
+ vp_y_unit = Unit(0.5, "npc")
863
+ if vp_w_unit is None:
864
+ vp_w_unit = Unit(1.0, "npc")
865
+ if vp_h_unit is None:
866
+ vp_h_unit = Unit(1.0, "npc")
867
+
868
+ x_inches = transform_x_to_inches(
869
+ vp_x_unit, 0, parent_context,
870
+ gc_fontsize, gc_cex, gc_lineheight,
871
+ parent_width_cm, parent_height_cm,
872
+ str_metric_fn, grob_metric_fn,
873
+ )
874
+ y_inches = transform_y_to_inches(
875
+ vp_y_unit, 0, parent_context,
876
+ gc_fontsize, gc_cex, gc_lineheight,
877
+ parent_width_cm, parent_height_cm,
878
+ str_metric_fn, grob_metric_fn,
879
+ )
880
+
881
+ # -- viewport.c:317-324: convert width/height to CM --
882
+ # Note: R stores these in CM, converting from inches * 2.54
883
+ vp_width_cm = transform_width_to_inches(
884
+ vp_w_unit, 0, parent_context,
885
+ gc_fontsize, gc_cex, gc_lineheight,
886
+ parent_width_cm, parent_height_cm,
887
+ str_metric_fn, grob_metric_fn,
888
+ ) * 2.54
889
+
890
+ vp_height_cm = transform_height_to_inches(
891
+ vp_h_unit, 0, parent_context,
892
+ gc_fontsize, gc_cex, gc_lineheight,
893
+ parent_width_cm, parent_height_cm,
894
+ str_metric_fn, grob_metric_fn,
895
+ ) * 2.54
896
+
897
+ # Non-finite check (viewport.c:327-331)
898
+ if (not math.isfinite(x_inches) or not math.isfinite(y_inches)
899
+ or not math.isfinite(vp_width_cm) or not math.isfinite(vp_height_cm)):
900
+ raise ValueError("non-finite location and/or size for viewport")
901
+
902
+ # -- viewport.c:334-335: justification offsets --
903
+ just = getattr(vp, "_just", (0.5, 0.5))
904
+ if isinstance(just, (list, tuple)) and len(just) >= 2:
905
+ hjust, vjust = float(just[0]), float(just[1])
906
+ else:
907
+ hjust, vjust = 0.5, 0.5
908
+
909
+ # justification() returns offsets in CM, then we convert to inches
910
+ xadj, yadj = justification(vp_width_cm, vp_height_cm, hjust, vjust)
911
+
912
+ # -- viewport.c:341-355: build transform chain --
913
+ # thisLocation: translate to viewport position (in inches)
914
+ this_location = translation(x_inches, y_inches)
915
+
916
+ # thisRotation: viewport rotation
917
+ vp_angle = float(getattr(vp, "_angle", 0))
918
+ if vp_angle != 0:
919
+ this_rotation = rotation(vp_angle)
920
+ else:
921
+ this_rotation = identity()
922
+
923
+ # thisJustification: translate by justification offsets (CM -> inches)
924
+ this_justification = translation(xadj / 2.54, yadj / 2.54)
925
+
926
+ # viewport.c:349: Position relative to origin of rotation THEN rotate
927
+ temp_transform = multiply(this_justification, this_rotation)
928
+
929
+ # viewport.c:352: Translate to bottom-left corner
930
+ this_transform = multiply(temp_transform, this_location)
931
+
932
+ # viewport.c:355: Combine with parent's transform
933
+ transform = multiply(this_transform, parent_transform)
934
+
935
+ # viewport.c:358: Sum up the rotation angles
936
+ rotation_angle = parent_angle + vp_angle
937
+
938
+ # Build viewport context for children
939
+ xscale = getattr(vp, "_xscale", [0.0, 1.0])
940
+ yscale = getattr(vp, "_yscale", [0.0, 1.0])
941
+ vpc = ViewportContext(
942
+ xscale=(float(xscale[0]), float(xscale[1])),
943
+ yscale=(float(yscale[0]), float(yscale[1])),
944
+ )
945
+
946
+ return ViewportTransformResult(
947
+ width_cm=vp_width_cm,
948
+ height_cm=vp_height_cm,
949
+ rotation_angle=rotation_angle,
950
+ transform=transform,
951
+ vpc=vpc,
952
+ )
953
+
954
+
955
+ def calc_root_transform(
956
+ device_width_cm: float,
957
+ device_height_cm: float,
958
+ ) -> ViewportTransformResult:
959
+ """Compute the root (device-level) viewport transform.
960
+
961
+ Port of ``viewport.c:233-260``: when the parent is NULL (top-level
962
+ viewport), the parent is the device itself.
963
+ """
964
+ return ViewportTransformResult(
965
+ width_cm=device_width_cm,
966
+ height_cm=device_height_cm,
967
+ rotation_angle=0.0,
968
+ transform=identity(),
969
+ vpc=ViewportContext(xscale=(0.0, 1.0), yscale=(0.0, 1.0)),
970
+ )