universal-render 0.1.0__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.
- universal_render/__init__.py +277 -0
- universal_render/backend.py +375 -0
- universal_render/compat.py +128 -0
- universal_render/diagnostics.py +249 -0
- universal_render/fonts.py +516 -0
- universal_render/highlevel.py +378 -0
- universal_render/layout.py +967 -0
- universal_render/mpl_support.py +1144 -0
- universal_render/renderer.py +806 -0
- universal_render/scripts.py +311 -0
- universal_render-0.1.0.dist-info/METADATA +189 -0
- universal_render-0.1.0.dist-info/RECORD +15 -0
- universal_render-0.1.0.dist-info/WHEEL +5 -0
- universal_render-0.1.0.dist-info/licenses/LICENSE +21 -0
- universal_render-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
# universal_render/fonts.py
|
|
2
|
+
"""
|
|
3
|
+
Script-aware font management: per-script candidate tables, glyph-coverage
|
|
4
|
+
validation, and automatic font fallback.
|
|
5
|
+
|
|
6
|
+
Resolution order for a piece of text:
|
|
7
|
+
|
|
8
|
+
1. Explicit ``font_path`` (registered, its family used directly)
|
|
9
|
+
2. Explicit ``font_family`` (validated for coverage; falls back if it
|
|
10
|
+
cannot draw the text and fallback is allowed)
|
|
11
|
+
3. Per-script default set via set_script_font()
|
|
12
|
+
4. First installed candidate from SCRIPT_FONT_CANDIDATES that covers
|
|
13
|
+
the text
|
|
14
|
+
5. The package default font
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
from dataclasses import dataclass, asdict, field
|
|
20
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
21
|
+
|
|
22
|
+
from .backend import ensure_qt_application
|
|
23
|
+
from .scripts import COMMON, INHERITED, detect_char_script, detect_script, segment_runs
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
from PySide6.QtGui import (
|
|
27
|
+
QFont,
|
|
28
|
+
QFontDatabase,
|
|
29
|
+
QFontMetrics,
|
|
30
|
+
QRawFont,
|
|
31
|
+
)
|
|
32
|
+
QT_FONT_AVAILABLE = True
|
|
33
|
+
QT_FONT_IMPORT_ERROR = None
|
|
34
|
+
except Exception as e: # pragma: no cover
|
|
35
|
+
QFont = None
|
|
36
|
+
QFontDatabase = None
|
|
37
|
+
QFontMetrics = None
|
|
38
|
+
QRawFont = None
|
|
39
|
+
QT_FONT_AVAILABLE = False
|
|
40
|
+
QT_FONT_IMPORT_ERROR = e
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------
|
|
44
|
+
# Candidate tables
|
|
45
|
+
# ---------------------------------------------------------------------
|
|
46
|
+
# Ordered per script: fonts shipped with Windows first (guaranteed on the
|
|
47
|
+
# primary target platform), then Noto families (common on Linux/CI and
|
|
48
|
+
# easy to install anywhere), then legacy/system alternatives.
|
|
49
|
+
|
|
50
|
+
SCRIPT_FONT_CANDIDATES: Dict[str, List[str]] = {
|
|
51
|
+
"Bengali": [
|
|
52
|
+
"Nirmala UI", "Noto Sans Bengali", "Noto Serif Bengali",
|
|
53
|
+
"SolaimanLipi", "Kalpurush", "Nikosh", "Vrinda", "Siyam Rupali",
|
|
54
|
+
"Hind Siliguri", "Mukti", "Akaash", "Lohit Bengali",
|
|
55
|
+
],
|
|
56
|
+
"Devanagari": [
|
|
57
|
+
"Nirmala UI", "Noto Sans Devanagari", "Noto Serif Devanagari",
|
|
58
|
+
"Mangal", "Hind", "Lohit Devanagari", "Aparajita",
|
|
59
|
+
],
|
|
60
|
+
"Tamil": [
|
|
61
|
+
"Nirmala UI", "Noto Sans Tamil", "Latha", "Vijaya", "Lohit Tamil",
|
|
62
|
+
],
|
|
63
|
+
"Telugu": [
|
|
64
|
+
"Nirmala UI", "Noto Sans Telugu", "Gautami", "Vani", "Lohit Telugu",
|
|
65
|
+
],
|
|
66
|
+
"Kannada": [
|
|
67
|
+
"Nirmala UI", "Noto Sans Kannada", "Tunga", "Lohit Kannada",
|
|
68
|
+
],
|
|
69
|
+
"Malayalam": [
|
|
70
|
+
"Nirmala UI", "Noto Sans Malayalam", "Kartika", "Lohit Malayalam",
|
|
71
|
+
],
|
|
72
|
+
"Gujarati": [
|
|
73
|
+
"Nirmala UI", "Noto Sans Gujarati", "Shruti", "Lohit Gujarati",
|
|
74
|
+
],
|
|
75
|
+
"Gurmukhi": [
|
|
76
|
+
"Nirmala UI", "Noto Sans Gurmukhi", "Raavi", "Lohit Gurmukhi",
|
|
77
|
+
],
|
|
78
|
+
"Odia": [
|
|
79
|
+
"Nirmala UI", "Noto Sans Oriya", "Kalinga", "Lohit Odia",
|
|
80
|
+
],
|
|
81
|
+
"Sinhala": [
|
|
82
|
+
"Nirmala UI", "Noto Sans Sinhala", "Iskoola Pota",
|
|
83
|
+
],
|
|
84
|
+
"Arabic": [
|
|
85
|
+
"Segoe UI", "Noto Naskh Arabic", "Noto Sans Arabic",
|
|
86
|
+
"Tahoma", "Traditional Arabic", "Arial",
|
|
87
|
+
],
|
|
88
|
+
"Hebrew": [
|
|
89
|
+
"Segoe UI", "Noto Sans Hebrew", "David", "Tahoma", "Arial",
|
|
90
|
+
],
|
|
91
|
+
"Thai": [
|
|
92
|
+
"Leelawadee UI", "Noto Sans Thai", "Tahoma", "Angsana New",
|
|
93
|
+
],
|
|
94
|
+
"Lao": [
|
|
95
|
+
"Leelawadee UI", "Noto Sans Lao", "Lao UI", "DokChampa",
|
|
96
|
+
],
|
|
97
|
+
"Khmer": [
|
|
98
|
+
"Leelawadee UI", "Noto Sans Khmer", "Khmer UI", "DaunPenh",
|
|
99
|
+
],
|
|
100
|
+
"Myanmar": [
|
|
101
|
+
"Myanmar Text", "Noto Sans Myanmar", "Padauk",
|
|
102
|
+
],
|
|
103
|
+
"Tibetan": [
|
|
104
|
+
"Microsoft Himalaya", "Noto Serif Tibetan", "Noto Sans Tibetan",
|
|
105
|
+
],
|
|
106
|
+
"Ethiopic": [
|
|
107
|
+
"Ebrima", "Noto Sans Ethiopic", "Nyala",
|
|
108
|
+
],
|
|
109
|
+
"Georgian": [
|
|
110
|
+
"Sylfaen", "Noto Sans Georgian", "Segoe UI",
|
|
111
|
+
],
|
|
112
|
+
"Armenian": [
|
|
113
|
+
"Sylfaen", "Noto Sans Armenian", "Segoe UI",
|
|
114
|
+
],
|
|
115
|
+
"Han": [
|
|
116
|
+
"Microsoft YaHei", "Noto Sans CJK SC", "Noto Sans SC", "SimSun",
|
|
117
|
+
],
|
|
118
|
+
"Hiragana": [
|
|
119
|
+
"Yu Gothic", "Meiryo", "Noto Sans CJK JP", "Noto Sans JP", "MS Gothic",
|
|
120
|
+
],
|
|
121
|
+
"Katakana": [
|
|
122
|
+
"Yu Gothic", "Meiryo", "Noto Sans CJK JP", "Noto Sans JP", "MS Gothic",
|
|
123
|
+
],
|
|
124
|
+
"Hangul": [
|
|
125
|
+
"Malgun Gothic", "Noto Sans CJK KR", "Noto Sans KR", "Gulim",
|
|
126
|
+
],
|
|
127
|
+
"Greek": [
|
|
128
|
+
"Segoe UI", "Noto Sans", "Arial", "DejaVu Sans",
|
|
129
|
+
],
|
|
130
|
+
"Cyrillic": [
|
|
131
|
+
"Segoe UI", "Noto Sans", "Arial", "DejaVu Sans",
|
|
132
|
+
],
|
|
133
|
+
"Latin": [
|
|
134
|
+
"Segoe UI", "Noto Sans", "Arial", "DejaVu Sans",
|
|
135
|
+
],
|
|
136
|
+
COMMON: [
|
|
137
|
+
"Segoe UI", "Noto Sans", "Arial", "DejaVu Sans",
|
|
138
|
+
],
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------
|
|
143
|
+
# Module state
|
|
144
|
+
# ---------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
_DEFAULT_FONT_FAMILY = "Segoe UI"
|
|
147
|
+
_DEFAULT_FONT_PATH: Optional[str] = None
|
|
148
|
+
|
|
149
|
+
# User-pinned per-script overrides (script -> family)
|
|
150
|
+
_SCRIPT_FONT_OVERRIDES: Dict[str, str] = {}
|
|
151
|
+
|
|
152
|
+
_REGISTERED_FONT_FILES: List[str] = []
|
|
153
|
+
_REGISTERED_FONT_FAMILIES: Dict[str, List[str]] = {} # font_path -> families
|
|
154
|
+
|
|
155
|
+
# Cache: (script, text-key) -> resolved family
|
|
156
|
+
_RESOLVE_CACHE: Dict[Any, str] = {}
|
|
157
|
+
_RESOLVE_CACHE_MAX = 1024
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass
|
|
161
|
+
class FontValidationResult:
|
|
162
|
+
ok: bool
|
|
163
|
+
requested_family: Optional[str] = None
|
|
164
|
+
requested_path: Optional[str] = None
|
|
165
|
+
resolved_family: Optional[str] = None
|
|
166
|
+
path_registered: bool = False
|
|
167
|
+
family_exists: bool = False
|
|
168
|
+
covers_sample: bool = False
|
|
169
|
+
sample_text: Optional[str] = None
|
|
170
|
+
warnings: List[str] = field(default_factory=list)
|
|
171
|
+
error: Optional[str] = None
|
|
172
|
+
|
|
173
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
174
|
+
return asdict(self)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ---------------------------------------------------------------------
|
|
178
|
+
# Internal helpers
|
|
179
|
+
# ---------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
def _ensure_font_runtime() -> None:
|
|
182
|
+
ensure_qt_application()
|
|
183
|
+
if not QT_FONT_AVAILABLE:
|
|
184
|
+
raise RuntimeError(
|
|
185
|
+
"Qt font classes are not available. "
|
|
186
|
+
f"Original import error: {QT_FONT_IMPORT_ERROR}"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _normalize_path(path: str) -> str:
|
|
191
|
+
return os.path.abspath(os.path.expanduser(path))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _cache_put(key: Any, family: str) -> None:
|
|
195
|
+
if len(_RESOLVE_CACHE) >= _RESOLVE_CACHE_MAX:
|
|
196
|
+
_RESOLVE_CACHE.clear()
|
|
197
|
+
_RESOLVE_CACHE[key] = family
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ---------------------------------------------------------------------
|
|
201
|
+
# Font registration / listing
|
|
202
|
+
# ---------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
def register_font(font_path: str) -> List[str]:
|
|
205
|
+
"""
|
|
206
|
+
Register a font file (.ttf/.otf) with Qt and return its family names.
|
|
207
|
+
"""
|
|
208
|
+
_ensure_font_runtime()
|
|
209
|
+
path = _normalize_path(font_path)
|
|
210
|
+
|
|
211
|
+
if path in _REGISTERED_FONT_FAMILIES:
|
|
212
|
+
return list(_REGISTERED_FONT_FAMILIES[path])
|
|
213
|
+
|
|
214
|
+
if not os.path.isfile(path):
|
|
215
|
+
raise FileNotFoundError(f"Font file not found: {path}")
|
|
216
|
+
|
|
217
|
+
font_id = QFontDatabase.addApplicationFont(path)
|
|
218
|
+
if font_id < 0:
|
|
219
|
+
raise RuntimeError(f"Qt failed to load font file: {path}")
|
|
220
|
+
|
|
221
|
+
families = list(QFontDatabase.applicationFontFamilies(font_id))
|
|
222
|
+
_REGISTERED_FONT_FILES.append(path)
|
|
223
|
+
_REGISTERED_FONT_FAMILIES[path] = families
|
|
224
|
+
_RESOLVE_CACHE.clear()
|
|
225
|
+
return families
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def register_fonts(font_paths: Sequence[str]) -> Dict[str, List[str]]:
|
|
229
|
+
"""Register multiple font files; returns {path: families}."""
|
|
230
|
+
return {p: register_font(p) for p in font_paths}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def list_available_fonts() -> List[str]:
|
|
234
|
+
"""All font families Qt can see on this system."""
|
|
235
|
+
_ensure_font_runtime()
|
|
236
|
+
return [str(f) for f in QFontDatabase.families()]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def list_registered_fonts() -> Dict[str, List[str]]:
|
|
240
|
+
"""Font files registered through register_font()."""
|
|
241
|
+
return {p: list(f) for p, f in _REGISTERED_FONT_FAMILIES.items()}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def family_exists(family: str) -> bool:
|
|
245
|
+
"""True if a family with this (case-insensitive) name is installed."""
|
|
246
|
+
_ensure_font_runtime()
|
|
247
|
+
target = family.strip().lower()
|
|
248
|
+
return any(str(f).strip().lower() == target for f in QFontDatabase.families())
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# ---------------------------------------------------------------------
|
|
252
|
+
# Glyph coverage
|
|
253
|
+
# ---------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
def font_covers_text(family: str, text: str, min_ratio: float = 1.0) -> bool:
|
|
256
|
+
"""
|
|
257
|
+
True if ``family`` itself (font merging disabled) has glyphs for the
|
|
258
|
+
script characters in ``text``.
|
|
259
|
+
|
|
260
|
+
Common characters (spaces, digits, punctuation) are ignored so that
|
|
261
|
+
a Bengali font is not rejected for missing a rarely used symbol.
|
|
262
|
+
``min_ratio`` allows tolerance, e.g. 0.9 accepts fonts covering 90%
|
|
263
|
+
of the sampled characters.
|
|
264
|
+
"""
|
|
265
|
+
_ensure_font_runtime()
|
|
266
|
+
|
|
267
|
+
font = QFont(family)
|
|
268
|
+
font.setStyleStrategy(QFont.StyleStrategy.NoFontMerging)
|
|
269
|
+
raw = QRawFont.fromFont(font)
|
|
270
|
+
if not raw.isValid():
|
|
271
|
+
return False
|
|
272
|
+
|
|
273
|
+
checked = 0
|
|
274
|
+
covered = 0
|
|
275
|
+
seen = set()
|
|
276
|
+
for ch in str(text):
|
|
277
|
+
if ch in seen:
|
|
278
|
+
continue
|
|
279
|
+
seen.add(ch)
|
|
280
|
+
script = detect_char_script(ch)
|
|
281
|
+
if script in (COMMON, INHERITED):
|
|
282
|
+
continue
|
|
283
|
+
checked += 1
|
|
284
|
+
# Pass the integer codepoint: PySide6's QChar overload misresolves
|
|
285
|
+
# single-character strings and reports False for supported glyphs.
|
|
286
|
+
if raw.supportsCharacter(ord(ch)):
|
|
287
|
+
covered += 1
|
|
288
|
+
if checked >= 64: # sampling cap for very long strings
|
|
289
|
+
break
|
|
290
|
+
|
|
291
|
+
if checked == 0:
|
|
292
|
+
return True
|
|
293
|
+
return (covered / checked) >= min_ratio
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# ---------------------------------------------------------------------
|
|
297
|
+
# Script-level resolution
|
|
298
|
+
# ---------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
def set_script_font(script: str, family: str) -> None:
|
|
301
|
+
"""
|
|
302
|
+
Pin a font family for a script, taking priority over the candidate
|
|
303
|
+
table. Example: set_script_font("Bengali", "Kalpurush").
|
|
304
|
+
"""
|
|
305
|
+
_SCRIPT_FONT_OVERRIDES[script] = family
|
|
306
|
+
_RESOLVE_CACHE.clear()
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def get_script_font(script: str) -> Optional[str]:
|
|
310
|
+
"""Return the pinned family for a script, if any."""
|
|
311
|
+
return _SCRIPT_FONT_OVERRIDES.get(script)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def find_font_for_script(
|
|
315
|
+
script: str,
|
|
316
|
+
sample_text: Optional[str] = None,
|
|
317
|
+
preferred: Optional[str] = None,
|
|
318
|
+
) -> str:
|
|
319
|
+
"""
|
|
320
|
+
Return the best installed font family for ``script``.
|
|
321
|
+
|
|
322
|
+
Checks, in order: ``preferred``, the pinned override, each entry of
|
|
323
|
+
SCRIPT_FONT_CANDIDATES. A candidate must be installed and (when
|
|
324
|
+
``sample_text`` is given) cover its script characters. Falls back to
|
|
325
|
+
the package default family when nothing matches.
|
|
326
|
+
"""
|
|
327
|
+
_ensure_font_runtime()
|
|
328
|
+
|
|
329
|
+
key = ("script", script, sample_text, preferred)
|
|
330
|
+
cached = _RESOLVE_CACHE.get(key)
|
|
331
|
+
if cached is not None:
|
|
332
|
+
return cached
|
|
333
|
+
|
|
334
|
+
candidates: List[str] = []
|
|
335
|
+
if preferred:
|
|
336
|
+
candidates.append(preferred)
|
|
337
|
+
override = _SCRIPT_FONT_OVERRIDES.get(script)
|
|
338
|
+
if override:
|
|
339
|
+
candidates.append(override)
|
|
340
|
+
candidates.extend(SCRIPT_FONT_CANDIDATES.get(script, []))
|
|
341
|
+
candidates.append(_DEFAULT_FONT_FAMILY)
|
|
342
|
+
|
|
343
|
+
for family in candidates:
|
|
344
|
+
if not family_exists(family):
|
|
345
|
+
continue
|
|
346
|
+
if sample_text and not font_covers_text(family, sample_text):
|
|
347
|
+
continue
|
|
348
|
+
_cache_put(key, family)
|
|
349
|
+
return family
|
|
350
|
+
|
|
351
|
+
# Nothing installed covers it; return the default and let Qt's own
|
|
352
|
+
# font merging find *some* glyph source at draw time.
|
|
353
|
+
_cache_put(key, _DEFAULT_FONT_FAMILY)
|
|
354
|
+
return _DEFAULT_FONT_FAMILY
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def auto_font_fallback(text: str, preferred: Optional[str] = None) -> str:
|
|
358
|
+
"""
|
|
359
|
+
Pick a font family for ``text`` automatically.
|
|
360
|
+
|
|
361
|
+
The dominant script is detected, then resolved through
|
|
362
|
+
find_font_for_script() with ``text`` itself as the coverage sample.
|
|
363
|
+
"""
|
|
364
|
+
script = detect_script(text)
|
|
365
|
+
return find_font_for_script(script, sample_text=text, preferred=preferred)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def resolve_fonts_for_runs(text: str) -> List[Dict[str, str]]:
|
|
369
|
+
"""
|
|
370
|
+
For mixed-script text, return one entry per script run with the font
|
|
371
|
+
that would be used: [{"text", "script", "family"}, ...].
|
|
372
|
+
|
|
373
|
+
Rendering itself draws the whole string with the dominant-script
|
|
374
|
+
font and lets Qt shape each run (with font merging covering the
|
|
375
|
+
rest); this function exists for diagnostics and evaluation tables.
|
|
376
|
+
"""
|
|
377
|
+
out = []
|
|
378
|
+
for run in segment_runs(text):
|
|
379
|
+
out.append({
|
|
380
|
+
"text": run.text,
|
|
381
|
+
"script": run.script,
|
|
382
|
+
"family": find_font_for_script(run.script, sample_text=run.text),
|
|
383
|
+
})
|
|
384
|
+
return out
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# ---------------------------------------------------------------------
|
|
388
|
+
# Default font / general resolution
|
|
389
|
+
# ---------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
def set_default_font(font_family: Optional[str] = None, font_path: Optional[str] = None) -> str:
|
|
392
|
+
"""
|
|
393
|
+
Set the package-wide default family (used for Latin/Common text and
|
|
394
|
+
as the last-resort fallback). A ``font_path`` is registered first.
|
|
395
|
+
"""
|
|
396
|
+
global _DEFAULT_FONT_FAMILY, _DEFAULT_FONT_PATH
|
|
397
|
+
|
|
398
|
+
if font_path:
|
|
399
|
+
families = register_font(font_path)
|
|
400
|
+
_DEFAULT_FONT_PATH = _normalize_path(font_path)
|
|
401
|
+
if not font_family and families:
|
|
402
|
+
font_family = families[0]
|
|
403
|
+
|
|
404
|
+
if font_family:
|
|
405
|
+
_DEFAULT_FONT_FAMILY = str(font_family)
|
|
406
|
+
|
|
407
|
+
_RESOLVE_CACHE.clear()
|
|
408
|
+
return _DEFAULT_FONT_FAMILY
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def get_default_font() -> str:
|
|
412
|
+
return _DEFAULT_FONT_FAMILY
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def resolve_font(
|
|
416
|
+
font_family: Optional[str] = None,
|
|
417
|
+
font_path: Optional[str] = None,
|
|
418
|
+
text: Optional[str] = None,
|
|
419
|
+
) -> str:
|
|
420
|
+
"""
|
|
421
|
+
Resolve the family to render with.
|
|
422
|
+
|
|
423
|
+
- ``font_path`` wins: it is registered and its first family used.
|
|
424
|
+
- ``font_family`` is honored when installed (no silent override).
|
|
425
|
+
- Otherwise, when ``text`` is given, the family is chosen per the
|
|
426
|
+
text's dominant script via auto_font_fallback().
|
|
427
|
+
- Otherwise the package default family is returned.
|
|
428
|
+
"""
|
|
429
|
+
_ensure_font_runtime()
|
|
430
|
+
|
|
431
|
+
if font_path:
|
|
432
|
+
families = register_font(font_path)
|
|
433
|
+
if families:
|
|
434
|
+
return families[0]
|
|
435
|
+
|
|
436
|
+
if font_family:
|
|
437
|
+
if family_exists(font_family):
|
|
438
|
+
return str(font_family)
|
|
439
|
+
# Requested family missing: fall through to script-aware choice.
|
|
440
|
+
|
|
441
|
+
if text is not None:
|
|
442
|
+
return auto_font_fallback(str(text), preferred=font_family)
|
|
443
|
+
|
|
444
|
+
return _DEFAULT_FONT_FAMILY
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def validate_font(
|
|
448
|
+
font_family: Optional[str] = None,
|
|
449
|
+
font_path: Optional[str] = None,
|
|
450
|
+
sample_text: str = "Aআক্ষর",
|
|
451
|
+
) -> FontValidationResult:
|
|
452
|
+
"""
|
|
453
|
+
Check that a font can actually draw ``sample_text``.
|
|
454
|
+
"""
|
|
455
|
+
result = FontValidationResult(
|
|
456
|
+
ok=False,
|
|
457
|
+
requested_family=font_family,
|
|
458
|
+
requested_path=font_path,
|
|
459
|
+
sample_text=sample_text,
|
|
460
|
+
)
|
|
461
|
+
try:
|
|
462
|
+
_ensure_font_runtime()
|
|
463
|
+
|
|
464
|
+
if font_path:
|
|
465
|
+
families = register_font(font_path)
|
|
466
|
+
result.path_registered = True
|
|
467
|
+
if families:
|
|
468
|
+
font_family = font_family or families[0]
|
|
469
|
+
|
|
470
|
+
if not font_family:
|
|
471
|
+
result.error = "No font family to validate."
|
|
472
|
+
return result
|
|
473
|
+
|
|
474
|
+
result.family_exists = family_exists(font_family)
|
|
475
|
+
if not result.family_exists:
|
|
476
|
+
result.warnings.append(f"Family not installed: {font_family}")
|
|
477
|
+
return result
|
|
478
|
+
|
|
479
|
+
result.resolved_family = font_family
|
|
480
|
+
result.covers_sample = font_covers_text(font_family, sample_text)
|
|
481
|
+
if not result.covers_sample:
|
|
482
|
+
result.warnings.append(
|
|
483
|
+
f"'{font_family}' lacks glyphs for sample: {sample_text!r}"
|
|
484
|
+
)
|
|
485
|
+
result.ok = result.covers_sample
|
|
486
|
+
return result
|
|
487
|
+
except Exception as e:
|
|
488
|
+
result.error = str(e)
|
|
489
|
+
return result
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def font_info(family: str) -> Dict[str, Any]:
|
|
493
|
+
"""Basic information about an installed family."""
|
|
494
|
+
_ensure_font_runtime()
|
|
495
|
+
exists = family_exists(family)
|
|
496
|
+
info: Dict[str, Any] = {"family": family, "exists": exists}
|
|
497
|
+
if exists:
|
|
498
|
+
try:
|
|
499
|
+
info["styles"] = [str(s) for s in QFontDatabase.styles(family)]
|
|
500
|
+
info["scalable"] = bool(QFontDatabase.isScalable(family))
|
|
501
|
+
except Exception:
|
|
502
|
+
pass
|
|
503
|
+
return info
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def coverage_report(text: str) -> Dict[str, Any]:
|
|
507
|
+
"""
|
|
508
|
+
Per-run font resolution for ``text`` plus the whole-string choice.
|
|
509
|
+
Handy for the comparative-evaluation section of the paper.
|
|
510
|
+
"""
|
|
511
|
+
return {
|
|
512
|
+
"text": str(text),
|
|
513
|
+
"dominant_script": detect_script(text),
|
|
514
|
+
"whole_string_family": auto_font_fallback(text),
|
|
515
|
+
"runs": resolve_fonts_for_runs(text),
|
|
516
|
+
}
|