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.
@@ -0,0 +1,764 @@
1
+ """WebRenderer — scene-graph renderer that outputs interactive HTML.
2
+
3
+ Builds a JSON-serialisable scene graph during ``draw_*()`` calls. The
4
+ browser-side runtime (``gridpy.js``) reads the scene graph and renders
5
+ it to layered SVG + Canvas with D3.js-powered interactions.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import io
12
+ import json
13
+ import os
14
+ from typing import Any, Dict, List, Optional, Tuple, Union
15
+
16
+ import numpy as np
17
+
18
+ from ._font_metrics import get_font_backend, FontMetricsBackend
19
+ from ._gpar import Gpar
20
+ from ._patterns import LinearGradient, RadialGradient, Pattern
21
+ from ._renderer_base import GridRenderer
22
+ from ._scene_graph import (
23
+ DefsCollection,
24
+ GrobNode,
25
+ SceneGraph,
26
+ SceneNode,
27
+ ViewportNode,
28
+ _IdGenerator,
29
+ )
30
+
31
+ __all__ = ["WebRenderer"]
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Colour / Gpar serialisation helpers
35
+ # ---------------------------------------------------------------------------
36
+
37
+ from ._colour import colour_to_css as _parse_colour_str
38
+
39
+
40
+ def _serialise_gpar(gp: Optional[Gpar], defs: DefsCollection, id_gen: _IdGenerator) -> dict:
41
+ """Convert Gpar to a JSON-safe dict. Gradient/pattern fills become def refs."""
42
+ if gp is None:
43
+ return {}
44
+ result: Dict[str, Any] = {}
45
+ for key in ("col", "fill", "lwd", "lty", "lineend", "linejoin",
46
+ "fontsize", "fontfamily", "fontface", "alpha"):
47
+ val = gp.get(key, None)
48
+ if val is None:
49
+ continue
50
+ # Unwrap single-element lists/arrays to scalar
51
+ if isinstance(val, (list, tuple, np.ndarray)) and not isinstance(val, str):
52
+ if len(val) == 0:
53
+ continue
54
+ if len(val) == 1:
55
+ val = val[0]
56
+ else:
57
+ # Multi-element: keep as list for per-element rendering
58
+ if key in ("col", "fill"):
59
+ result[key] = [_parse_colour_str(v) for v in val]
60
+ elif key in ("lwd", "fontsize", "alpha"):
61
+ result[key] = [float(v) for v in val]
62
+ else:
63
+ result[key] = [str(v) for v in val]
64
+ continue
65
+ if key in ("col", "fill"):
66
+ if isinstance(val, LinearGradient):
67
+ grad_id = _register_gradient(val, defs, id_gen)
68
+ result[key] = f"url(#{grad_id})"
69
+ continue
70
+ if isinstance(val, RadialGradient):
71
+ grad_id = _register_gradient(val, defs, id_gen)
72
+ result[key] = f"url(#{grad_id})"
73
+ continue
74
+ if isinstance(val, Pattern):
75
+ pat_id = _register_pattern(val, defs, id_gen)
76
+ result[key] = f"url(#{pat_id})"
77
+ continue
78
+ result[key] = _parse_colour_str(val)
79
+ elif key in ("lwd", "fontsize", "alpha"):
80
+ result[key] = float(val)
81
+ else:
82
+ result[key] = str(val) if not isinstance(val, (int, float)) else val
83
+ return result
84
+
85
+
86
+ def _register_gradient(grad: Any, defs: DefsCollection, id_gen: _IdGenerator) -> str:
87
+ grad_id = id_gen.next("grad")
88
+ entry: Dict[str, Any] = {"id": grad_id}
89
+ if isinstance(grad, LinearGradient):
90
+ entry["type"] = "linear"
91
+ entry["x1"] = float(grad.x1.values[0])
92
+ entry["y1"] = float(grad.y1.values[0])
93
+ entry["x2"] = float(grad.x2.values[0])
94
+ entry["y2"] = float(grad.y2.values[0])
95
+ else:
96
+ entry["type"] = "radial"
97
+ entry["cx1"] = float(grad.cx1.values[0])
98
+ entry["cy1"] = float(grad.cy1.values[0])
99
+ entry["r1"] = float(grad.r1.values[0])
100
+ entry["cx2"] = float(grad.cx2.values[0])
101
+ entry["cy2"] = float(grad.cy2.values[0])
102
+ entry["r2"] = float(grad.r2.values[0])
103
+ entry["colours"] = list(grad.colours)
104
+ entry["stops"] = [float(s) for s in grad.stops]
105
+ entry["extend"] = getattr(grad, "extend", "pad")
106
+ defs.gradients.append(entry)
107
+ return grad_id
108
+
109
+
110
+ def _register_pattern(pat: Any, defs: DefsCollection, id_gen: _IdGenerator) -> str:
111
+ pat_id = id_gen.next("pat")
112
+ entry: Dict[str, Any] = {
113
+ "id": pat_id,
114
+ "width": float(pat.width.values[0]) if hasattr(pat.width, "values") else float(pat.width),
115
+ "height": float(pat.height.values[0]) if hasattr(pat.height, "values") else float(pat.height),
116
+ }
117
+ defs.patterns.append(entry)
118
+ return pat_id
119
+
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # HTML template
123
+ # ---------------------------------------------------------------------------
124
+
125
+ _D3_CDN_URL = "https://d3js.org/d3.v7.min.js"
126
+
127
+ _RESOURCES_DIR = os.path.join(os.path.dirname(__file__), "resources")
128
+
129
+
130
+ def _load_resource(name: str) -> str:
131
+ path = os.path.join(_RESOURCES_DIR, name)
132
+ with open(path, "r", encoding="utf-8") as f:
133
+ return f.read()
134
+
135
+
136
+ _HTML_TEMPLATE = """\
137
+ <!DOCTYPE html>
138
+ <html lang="en">
139
+ <head>
140
+ <meta charset="utf-8">
141
+ <style>
142
+ {css}
143
+ </style>
144
+ </head>
145
+ <body>
146
+ <div id="gridpy-plot"></div>
147
+ {d3_block}
148
+ <script>
149
+ {runtime_js}
150
+ </script>
151
+ <script>
152
+ gridpy.render(
153
+ document.getElementById("gridpy-plot"),
154
+ {scene_json},
155
+ {{interactive: {interactive}, theme: "{theme}"}}
156
+ );
157
+ </script>
158
+ </body>
159
+ </html>
160
+ """
161
+
162
+
163
+ def _array_to_data_uri(image: Any) -> str:
164
+ """Convert a numpy image array to a base64 PNG data URI."""
165
+ from PIL import Image as PILImage
166
+
167
+ img_array = np.asarray(image)
168
+ if img_array.dtype != np.uint8:
169
+ if img_array.max() <= 1.0:
170
+ img_array = (img_array * 255).astype(np.uint8)
171
+ else:
172
+ img_array = img_array.astype(np.uint8)
173
+
174
+ if img_array.ndim == 2:
175
+ pil_img = PILImage.fromarray(img_array)
176
+ elif img_array.shape[2] == 3:
177
+ pil_img = PILImage.fromarray(img_array)
178
+ else:
179
+ pil_img = PILImage.fromarray(img_array)
180
+
181
+ buf = io.BytesIO()
182
+ pil_img.save(buf, format="PNG")
183
+ b64 = base64.b64encode(buf.getvalue()).decode("ascii")
184
+ return f"data:image/png;base64,{b64}"
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # WebRenderer
189
+ # ---------------------------------------------------------------------------
190
+
191
+
192
+ class WebRenderer(GridRenderer):
193
+ """Scene-graph renderer producing interactive HTML output.
194
+
195
+ Parameters
196
+ ----------
197
+ width : float
198
+ Device width in inches.
199
+ height : float
200
+ Device height in inches.
201
+ dpi : float
202
+ Dots per inch (default 150).
203
+ default_hint : str
204
+ Default ``render_hint`` for grob nodes (``"auto"`` | ``"svg"`` | ``"canvas"``).
205
+ theme : str
206
+ CSS theme class (``"light"`` or ``"dark"``).
207
+ """
208
+
209
+ def __init__(
210
+ self,
211
+ width: float = 7.0,
212
+ height: float = 5.0,
213
+ dpi: float = 150.0,
214
+ default_hint: str = "auto",
215
+ theme: str = "light",
216
+ ) -> None:
217
+ device_w = width * dpi
218
+ device_h = height * dpi
219
+ super().__init__(width, height, dpi,
220
+ device_width=device_w, device_height=device_h)
221
+ self._default_hint = default_hint
222
+ self._theme = theme
223
+
224
+ # Font metrics backend
225
+ self._font_backend: FontMetricsBackend = get_font_backend()
226
+
227
+ # Scene graph
228
+ self._id_gen = _IdGenerator()
229
+ self._defs = DefsCollection()
230
+ self._scene_root = ViewportNode(
231
+ node_id=self._id_gen.next("vp"),
232
+ node_type="viewport",
233
+ name="ROOT",
234
+ transform={"x0": 0.0, "y0": 0.0, "w": device_w, "h": device_h},
235
+ clip=False,
236
+ )
237
+ self._node_stack: List[SceneNode] = [self._scene_root]
238
+
239
+ # Path collection buffer
240
+ self._path_buffer: List[SceneNode] = []
241
+
242
+ @property
243
+ def _current_parent(self) -> SceneNode:
244
+ return self._node_stack[-1]
245
+
246
+ # ===================================================================== #
247
+ # Viewport management (augments base class) #
248
+ # ===================================================================== #
249
+
250
+ def push_viewport(self, vp: Any) -> None:
251
+ super().push_viewport(vp)
252
+ x0, y0, pw, ph = self.get_viewport_bounds()
253
+ clip_active = bool(self._clip_stack and self._clip_stack[-1])
254
+
255
+ clip_id: Optional[str] = None
256
+ if clip_active:
257
+ clip_id = self._id_gen.next("clip")
258
+ self._defs.clip_paths.append({
259
+ "id": clip_id,
260
+ "x": x0, "y": y0, "w": pw, "h": ph,
261
+ })
262
+
263
+ vp_node = ViewportNode(
264
+ node_id=self._id_gen.next("vp"),
265
+ node_type="viewport",
266
+ name=getattr(vp, "name", "") or getattr(vp, "_name", "") or "",
267
+ transform={"x0": x0, "y0": y0, "w": pw, "h": ph},
268
+ clip=clip_active,
269
+ clip_id=clip_id,
270
+ )
271
+ self._current_parent.children.append(vp_node)
272
+ self._node_stack.append(vp_node)
273
+
274
+ def pop_viewport(self) -> None:
275
+ super().pop_viewport()
276
+ if len(self._node_stack) > 1:
277
+ self._node_stack.pop()
278
+
279
+ # ===================================================================== #
280
+ # Clipping (abstract implementations) #
281
+ # ===================================================================== #
282
+
283
+ def _apply_clip_rect(self, x0: float, y0: float, w: float, h: float) -> None:
284
+ pass # Clip is recorded via ViewportNode.clip_id in push_viewport
285
+
286
+ def _restore_clip(self) -> None:
287
+ pass # SVG clip-path / Canvas ctx.restore handled by JS runtime
288
+
289
+ # ===================================================================== #
290
+ # State save/restore #
291
+ # ===================================================================== #
292
+
293
+ def save_state(self) -> None:
294
+ pass # No graphics state to save in scene graph mode
295
+
296
+ def restore_state(self) -> None:
297
+ pass
298
+
299
+ # ===================================================================== #
300
+ # Path collection #
301
+ # ===================================================================== #
302
+
303
+ def begin_path_collect(self, rule: str = "winding") -> None:
304
+ self._path_collecting = True
305
+ self._path_buffer = []
306
+ self._path_rule = rule
307
+
308
+ def end_path_stroke(self, gp: Optional[Any] = None) -> None:
309
+ self._path_collecting = False
310
+ node = GrobNode(
311
+ node_id=self._id_gen.next("grob"),
312
+ node_type="compound_stroke",
313
+ props={"rule": getattr(self, "_path_rule", "winding"),
314
+ "sub_paths": [n.to_dict() for n in self._path_buffer]},
315
+ gpar=_serialise_gpar(gp, self._defs, self._id_gen),
316
+ render_hint="svg",
317
+ )
318
+ self._current_parent.children.append(node)
319
+ self._path_buffer = []
320
+
321
+ def end_path_fill(self, gp: Optional[Any] = None) -> None:
322
+ self._path_collecting = False
323
+ node = GrobNode(
324
+ node_id=self._id_gen.next("grob"),
325
+ node_type="compound_fill",
326
+ props={"rule": getattr(self, "_path_rule", "winding"),
327
+ "sub_paths": [n.to_dict() for n in self._path_buffer]},
328
+ gpar=_serialise_gpar(gp, self._defs, self._id_gen),
329
+ render_hint="svg",
330
+ )
331
+ self._current_parent.children.append(node)
332
+ self._path_buffer = []
333
+
334
+ def end_path_fill_stroke(self, gp: Optional[Any] = None) -> None:
335
+ self._path_collecting = False
336
+ node = GrobNode(
337
+ node_id=self._id_gen.next("grob"),
338
+ node_type="compound_fill_stroke",
339
+ props={"rule": getattr(self, "_path_rule", "winding"),
340
+ "sub_paths": [n.to_dict() for n in self._path_buffer]},
341
+ gpar=_serialise_gpar(gp, self._defs, self._id_gen),
342
+ render_hint="svg",
343
+ )
344
+ self._current_parent.children.append(node)
345
+ self._path_buffer = []
346
+
347
+ # ===================================================================== #
348
+ # Drawing primitives — append to scene graph #
349
+ # ===================================================================== #
350
+
351
+ def _append_node(self, node: SceneNode) -> None:
352
+ # Attach grob metadata if available (for tooltip data)
353
+ if self._current_grob_metadata and isinstance(node, GrobNode):
354
+ node.metadata = dict(self._current_grob_metadata)
355
+ if self._path_collecting:
356
+ self._path_buffer.append(node)
357
+ else:
358
+ self._current_parent.children.append(node)
359
+
360
+ # All draw_* methods receive device coordinates (pixels) from _draw.py.
361
+ # CairoRenderer uses top-left origin with y increasing downward —
362
+ # resolve_y() already applies the Y-flip, so coordinates are in
363
+ # the same space as SVG (top-left origin). No further transform needed.
364
+
365
+ def draw_rect(self, x: float, y: float, w: float, h: float,
366
+ hjust: float = 0.5, vjust: float = 0.5,
367
+ gp: Optional[Any] = None) -> None:
368
+ x0 = x - w * hjust
369
+ y0 = y - h * (1.0 - vjust)
370
+ node = GrobNode(
371
+ node_id=self._id_gen.next("grob"),
372
+ node_type="rect",
373
+ props={"x": x0, "y": y0, "w": w, "h": h},
374
+ gpar=_serialise_gpar(gp, self._defs, self._id_gen),
375
+ render_hint=self._default_hint,
376
+ )
377
+ self._append_node(node)
378
+
379
+ def draw_circle(self, x: float, y: float, r: float,
380
+ gp: Optional[Any] = None) -> None:
381
+ node = GrobNode(
382
+ node_id=self._id_gen.next("grob"),
383
+ node_type="circle",
384
+ props={"x": x, "y": y, "r": r},
385
+ gpar=_serialise_gpar(gp, self._defs, self._id_gen),
386
+ render_hint=self._default_hint,
387
+ )
388
+ self._append_node(node)
389
+
390
+ def draw_line(self, x: "np.ndarray", y: "np.ndarray",
391
+ gp: Optional[Any] = None) -> None:
392
+ n = max(len(x), len(y))
393
+ if n < 2:
394
+ return
395
+ if len(x) < n:
396
+ x = np.resize(x, n)
397
+ if len(y) < n:
398
+ y = np.resize(y, n)
399
+ node = GrobNode(
400
+ node_id=self._id_gen.next("grob"),
401
+ node_type="polyline",
402
+ props={"x": [float(v) for v in x], "y": [float(v) for v in y]},
403
+ gpar=_serialise_gpar(gp, self._defs, self._id_gen),
404
+ render_hint=self._default_hint,
405
+ )
406
+ self._append_node(node)
407
+
408
+ def draw_polyline(self, x: "np.ndarray", y: "np.ndarray",
409
+ id_: Optional["np.ndarray"] = None,
410
+ gp: Optional[Any] = None) -> None:
411
+ if id_ is None:
412
+ self.draw_line(x, y, gp)
413
+ return
414
+ groups = []
415
+ for uid in np.unique(id_):
416
+ mask = id_ == uid
417
+ px = x[mask]
418
+ py = y[mask]
419
+ if len(px) < 2:
420
+ continue
421
+ groups.append({
422
+ "x": [float(v) for v in px],
423
+ "y": [float(v) for v in py],
424
+ })
425
+ node = GrobNode(
426
+ node_id=self._id_gen.next("grob"),
427
+ node_type="polyline",
428
+ props={"groups": groups},
429
+ gpar=_serialise_gpar(gp, self._defs, self._id_gen),
430
+ render_hint=self._default_hint,
431
+ )
432
+ self._append_node(node)
433
+
434
+ def draw_segments(self, x0: "np.ndarray", y0: "np.ndarray",
435
+ x1: "np.ndarray", y1: "np.ndarray",
436
+ gp: Optional[Any] = None) -> None:
437
+ n = min(len(x0), len(y0), len(x1), len(y1))
438
+ node = GrobNode(
439
+ node_id=self._id_gen.next("grob"),
440
+ node_type="segments",
441
+ props={
442
+ "x0": [float(x0[i]) for i in range(n)],
443
+ "y0": [float(y0[i]) for i in range(n)],
444
+ "x1": [float(x1[i]) for i in range(n)],
445
+ "y1": [float(y1[i]) for i in range(n)],
446
+ },
447
+ gpar=_serialise_gpar(gp, self._defs, self._id_gen),
448
+ render_hint=self._default_hint,
449
+ )
450
+ self._append_node(node)
451
+
452
+ def draw_polygon(self, x: "np.ndarray", y: "np.ndarray",
453
+ gp: Optional[Any] = None) -> None:
454
+ if len(x) < 3:
455
+ return
456
+ node = GrobNode(
457
+ node_id=self._id_gen.next("grob"),
458
+ node_type="polygon",
459
+ props={"x": [float(v) for v in x], "y": [float(v) for v in y]},
460
+ gpar=_serialise_gpar(gp, self._defs, self._id_gen),
461
+ render_hint=self._default_hint,
462
+ )
463
+ self._append_node(node)
464
+
465
+ def draw_path(self, x: "np.ndarray", y: "np.ndarray",
466
+ path_id: "np.ndarray", rule: str = "winding",
467
+ gp: Optional[Any] = None) -> None:
468
+ groups = []
469
+ for pid in np.unique(path_id):
470
+ mask = path_id == pid
471
+ px = x[mask]
472
+ py = y[mask]
473
+ if len(px) < 2:
474
+ continue
475
+ groups.append({
476
+ "x": [float(v) for v in px],
477
+ "y": [float(v) for v in py],
478
+ })
479
+ node = GrobNode(
480
+ node_id=self._id_gen.next("grob"),
481
+ node_type="path",
482
+ props={"groups": groups, "rule": rule},
483
+ gpar=_serialise_gpar(gp, self._defs, self._id_gen),
484
+ render_hint=self._default_hint,
485
+ )
486
+ self._append_node(node)
487
+
488
+ def draw_text(self, x: float, y: float, label: str,
489
+ rot: float = 0.0, hjust: float = 0.5, vjust: float = 0.5,
490
+ gp: Optional[Any] = None) -> None:
491
+ node = GrobNode(
492
+ node_id=self._id_gen.next("grob"),
493
+ node_type="text",
494
+ props={
495
+ "x": x, "y": y,
496
+ "label": str(label),
497
+ "rot": rot, "hjust": hjust, "vjust": vjust,
498
+ },
499
+ gpar=_serialise_gpar(gp, self._defs, self._id_gen),
500
+ render_hint="svg", # Text always SVG
501
+ )
502
+ self._append_node(node)
503
+
504
+ def draw_points(self, x: "np.ndarray", y: "np.ndarray",
505
+ size: float = 1.0, pch: Any = 19,
506
+ gp: Optional[Any] = None) -> None:
507
+ node = GrobNode(
508
+ node_id=self._id_gen.next("grob"),
509
+ node_type="points",
510
+ props={
511
+ "x": [float(v) for v in x], "y": [float(v) for v in y],
512
+ "size": float(size),
513
+ "pch": int(pch) if not isinstance(pch, (list, tuple, np.ndarray)) else [int(p) for p in pch],
514
+ },
515
+ gpar=_serialise_gpar(gp, self._defs, self._id_gen),
516
+ render_hint=self._default_hint,
517
+ )
518
+ self._append_node(node)
519
+
520
+ def draw_raster(self, image: Any, x: float, y: float,
521
+ w: float, h: float, interpolate: bool = True) -> None:
522
+ data_uri = _array_to_data_uri(image)
523
+ node = GrobNode(
524
+ node_id=self._id_gen.next("grob"),
525
+ node_type="raster",
526
+ props={
527
+ "x": x, "y": y,
528
+ "w": w, "h": h,
529
+ "src": data_uri,
530
+ "interpolate": interpolate,
531
+ },
532
+ gpar={},
533
+ render_hint="svg",
534
+ )
535
+ self._append_node(node)
536
+
537
+ def draw_roundrect(self, x: float, y: float, w: float, h: float,
538
+ r: float = 0.0, hjust: float = 0.5, vjust: float = 0.5,
539
+ gp: Optional[Any] = None) -> None:
540
+ x0 = x - w * hjust
541
+ y0 = y - h * (1.0 - vjust)
542
+ dr = min(r, w / 2, h / 2) if r > 0 else 0.0
543
+ node = GrobNode(
544
+ node_id=self._id_gen.next("grob"),
545
+ node_type="roundrect",
546
+ props={"x": x0, "y": y0, "w": w, "h": h, "r": dr},
547
+ gpar=_serialise_gpar(gp, self._defs, self._id_gen),
548
+ render_hint=self._default_hint,
549
+ )
550
+ self._append_node(node)
551
+
552
+ def move_to(self, x: float, y: float) -> None:
553
+ self._pen_x = x
554
+ self._pen_y = y
555
+
556
+ def line_to(self, x: float, y: float,
557
+ gp: Optional[Any] = None) -> None:
558
+ x0 = getattr(self, "_pen_x", 0.0)
559
+ y0 = getattr(self, "_pen_y", 0.0)
560
+ node = GrobNode(
561
+ node_id=self._id_gen.next("grob"),
562
+ node_type="polyline",
563
+ props={
564
+ "x": [x0, x],
565
+ "y": [y0, y],
566
+ },
567
+ gpar=_serialise_gpar(gp, self._defs, self._id_gen),
568
+ render_hint=self._default_hint,
569
+ )
570
+ self._append_node(node)
571
+ self._pen_x = x
572
+ self._pen_y = y
573
+
574
+ # ===================================================================== #
575
+ # Clipping (explicit push/pop) #
576
+ # ===================================================================== #
577
+
578
+ def push_clip(self, x0: float, y0: float, x1: float, y1: float) -> None:
579
+ clip_id = self._id_gen.next("clip")
580
+ dx0 = min(x0, x1)
581
+ dy0 = min(y0, y1)
582
+ dw = abs(x1 - x0)
583
+ dh = abs(y1 - y0)
584
+ self._defs.clip_paths.append({
585
+ "id": clip_id, "x": dx0, "y": dy0, "w": dw, "h": dh,
586
+ })
587
+ # Wrap subsequent children in a clipped group
588
+ clip_group = ViewportNode(
589
+ node_id=self._id_gen.next("vp"),
590
+ node_type="viewport",
591
+ name="__clip__",
592
+ transform=self._current_parent.transform
593
+ if isinstance(self._current_parent, ViewportNode) else {},
594
+ clip=True,
595
+ clip_id=clip_id,
596
+ )
597
+ self._current_parent.children.append(clip_group)
598
+ self._node_stack.append(clip_group)
599
+
600
+ def pop_clip(self) -> None:
601
+ if len(self._node_stack) > 1:
602
+ self._node_stack.pop()
603
+
604
+ # ===================================================================== #
605
+ # Text metrics #
606
+ # ===================================================================== #
607
+
608
+ def text_extents(self, text: str, gp: Optional[Any] = None) -> Dict[str, float]:
609
+ return self._font_backend.measure(text, gp)
610
+
611
+ # ===================================================================== #
612
+ # Masking #
613
+ # ===================================================================== #
614
+
615
+ def render_mask(self, mask_grob: Any) -> Any:
616
+ mask_id = self._id_gen.next("mask")
617
+ sub = WebRenderer(
618
+ width=self.width_in, height=self.height_in,
619
+ dpi=self.dpi, default_hint="svg",
620
+ )
621
+ from ._draw import grid_draw
622
+ from ._state import get_state
623
+ state = get_state()
624
+ orig = state._renderer
625
+ state._renderer = sub
626
+ try:
627
+ grid_draw(mask_grob, recording=False)
628
+ finally:
629
+ state._renderer = orig
630
+ self._defs.masks.append({
631
+ "id": mask_id,
632
+ "content": sub._scene_root.to_dict(),
633
+ })
634
+ return mask_id
635
+
636
+ def apply_mask(self, mask_surface: Any, mask_type: str = "alpha") -> None:
637
+ if isinstance(mask_surface, str):
638
+ # mask_surface is actually a mask_id
639
+ if isinstance(self._current_parent, ViewportNode):
640
+ self._current_parent.mask_id = mask_surface
641
+ self._current_parent.mask_type = mask_type
642
+
643
+ # ===================================================================== #
644
+ # Output #
645
+ # ===================================================================== #
646
+
647
+ def to_scene_dict(self) -> dict:
648
+ """Return the scene graph as a plain dict (JSON-serialisable)."""
649
+ return SceneGraph(
650
+ width=self.width_in * self.dpi,
651
+ height=self.height_in * self.dpi,
652
+ dpi=self.dpi,
653
+ root=self._scene_root,
654
+ defs=self._defs,
655
+ ).to_dict()
656
+
657
+ def to_scene_json(self, indent: Optional[int] = None) -> str:
658
+ """Return the scene graph as a JSON string."""
659
+ return json.dumps(self.to_scene_dict(), indent=indent)
660
+
661
+ def to_html(self, interactive: bool = True, cdn: bool = True,
662
+ inline_d3: bool = False) -> str:
663
+ """Generate a self-contained HTML file.
664
+
665
+ Parameters
666
+ ----------
667
+ interactive : bool
668
+ Enable D3 interactions (zoom, pan, tooltip).
669
+ cdn : bool
670
+ Load D3 from CDN (for ``save()``). Ignored when *inline_d3* is True.
671
+ inline_d3 : bool
672
+ Embed D3 source directly in the HTML. Use this when the output
673
+ must work without network access (e.g. Jupyter iframe).
674
+ """
675
+ scene_json = self.to_scene_json()
676
+ css = _load_resource("gridpy.css")
677
+ runtime_js = _load_resource("gridpy.js")
678
+ if inline_d3:
679
+ d3_block = "<script>" + _load_resource("d3.v7.min.js") + "</script>"
680
+ elif cdn:
681
+ d3_block = f'<script src="{_D3_CDN_URL}"></script>'
682
+ else:
683
+ d3_block = ""
684
+ return _HTML_TEMPLATE.format(
685
+ css=css,
686
+ d3_block=d3_block,
687
+ runtime_js=runtime_js,
688
+ scene_json=scene_json,
689
+ interactive="true" if interactive else "false",
690
+ theme=self._theme,
691
+ )
692
+
693
+ def _repr_html_(self) -> str:
694
+ """Jupyter notebook display integration.
695
+
696
+ Uses an ``<iframe srcdoc="...">`` so that the embedded ``<script>``
697
+ tags execute normally (``innerHTML`` injection silently drops them).
698
+ D3 is inlined to avoid CSP / sandbox restrictions on external scripts.
699
+ """
700
+ full_html = self.to_html(interactive=True, inline_d3=True)
701
+ # Escape for embedding inside the srcdoc="..." attribute
702
+ escaped = full_html.replace("&", "&amp;").replace('"', "&quot;")
703
+ w = int(self.width_in * self.dpi)
704
+ h = int(self.height_in * self.dpi)
705
+ return (
706
+ f'<iframe srcdoc="{escaped}" '
707
+ f'width="{w}" height="{h}" '
708
+ f'style="border:none;" '
709
+ f'sandbox="allow-scripts"></iframe>'
710
+ )
711
+
712
+ def save(self, filename: str) -> None:
713
+ """Save to an HTML file."""
714
+ with open(filename, "w", encoding="utf-8") as f:
715
+ f.write(self.to_html())
716
+
717
+ def new_page(self, bg: Any = "white") -> None:
718
+ """Reset the scene graph for a new page."""
719
+ self._id_gen.reset()
720
+ self._defs = DefsCollection()
721
+ dw = self.width_in * self.dpi
722
+ dh = self.height_in * self.dpi
723
+ self._scene_root = ViewportNode(
724
+ node_id=self._id_gen.next("vp"),
725
+ node_type="viewport",
726
+ name="ROOT",
727
+ transform={"x0": 0.0, "y0": 0.0, "w": dw, "h": dh},
728
+ clip=False,
729
+ )
730
+ self._node_stack = [self._scene_root]
731
+ # Re-init base class viewport stack
732
+ from ._vp_calc import calc_root_transform
733
+ root_vtr = calc_root_transform(self.width_in * 2.54, self.height_in * 2.54)
734
+ self._vp_transform_stack = [root_vtr]
735
+ self._vp_obj_stack = [None]
736
+ self._layout_stack = []
737
+ self._layout_depth_stack = []
738
+ self._clip_stack = []
739
+ self._path_collecting = False
740
+
741
+ def finish(self) -> None:
742
+ pass # No resources to release
743
+
744
+ def get_surface(self) -> Any:
745
+ """WebRenderer has no Cairo surface; return ``None``."""
746
+ return None
747
+
748
+ def write_to_png(self, filename: str) -> None:
749
+ """Not supported — WebRenderer produces HTML, not PNG.
750
+
751
+ Use :meth:`save` for HTML output.
752
+ """
753
+ raise NotImplementedError(
754
+ "WebRenderer produces HTML, not PNG. Use save() or to_html()."
755
+ )
756
+
757
+ def to_png_bytes(self) -> bytes:
758
+ """Not supported — WebRenderer produces HTML, not PNG.
759
+
760
+ Use :meth:`to_scene_json` or :meth:`to_html`.
761
+ """
762
+ raise NotImplementedError(
763
+ "WebRenderer produces HTML, not PNG. Use to_scene_json() or to_html()."
764
+ )