""" 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 _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")