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.
@@ -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
+ }