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,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