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/__init__.py +340 -0
- grid_py/_arrow.py +331 -0
- grid_py/_clippath.py +170 -0
- grid_py/_colour.py +815 -0
- grid_py/_coords.py +1534 -0
- grid_py/_curve.py +1668 -0
- grid_py/_display_list.py +507 -0
- grid_py/_draw.py +1397 -0
- grid_py/_edit.py +756 -0
- grid_py/_font_metrics.py +319 -0
- grid_py/_gpar.py +572 -0
- grid_py/_grab.py +501 -0
- grid_py/_grob.py +1377 -0
- grid_py/_group.py +798 -0
- grid_py/_highlevel.py +2176 -0
- grid_py/_just.py +361 -0
- grid_py/_layout.py +593 -0
- grid_py/_ls.py +895 -0
- grid_py/_mask.py +196 -0
- grid_py/_path.py +414 -0
- grid_py/_patterns.py +1049 -0
- grid_py/_primitives.py +2198 -0
- grid_py/_renderer_base.py +1184 -0
- grid_py/_scene_graph.py +248 -0
- grid_py/_size.py +1352 -0
- grid_py/_state.py +683 -0
- grid_py/_transforms.py +448 -0
- grid_py/_typeset.py +384 -0
- grid_py/_units.py +1924 -0
- grid_py/_utils.py +310 -0
- grid_py/_viewport.py +1649 -0
- grid_py/_vp_calc.py +970 -0
- grid_py/py.typed +0 -0
- grid_py/renderer.py +1762 -0
- grid_py/renderer_web.py +764 -0
- grid_py/resources/d3.v7.min.js +2 -0
- grid_py/resources/gridpy.css +80 -0
- grid_py/resources/gridpy.js +813 -0
- rgrid_python-4.5.3.dist-info/METADATA +489 -0
- rgrid_python-4.5.3.dist-info/RECORD +42 -0
- rgrid_python-4.5.3.dist-info/WHEEL +4 -0
- rgrid_python-4.5.3.dist-info/licenses/LICENSE +3 -0
grid_py/_font_metrics.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Pluggable text measurement backends for grid_py.
|
|
2
|
+
|
|
3
|
+
Provides font metrics implementations that renderers can use when they
|
|
4
|
+
lack native text measurement capabilities (e.g. the WebRenderer).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"FontMetricsBackend",
|
|
14
|
+
"CairoFontMetrics",
|
|
15
|
+
"FonttoolsMetrics",
|
|
16
|
+
"HeuristicMetrics",
|
|
17
|
+
"get_font_backend",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
# Module-level cache for the default backend.
|
|
21
|
+
_cached_backend: Optional["FontMetricsBackend"] = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FontMetricsBackend(ABC):
|
|
25
|
+
"""Abstract interface for text measurement."""
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def measure(self, text: str, gp: Any = None) -> Dict[str, float]:
|
|
29
|
+
"""Return ``{'ascent', 'descent', 'width'}`` in inches."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Helpers shared by concrete backends
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
def _extract_font_params(gp: Any) -> tuple:
|
|
37
|
+
"""Pull fontfamily, fontsize, fontface, and cex from a Gpar-like object.
|
|
38
|
+
|
|
39
|
+
Returns (family, fontsize, fontface, cex).
|
|
40
|
+
"""
|
|
41
|
+
family: Optional[str] = None
|
|
42
|
+
fontsize: float = 12.0
|
|
43
|
+
fontface: Any = None
|
|
44
|
+
cex: float = 1.0
|
|
45
|
+
|
|
46
|
+
if gp is not None:
|
|
47
|
+
val = gp.get("fontfamily", None)
|
|
48
|
+
if val is not None:
|
|
49
|
+
family = str(val)
|
|
50
|
+
val = gp.get("fontsize", None)
|
|
51
|
+
if val is not None:
|
|
52
|
+
fontsize = float(val)
|
|
53
|
+
val = gp.get("fontface", None)
|
|
54
|
+
if val is not None:
|
|
55
|
+
fontface = val
|
|
56
|
+
val = gp.get("cex", None)
|
|
57
|
+
if val is not None:
|
|
58
|
+
cex = float(val)
|
|
59
|
+
|
|
60
|
+
return family, fontsize, fontface, cex
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# Cairo backend
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
class CairoFontMetrics(FontMetricsBackend):
|
|
68
|
+
"""Text measurement using a Cairo context."""
|
|
69
|
+
|
|
70
|
+
def __init__(self) -> None:
|
|
71
|
+
import cairo
|
|
72
|
+
self._cairo = cairo
|
|
73
|
+
self._surface = cairo.ImageSurface(cairo.FORMAT_A8, 1, 1)
|
|
74
|
+
self._ctx = cairo.Context(self._surface)
|
|
75
|
+
|
|
76
|
+
def measure(self, text: str, gp: Any = None) -> Dict[str, float]:
|
|
77
|
+
cairo = self._cairo
|
|
78
|
+
ctx = self._ctx
|
|
79
|
+
|
|
80
|
+
family, fontsize, fontface, cex = _extract_font_params(gp)
|
|
81
|
+
|
|
82
|
+
slant = cairo.FONT_SLANT_NORMAL
|
|
83
|
+
weight = cairo.FONT_WEIGHT_NORMAL
|
|
84
|
+
|
|
85
|
+
if fontface is not None:
|
|
86
|
+
if fontface in (2, "bold"):
|
|
87
|
+
weight = cairo.FONT_WEIGHT_BOLD
|
|
88
|
+
elif fontface in (3, "italic"):
|
|
89
|
+
slant = cairo.FONT_SLANT_ITALIC
|
|
90
|
+
elif fontface in (4, "bold.italic"):
|
|
91
|
+
weight = cairo.FONT_WEIGHT_BOLD
|
|
92
|
+
slant = cairo.FONT_SLANT_ITALIC
|
|
93
|
+
|
|
94
|
+
ctx.select_font_face(family or "sans-serif", slant, weight)
|
|
95
|
+
|
|
96
|
+
# Scaled measurement to defeat Cairo toy-font integer quantisation.
|
|
97
|
+
_SCALE = 100
|
|
98
|
+
actual_size = fontsize * cex
|
|
99
|
+
ctx.set_font_size(actual_size * _SCALE)
|
|
100
|
+
|
|
101
|
+
te = ctx.text_extents(text)
|
|
102
|
+
|
|
103
|
+
# Text-specific ascent/descent from text_extents (matches R's
|
|
104
|
+
# GEStrMetric which returns per-string metrics).
|
|
105
|
+
ascent = (-te[1]) / _SCALE / 72.0
|
|
106
|
+
descent = max(0.0, (te[3] + te[1])) / _SCALE / 72.0
|
|
107
|
+
width = te[4] / _SCALE / 72.0
|
|
108
|
+
|
|
109
|
+
# Restore original size on the shared context.
|
|
110
|
+
ctx.set_font_size(actual_size)
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
"ascent": ascent,
|
|
114
|
+
"descent": descent,
|
|
115
|
+
"width": width,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# fonttools backend
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
class FonttoolsMetrics(FontMetricsBackend):
|
|
124
|
+
"""Text measurement using fontTools to read glyph advances from font files.
|
|
125
|
+
|
|
126
|
+
More accurate than heuristic estimation when pycairo is unavailable.
|
|
127
|
+
Raises ``ImportError`` if fontTools is not installed, or ``RuntimeError``
|
|
128
|
+
if no font files can be found on the system.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
def __init__(self) -> None:
|
|
132
|
+
from fontTools.ttLib import TTFont # noqa: F401 — validate import
|
|
133
|
+
self._font_cache: dict = {}
|
|
134
|
+
self._system_fonts: Optional[dict] = None
|
|
135
|
+
|
|
136
|
+
def _find_system_fonts(self) -> dict:
|
|
137
|
+
"""Build a map of family -> [(path, bold, italic), ...]."""
|
|
138
|
+
if self._system_fonts is not None:
|
|
139
|
+
return self._system_fonts
|
|
140
|
+
|
|
141
|
+
import glob
|
|
142
|
+
import os
|
|
143
|
+
from fontTools.ttLib import TTFont
|
|
144
|
+
|
|
145
|
+
self._system_fonts = {}
|
|
146
|
+
search_paths = []
|
|
147
|
+
|
|
148
|
+
conda_prefix = os.environ.get("CONDA_PREFIX", "")
|
|
149
|
+
if conda_prefix:
|
|
150
|
+
search_paths.append(os.path.join(conda_prefix, "fonts"))
|
|
151
|
+
search_paths.append(os.path.join(conda_prefix, "lib", "fonts"))
|
|
152
|
+
search_paths.extend([
|
|
153
|
+
"/usr/share/fonts", "/usr/local/share/fonts",
|
|
154
|
+
os.path.expanduser("~/.fonts"),
|
|
155
|
+
os.path.expanduser("~/.local/share/fonts"),
|
|
156
|
+
])
|
|
157
|
+
# matplotlib bundled fonts (optional)
|
|
158
|
+
try:
|
|
159
|
+
import matplotlib
|
|
160
|
+
search_paths.append(os.path.join(
|
|
161
|
+
os.path.dirname(matplotlib.__file__), "mpl-data", "fonts", "ttf"))
|
|
162
|
+
except ImportError:
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
for base in search_paths:
|
|
166
|
+
if not os.path.isdir(base):
|
|
167
|
+
continue
|
|
168
|
+
for pattern in ("**/*.ttf", "**/*.otf"):
|
|
169
|
+
for path in glob.glob(os.path.join(base, pattern), recursive=True):
|
|
170
|
+
font = None
|
|
171
|
+
try:
|
|
172
|
+
font = TTFont(path, lazy=True)
|
|
173
|
+
name_table = font["name"]
|
|
174
|
+
family = None
|
|
175
|
+
for record in name_table.names:
|
|
176
|
+
if record.nameID == 1:
|
|
177
|
+
family = record.toUnicode().lower()
|
|
178
|
+
break
|
|
179
|
+
if family:
|
|
180
|
+
os2 = font.get("OS/2")
|
|
181
|
+
bold = bool(os2.fsSelection & 0x20) if os2 else False
|
|
182
|
+
italic = bool(os2.fsSelection & 0x01) if os2 else False
|
|
183
|
+
self._system_fonts.setdefault(family, []).append(
|
|
184
|
+
(path, bold, italic))
|
|
185
|
+
except Exception:
|
|
186
|
+
# Corrupt/unreadable font file — skip it
|
|
187
|
+
pass
|
|
188
|
+
finally:
|
|
189
|
+
if font is not None:
|
|
190
|
+
font.close()
|
|
191
|
+
|
|
192
|
+
if not self._system_fonts:
|
|
193
|
+
raise RuntimeError("FonttoolsMetrics: no font files found on this system")
|
|
194
|
+
|
|
195
|
+
return self._system_fonts
|
|
196
|
+
|
|
197
|
+
def _get_font(self, family: Optional[str], bold: bool, italic: bool):
|
|
198
|
+
"""Load and cache a TTFont for the given style."""
|
|
199
|
+
from fontTools.ttLib import TTFont
|
|
200
|
+
|
|
201
|
+
key = (family or "sans-serif", bold, italic)
|
|
202
|
+
if key in self._font_cache:
|
|
203
|
+
return self._font_cache[key]
|
|
204
|
+
|
|
205
|
+
fonts = self._find_system_fonts()
|
|
206
|
+
family_lower = (family or "sans-serif").lower()
|
|
207
|
+
|
|
208
|
+
# Generic family aliases
|
|
209
|
+
_GENERIC = {
|
|
210
|
+
"sans-serif": ["dejavu sans", "liberation sans", "arial",
|
|
211
|
+
"helvetica", "source sans", "noto sans"],
|
|
212
|
+
"serif": ["dejavu serif", "liberation serif", "times", "noto serif"],
|
|
213
|
+
"mono": ["dejavu sans mono", "liberation mono", "courier",
|
|
214
|
+
"source code pro", "noto mono"],
|
|
215
|
+
"monospace": ["dejavu sans mono", "liberation mono", "courier"],
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
candidates = fonts.get(family_lower)
|
|
219
|
+
if candidates is None:
|
|
220
|
+
for alias in _GENERIC.get(family_lower, []):
|
|
221
|
+
candidates = fonts.get(alias)
|
|
222
|
+
if candidates:
|
|
223
|
+
break
|
|
224
|
+
if candidates is None:
|
|
225
|
+
# Use any available font
|
|
226
|
+
candidates = next(iter(fonts.values()))
|
|
227
|
+
|
|
228
|
+
# Best style match
|
|
229
|
+
best = candidates[0]
|
|
230
|
+
for path, b, i in candidates:
|
|
231
|
+
if b == bold and i == italic:
|
|
232
|
+
best = (path, b, i)
|
|
233
|
+
break
|
|
234
|
+
|
|
235
|
+
font = TTFont(best[0])
|
|
236
|
+
self._font_cache[key] = font
|
|
237
|
+
return font
|
|
238
|
+
|
|
239
|
+
def measure(self, text: str, gp: Any = None) -> Dict[str, float]:
|
|
240
|
+
family, fontsize, fontface, cex = _extract_font_params(gp)
|
|
241
|
+
effective_size = fontsize * cex
|
|
242
|
+
|
|
243
|
+
bold = fontface in (2, "bold", 4, "bold.italic")
|
|
244
|
+
italic = fontface in (3, "italic", "oblique", 4, "bold.italic")
|
|
245
|
+
|
|
246
|
+
font = self._get_font(family, bold, italic)
|
|
247
|
+
cmap = font.getBestCmap()
|
|
248
|
+
hmtx = font["hmtx"]
|
|
249
|
+
units_per_em = font["head"].unitsPerEm
|
|
250
|
+
|
|
251
|
+
total_advance = 0
|
|
252
|
+
for char in text:
|
|
253
|
+
code = ord(char)
|
|
254
|
+
if code in cmap:
|
|
255
|
+
advance, _ = hmtx[cmap[code]]
|
|
256
|
+
total_advance += advance
|
|
257
|
+
else:
|
|
258
|
+
total_advance += units_per_em // 2
|
|
259
|
+
|
|
260
|
+
width_inches = (total_advance / units_per_em) * effective_size / 72.0
|
|
261
|
+
|
|
262
|
+
if "OS/2" in font:
|
|
263
|
+
os2 = font["OS/2"]
|
|
264
|
+
ascent = os2.sTypoAscender / units_per_em * effective_size / 72.0
|
|
265
|
+
descent = abs(os2.sTypoDescender) / units_per_em * effective_size / 72.0
|
|
266
|
+
else:
|
|
267
|
+
hhea = font["hhea"]
|
|
268
|
+
ascent = hhea.ascent / units_per_em * effective_size / 72.0
|
|
269
|
+
descent = abs(hhea.descent) / units_per_em * effective_size / 72.0
|
|
270
|
+
|
|
271
|
+
return {"ascent": ascent, "descent": descent, "width": width_inches}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# ---------------------------------------------------------------------------
|
|
275
|
+
# Heuristic fallback
|
|
276
|
+
# ---------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
class HeuristicMetrics(FontMetricsBackend):
|
|
279
|
+
"""Rough estimates based on character count and font size."""
|
|
280
|
+
|
|
281
|
+
def measure(self, text: str, gp: Any = None) -> Dict[str, float]:
|
|
282
|
+
_family, fontsize, _fontface, cex = _extract_font_params(gp)
|
|
283
|
+
effective = fontsize * cex
|
|
284
|
+
avg_char_width = effective * 0.6 / 72.0
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
"ascent": effective * 0.75 / 72.0,
|
|
288
|
+
"descent": effective * 0.25 / 72.0,
|
|
289
|
+
"width": len(text) * avg_char_width,
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# ---------------------------------------------------------------------------
|
|
294
|
+
# Factory
|
|
295
|
+
# ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
def get_font_backend() -> FontMetricsBackend:
|
|
298
|
+
"""Return a cached :class:`FontMetricsBackend` instance.
|
|
299
|
+
|
|
300
|
+
Tries CairoFontMetrics first, then FonttoolsMetrics, then HeuristicMetrics.
|
|
301
|
+
"""
|
|
302
|
+
global _cached_backend
|
|
303
|
+
if _cached_backend is not None:
|
|
304
|
+
return _cached_backend
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
_cached_backend = CairoFontMetrics()
|
|
308
|
+
return _cached_backend
|
|
309
|
+
except ImportError:
|
|
310
|
+
pass
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
_cached_backend = FonttoolsMetrics()
|
|
314
|
+
return _cached_backend
|
|
315
|
+
except (ImportError, RuntimeError):
|
|
316
|
+
pass
|
|
317
|
+
|
|
318
|
+
_cached_backend = HeuristicMetrics()
|
|
319
|
+
return _cached_backend
|