-
-
Notifications
You must be signed in to change notification settings - Fork 29
Expand file tree
/
Copy pathfont_manager.py
More file actions
699 lines (590 loc) · 29.5 KB
/
Copy pathfont_manager.py
File metadata and controls
699 lines (590 loc) · 29.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
"""
Font Manager — TTF/BDF font loading, caching, and dynamic registration.
:class:`FontManager` serves two purposes:
1. **System fonts** — loads the configured small/medium/large TTF fonts (and
their BDF bitmap equivalents) at startup, caches metrics, and exposes them
via ``DisplayManager`` attributes (``small_font``, ``medium_font``, etc.).
2. **Plugin fonts** — lets plugins register their own fonts at runtime via
:meth:`FontManager.register_manager_font` and resolve them later via
:meth:`FontManager.resolve_font`. Registered fonts are namespaced by
plugin ID so they cannot collide.
Font sources
------------
* Local paths relative to the project root.
* Remote URLs — downloaded once, cached to disk, and never re-fetched while
the cached copy is fresh.
BDF fallback
------------
Pixel-accurate LED fonts are stored as ``.bdf`` (Bitmap Distribution Format)
files. When PIL cannot measure BDF glyphs natively, ``freetype-py`` is used
for accurate width/height calculations.
"""
import os
import logging
import freetype
import requests
import json
import hashlib
import urllib.parse
import zipfile
import tempfile
import time
from collections import OrderedDict
from pathlib import Path
from PIL import ImageFont
from src.common.bdf_font import load_bdf_face, read_bdf_native_size
from src.common.font_layout import load_truetype, resolve_asset_path
from typing import Dict, Tuple, Optional, Union, Any
logger = logging.getLogger(__name__)
# Seconds before a stalled font download gives up (connect and per-read).
_FONT_DOWNLOAD_TIMEOUT = 30
class FontManager:
"""
Comprehensive font management supporting TTF and BDF fonts with caching,
measurement, plugin support, and manager font registration.
This FontManager serves dual purposes:
1. Utility functions for font loading, caching, and measurement
2. Dynamic detection and override of fonts used by managers/plugins
"""
def __init__(self, config: Dict[str, Any]):
self.config = config
# Font discovery and catalog
self.font_catalog: Dict[str, str] = {} # family_name -> file_path
self.font_cache: Dict[str, Union[ImageFont.FreeTypeFont, freetype.Face]] = {} # (family, size) -> font
# (text, id(font)) -> ((width, height, baseline), font_ref).
# LRU-bounded — keys embed the measured TEXT, so changing strings
# (clocks, live scores) would otherwise grow it forever. Entries
# keep the font alive so its id() can't be recycled by a different
# font object (which would silently return wrong metrics).
self.metrics_cache: "OrderedDict[Any, Tuple[Tuple[int, int, int], Any]]" = OrderedDict()
self._METRICS_CACHE_MAX = 1024
# Plugin font management
self.plugin_fonts: Dict[str, Dict[str, Any]] = {} # plugin_id -> font_manifest
self.plugin_font_catalogs: Dict[str, Dict[str, str]] = {} # plugin_id -> {family_name -> file_path}
# Fonts managers and plugins report using (register_manager_font).
self.manager_fonts: Dict[str, Dict[str, Any]] = {} # manager_id -> {element_key: {family, size_px, color}}
self.detected_fonts: Dict[str, Dict[str, Any]] = {} # element_key -> {family, size_px, color, manager_id, usage_count}
# Bumped when a manager's registered families change (not when one
# re-registers what it already had), so src/font_usage.py can tell
# "nothing new" without rebuilding its snapshot.
self.manager_fonts_version = 0
# Dynamic font loading
self.temp_font_dir = Path(tempfile.gettempdir()) / "ledmatrix_fonts"
self.temp_font_dir.mkdir(exist_ok=True)
# Font-load counters, kept up by get_font().
self.performance_stats = {
"cache_hits": 0,
"cache_misses": 0,
"failed_loads": 0,
"start_time": time.time()
}
# Common font paths for convenience
self.common_fonts = {
"press_start": "assets/fonts/PressStart2P-Regular.ttf",
"four_by_six": "assets/fonts/4x6-font.ttf",
"five_by_seven": "assets/fonts/5x7.bdf",
"tom_thumb": "assets/fonts/tom-thumb.bdf"
}
# Per-element overrides read from config/font_overrides.json;
# resolve_font applies them.
# Under the install root's config/ (which always exists), not the
# cwd: the file itself may not exist yet, and resolve_asset_path
# hands back a missing path unchanged.
self.font_overrides_file = os.path.join(resolve_asset_path("config"), "font_overrides.json")
self.font_overrides: Dict[str, Dict[str, Any]] = {}
# Bumped whenever cached font objects are invalidated, so holders of
# derived caches (e.g. adaptive-layout fit results) know to rebuild.
self.cache_generation = 0
self._initialize_fonts()
def reload_config(self, new_config: Dict[str, Any]):
"""Reload configuration and refresh font catalog."""
self.config = new_config
self.font_cache.clear() # Clear cache to force reload
self.metrics_cache.clear() # Clear metrics cache
self.cache_generation += 1
self._initialize_fonts()
logger.info("FontManager configuration reloaded successfully")
# ==================== Manager Font Registration ====================
def register_manager_font(self, manager_id: str, element_key: str,
family: str, size_px: int, color: Optional[Tuple[int, int, int]] = None):
"""
Register a font choice made by a manager for a specific element.
This allows us to detect and track which fonts managers are using.
Args:
manager_id: Identifier for the manager (e.g., 'nfl_live', 'nba_recent')
element_key: Element key (e.g., 'nfl.live.score')
family: Font family name
size_px: Font size in pixels
color: Optional RGB color tuple
"""
if manager_id not in self.manager_fonts:
self.manager_fonts[manager_id] = {}
font_spec = {
"family": family,
"size_px": size_px,
"manager_id": manager_id
}
if color:
font_spec["color"] = color
previous = self.manager_fonts[manager_id].get(element_key)
if previous is None or previous.get("family") != family:
self.manager_fonts_version += 1
self.manager_fonts[manager_id][element_key] = font_spec
# Track usage in detected_fonts
if element_key not in self.detected_fonts:
self.detected_fonts[element_key] = font_spec.copy()
self.detected_fonts[element_key]["usage_count"] = 1
else:
self.detected_fonts[element_key]["usage_count"] += 1
logger.debug(f"Registered font for {manager_id}.{element_key}: {family}@{size_px}px")
def forget_manager_fonts(self, manager_id: str) -> None:
"""Drop every registration ``manager_id`` made. Called by core when a
plugin is unloaded, so a reloaded plugin starts from what its new
instance registers and the web UI's Fonts tab stops listing it."""
removed = self.manager_fonts.pop(manager_id, None)
for element_key, spec in list(self.detected_fonts.items()):
if spec.get("manager_id") == manager_id:
self.detected_fonts.pop(element_key, None)
if removed:
self.manager_fonts_version += 1
# ==================== Plugin Font Management ====================
def register_plugin_fonts(self, plugin_id: str, font_manifest: Dict[str, Any],
plugin_dir: Optional[Union[str, Path]] = None) -> bool:
"""
Register fonts for a specific plugin.
Args:
plugin_id: Unique identifier for the plugin
font_manifest: The ``fonts`` block of the plugin's manifest.json
plugin_dir: The plugin's directory, which ``plugin://`` sources
are relative to. PluginManager passes the directory it loaded
the plugin from. When omitted, the plugin is looked up in the
configured ``plugin_system.plugins_directory`` and then in
``plugins/``.
Returns:
True if the manifest was valid (individual fonts that fail to load
are logged and skipped), False otherwise
"""
try:
# Validate font manifest structure
if not self._validate_font_manifest(font_manifest):
logger.error(f"Invalid font manifest for plugin {plugin_id}")
return False
# Store plugin font manifest
self.plugin_fonts[plugin_id] = font_manifest
# Create plugin-specific font catalog
self.plugin_font_catalogs[plugin_id] = {}
# Process font definitions
fonts = font_manifest.get("fonts", [])
for font_def in fonts:
if self._register_plugin_font(plugin_id, font_def, plugin_dir):
logger.info(f"Successfully registered font {font_def.get('family')} for plugin {plugin_id}")
logger.info(f"Registered {len(fonts)} fonts for plugin {plugin_id}")
return True
except Exception as e:
logger.error(f"Error registering fonts for plugin {plugin_id}: {e}", exc_info=True)
return False
def forget_plugin_fonts(self, plugin_id: str) -> bool:
"""Drop the fonts ``plugin_id``'s manifest registered: its manifest
and catalog, its ``plugin_id::family`` entries in font_catalog, and
cached font objects for those families. Called by core when a plugin
is unloaded, so a reload registers from its current manifest and a
removed plugin's fonts stop resolving.
FontManager takes no locks; like forget_manager_fonts this relies on
single dict operations being atomic and iterates snapshots, so a
render thread calling get_font() meanwhile cannot break it. Returns
True if the plugin had registered fonts.
"""
prefix = f"{plugin_id}::"
manifest = self.plugin_fonts.pop(plugin_id, None)
catalog = self.plugin_font_catalogs.pop(plugin_id, None)
# Every namespaced entry, not just the families in the catalog: one
# whose file failed to load never made it into the catalog, and a
# caller may have added one directly.
for family in list(self.font_catalog):
if family.startswith(prefix):
self.font_catalog.pop(family, None)
# get_font() keys the cache f"{family}_{size_px}".
dropped = [key for key in list(self.font_cache) if key.startswith(prefix)]
for key in dropped:
self.font_cache.pop(key, None)
if dropped:
# Font objects someone may hold were dropped; see cache_generation.
self.cache_generation += 1
if manifest is None and catalog is None:
return False
logger.info("Forgot fonts of plugin %s", plugin_id)
return True
def _validate_font_manifest(self, font_manifest: Dict[str, Any]) -> bool:
"""Validate the structure of a plugin's font manifest."""
required_fields = ["fonts"]
# Check required top-level fields
for field in required_fields:
if field not in font_manifest:
logger.error(f"Missing required field '{field}' in font manifest")
return False
# Validate each font definition
fonts = font_manifest.get("fonts", [])
for font_def in fonts:
if not isinstance(font_def, dict):
logger.error("Font definition must be a dictionary")
return False
required_font_fields = ["family", "source"]
for field in required_font_fields:
if field not in font_def:
logger.error(f"Missing required field '{field}' in font definition")
return False
return True
def _register_plugin_font(self, plugin_id: str, font_def: Dict[str, Any],
plugin_dir: Optional[Union[str, Path]] = None) -> bool:
"""Register a single font from a plugin."""
try:
family = font_def["family"]
source = font_def["source"]
# Handle different source types
font_path = None
if source.startswith(("http://", "https://")):
# Download from URL
font_path = self._download_font(source, font_def)
elif source.startswith("plugin://"):
# Relative to plugin directory
relative_path = source.replace("plugin://", "")
font_path = self._resolve_plugin_font_path(plugin_id, relative_path, plugin_dir)
else:
# Absolute or relative path
font_path = source
if not font_path or not os.path.exists(font_path):
logger.error(f"Font file not found: {font_path}")
return False
# Add to plugin catalog with namespaced family name
namespaced_family = f"{plugin_id}::{family}"
self.plugin_font_catalogs[plugin_id][family] = font_path
self.font_catalog[namespaced_family] = font_path
logger.info(f"Registered plugin font: {namespaced_family} -> {font_path}")
return True
except Exception as e:
logger.error(f"Error registering plugin font: {e}", exc_info=True)
return False
def _download_font(self, url: str, font_def: Dict[str, Any]) -> Optional[str]:
"""Download a font from a URL."""
try:
family = font_def["family"]
# Generate cache filename based on URL hash
url_hash = hashlib.sha256(url.encode()).hexdigest()[:16]
extension = self._get_font_extension(url)
cache_filename = f"{family}_{url_hash}{extension}"
cache_path = self.temp_font_dir / cache_filename
is_zip = url.endswith('.zip')
extract_dir = self.temp_font_dir / f"{family}_{url_hash}"
# Check if already downloaded. For a zip the font is the file
# extracted from it, so look there first -- returning the cached
# .zip itself would register the archive as the font after a
# restart.
if is_zip:
extracted = self._find_extracted_font(extract_dir)
if extracted:
logger.info(f"Using cached font: {extracted}")
return extracted
elif cache_path.exists():
logger.info(f"Using cached font: {cache_path}")
return str(cache_path)
if not cache_path.exists():
# Download font — restrict to http/https to prevent file:// reads
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in ('http', 'https'):
raise ValueError(f"Font URL must use http or https, got: {parsed.scheme!r}")
logger.info(f"Downloading font from {url}")
# Download to a temp file and rename into place, with a
# timeout: writing straight to cache_path left a truncated
# file after a stalled/interrupted download, and the exists()
# check above then served it forever.
fd, tmp_name = tempfile.mkstemp(dir=self.temp_font_dir, suffix='.part')
try:
with os.fdopen(fd, 'wb') as tmp_file:
response = requests.get(url, timeout=_FONT_DOWNLOAD_TIMEOUT, stream=True)
response.raise_for_status()
for chunk in response.iter_content(chunk_size=65536):
if chunk:
tmp_file.write(chunk)
os.replace(tmp_name, cache_path)
except BaseException:
try:
os.unlink(tmp_name)
except OSError:
pass
raise
# Handle zip files
if is_zip:
extract_dir.mkdir(exist_ok=True)
with zipfile.ZipFile(cache_path, 'r') as zip_ref:
zip_ref.extractall(extract_dir)
# Find the actual font file
extracted = self._find_extracted_font(extract_dir)
if extracted:
return extracted
return str(cache_path)
except Exception as e:
logger.error(f"Error downloading font from {url}: {e}")
return None
@staticmethod
def _find_extracted_font(extract_dir: Path) -> Optional[str]:
"""Return the first font file in a zip's extract dir, if any."""
if not extract_dir.is_dir():
return None
for file in extract_dir.iterdir():
if file.suffix.lower() in ['.ttf', '.otf', '.bdf']:
return str(file)
return None
def _get_font_extension(self, url: str) -> str:
"""Extract font file extension from URL."""
if '.ttf' in url.lower():
return '.ttf'
elif '.otf' in url.lower():
return '.otf'
elif '.bdf' in url.lower():
return '.bdf'
elif '.zip' in url.lower():
return '.zip'
return '.ttf' # default
def _resolve_plugin_font_path(self, plugin_id: str, relative_path: str,
plugin_dir: Optional[Union[str, Path]] = None) -> Optional[str]:
"""Resolve a ``plugin://`` font path against the plugin's directory."""
if plugin_dir is None:
plugin_dir = self._find_plugin_dir(plugin_id)
if plugin_dir is None:
logger.error(f"Plugin font {relative_path}: directory for plugin {plugin_id} not found")
return None
font_path = Path(plugin_dir) / relative_path
if font_path.exists():
return str(font_path)
logger.error(f"Plugin font not found: {font_path}")
return None
def _find_plugin_dir(self, plugin_id: str) -> Optional[Path]:
"""The installed directory of ``plugin_id``, for callers of
register_plugin_fonts that do not pass one: the configured plugins
directory (relative paths are relative to the install root), then the
legacy ``plugins/`` directory."""
# Imported here: src.plugin_system's package import loads PluginManager.
from src.plugin_system.plugin_dirs import resolve_plugin_dir
configured = (self.config.get("plugin_system") or {}).get("plugins_directory") or "plugin-repos"
search_dirs = [Path(resolve_asset_path(configured)), Path(resolve_asset_path("plugins"))]
return resolve_plugin_dir(plugin_id, search_dirs, prefix=True)
# ==================== Font Resolution ====================
def resolve_font(self, element_key: str, family: str, size_px: int,
plugin_id: Optional[str] = None) -> Union[ImageFont.FreeTypeFont, freetype.Face]:
"""
Resolve font for an element, checking for overrides.
This is the main method managers should call to get fonts.
It checks for manual overrides first, then uses the manager's choice.
Args:
element_key: Element key (e.g., 'nfl.live.score')
family: Font family name (manager's choice)
size_px: Font size in pixels (manager's choice)
plugin_id: Optional plugin context for namespaced fonts
Returns:
Resolved font object
"""
try:
# Check for manual overrides first
if element_key in self.font_overrides:
override = self.font_overrides[element_key]
if override.get("family"):
family = override["family"]
if override.get("size_px"):
size_px = override["size_px"]
logger.debug(f"Applied override for {element_key}: {family}@{size_px}px")
# Handle namespaced plugin fonts
if plugin_id and "::" not in family:
# Check if plugin has this font
if plugin_id in self.plugin_font_catalogs and family in self.plugin_font_catalogs[plugin_id]:
family = f"{plugin_id}::{family}"
return self.get_font(family, size_px)
except Exception as e:
logger.error(f"Error resolving font for {element_key}: {e}", exc_info=True)
return self._get_fallback_font()
def get_font(self, family: str, size_px: int) -> Union[ImageFont.FreeTypeFont, freetype.Face]:
"""
Get a font object for the specified family and size.
Args:
family: Font family name (can include plugin namespace like "plugin_id::family")
size_px: Font size in pixels
Returns:
Font object (PIL Font for TTF, freetype.Face for BDF)
"""
# Check cache first
cache_key = f"{family}_{size_px}"
if cache_key in self.font_cache:
self.performance_stats["cache_hits"] += 1
return self.font_cache[cache_key]
self.performance_stats["cache_misses"] += 1
# Load font
shareable = True
font_path = self.font_catalog.get(family)
if not font_path:
logger.warning(f"Font family '{family}' not found")
self.performance_stats["failed_loads"] += 1
font = ImageFont.load_default()
else:
try:
if font_path.endswith('.bdf'):
font = self._load_bdf_font(font_path, size_px)
shareable = False
else:
font = load_truetype(font_path, size_px)
except Exception as e:
# The one log line for a failed load: _load_bdf_font lets
# its error propagate to here.
logger.error(f"Error loading font {font_path}: {e}")
self.performance_stats["failed_loads"] += 1
font = ImageFont.load_default()
# A BDF face is not cached here: font_cache is shared by every
# thread, and a freetype.Face must never be (see load_bdf_face, which
# already caches BDF faces per thread).
if shareable:
self.font_cache[cache_key] = font
return font
def _load_bdf_font(self, font_path: str, size_px: int) -> freetype.Face:
"""Load a BDF font through the shared loader.
A size the file has no strike for comes back at the native strike
rather than failing over to PIL's default font, a different typeface
(see :func:`src.common.bdf_font.load_bdf_face`).
"""
return load_bdf_face(font_path, size_px)[0]
def get_native_bdf_size(self, family: str) -> Optional[int]:
"""The one true pixel size of a BDF family in the catalog, or None
for scalable (TTF) families / unknown families."""
font_path = self.font_catalog.get(family)
if not font_path or not font_path.endswith('.bdf'):
return None
return self._read_bdf_native_size(font_path)
@staticmethod
def _read_bdf_native_size(bdf_path: str) -> Optional[int]:
"""A BDF file's one true pixel size; see
:func:`src.common.bdf_font.read_bdf_native_size`."""
return read_bdf_native_size(bdf_path)
def _get_fallback_font(self) -> ImageFont.ImageFont:
"""Get a fallback font when loading fails."""
return ImageFont.load_default()
# ==================== Font Measurement ====================
def measure_text(self, text: str, font: Union[ImageFont.FreeTypeFont, freetype.Face]) -> Tuple[int, int, int]:
"""
Measure text dimensions and baseline.
Args:
text: Text to measure
font: Font to use for measurement
Returns:
Tuple of (width, height, baseline_offset)
"""
# Key on the text itself (hash(text) could collide) + font identity;
# the entry below keeps the font referenced so the id stays valid.
cache_key = (text, id(font))
cached = self.metrics_cache.get(cache_key)
if cached is not None:
self.metrics_cache.move_to_end(cache_key)
return cached[0]
try:
if isinstance(font, freetype.Face):
# BDF font measurement using FreeType
width = 0
height = 0
baseline = 0
max_ascender = 0
for char in text:
font.load_char(char)
width += font.glyph.advance.x >> 6 # Convert from 26.6 fixed point
glyph_height = font.glyph.bitmap.rows
height = max(height, glyph_height)
# Get ascender for baseline calculation
ascender = font.size.ascender >> 6
max_ascender = max(max_ascender, ascender)
baseline = max_ascender
else:
# TTF font measurement with PIL
bbox = font.getbbox(text)
width = bbox[2] - bbox[0]
height = bbox[3] - bbox[1]
baseline = -bbox[1] # Distance from top to baseline
except Exception as e:
logger.error(f"Error measuring text '{text}': {e}", exc_info=True)
# Fallback measurements
width = len(text) * 8 # Rough estimate
height = 12
baseline = 10
result = (width, height, baseline)
self.metrics_cache[cache_key] = (result, font)
while len(self.metrics_cache) > self._METRICS_CACHE_MAX:
self.metrics_cache.popitem(last=False)
return result
def get_font_height(self, font: Union[ImageFont.FreeTypeFont, freetype.Face]) -> int:
"""Get the height of a font."""
try:
if isinstance(font, freetype.Face):
return font.size.height >> 6
else:
# Use a common character to measure height
bbox = font.getbbox("Ay")
return bbox[3] - bbox[1]
except Exception as e:
logger.error(f"Error getting font height: {e}", exc_info=True)
return 12 # Default height
# ==================== Font Discovery ====================
@staticmethod
def _resolve_asset_path(relative_path: str) -> str:
"""Resolve a repo-relative asset path independently of the process cwd.
Thin delegate to :func:`src.common.font_layout.resolve_asset_path`,
which holds the one definition (``DisplayManager._load_fonts`` needs
the same resolution and must not import this class for it). The method
stays because plugins probe for it by name to share the core's notion
of "install root" -- see the `_resolve_font_path` helpers in the
scoreboard plugins.
"""
return resolve_asset_path(relative_path)
def _initialize_fonts(self):
"""Initialize font catalog and validate configuration."""
self._scan_fonts_directory()
self._register_common_fonts()
self._load_overrides()
def _scan_fonts_directory(self):
"""Scan assets/fonts directory for available fonts."""
fonts_dir = self._resolve_asset_path("assets/fonts")
if not os.path.exists(fonts_dir):
logger.warning(f"Fonts directory not found: {fonts_dir}")
return
for filename in os.listdir(fonts_dir):
if filename.endswith(('.ttf', '.bdf')):
filepath = os.path.join(fonts_dir, filename)
# Generate family name from filename (without extension)
family_name = filename.rsplit('.', 1)[0].lower()
self.font_catalog[family_name] = filepath
logger.debug(f"Found font: {family_name} -> {filepath}")
def _register_common_fonts(self):
"""Register common font aliases from common_fonts dictionary."""
for family_name, font_path in self.common_fonts.items():
font_path = self._resolve_asset_path(font_path)
# Check if font file exists
if os.path.exists(font_path):
# Register the common font name (overrides auto-generated name if exists)
self.font_catalog[family_name] = font_path
logger.debug(f"Registered common font: {family_name} -> {font_path}")
else:
logger.warning(f"Common font file not found: {font_path} (family: {family_name})")
def _load_overrides(self):
"""Load font overrides from configuration."""
try:
if os.path.exists(self.font_overrides_file):
with open(self.font_overrides_file, 'r') as f:
self.font_overrides = json.load(f)
logger.info(f"Loaded {len(self.font_overrides)} font overrides")
else:
self.font_overrides = {}
except Exception as e:
logger.warning(f"Could not load font overrides: {e}")
self.font_overrides = {}
# ==================== Utility Methods ====================
def clear_cache(self):
"""Clear font and metrics cache."""
self.font_cache.clear()
self.metrics_cache.clear()
# Holders of derived caches (layout fits, font usage) key off this;
# without the bump they kept serving results for the dropped fonts.
self.cache_generation += 1
logger.info("Font cache cleared")