mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
* fix(errors): record the exception's own stack trace record_error() called traceback.format_exc(), which only sees an exception while its except block is running. plugin_executor records exceptions caught on a worker thread after that block has ended, so every trace on /errors read "NoneType: None". The trace is now built from the exception's __traceback__. The executor's log call had the same problem with exc_info=True and now passes the exception. record_error() also merged LEDMatrixError context into the caller's dict in place; it now works on a copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(wifi): point at configure_wifi_permissions.sh instead of a sudoers list The module docstring told users to grant NOPASSWD sudo on iptables and ip. configure_wifi_permissions.sh refuses those grants on purpose: a wildcard rule for either runs an arbitrary program as root. Point at the script and say why it leaves them out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(wifi): disconnect finds the saved profile by SSID disconnect_from_network() asked `nmcli -f NAME,802-11-wireless.ssid connection show` for the profile to take down, but nmcli rejects that column for `connection show`, so the lookup always failed and only the device was disconnected. The per-profile lookup _connect_nmcli() already used is now _find_profile_for_ssid(), and both callers share it. It also splits terse output on the last colon and unescapes "\:", so a profile name containing a colon is found. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(wifi): write wifi_config.json atomically and report a failed save _save_config() opened the file for writing in place and swallowed any error, so a wifi_config.json left owned by root made the web toggle for auto-enabling AP mode report success while nothing was saved, and a crash mid-write could truncate the file. It now uses atomic_write_json, which also keeps the file's owner and shared group when root saves it, and returns False on failure. POST /wifi/ap/auto-enable answers 500 in that case. The file is now written with indent=4, like the other config files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(fonts): resolve plugin:// fonts in the plugin's own directory FontManager looked for a plugin's bundled fonts under Path("plugins") / plugin_id: relative to the process cwd, and not the default install directory (plugin-repos/), so a manifest's plugin:// fonts never loaded. register_plugin_fonts() takes an optional plugin_dir, and PluginManager passes the directory it loaded the plugin from. Callers that omit it get a lookup in the configured plugin_system.plugins_directory, then plugins/, resolved against the install root. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(api-helper): cache responses for the requested cache_ttl APIHelper.get(cache_ttl=...) and set_cache(ttl=...) dropped the ttl on the claim that CacheManager does not support one, but CacheManager.set() takes a ttl, stores it with the entry, and both cache tiers honour it over a reader's max_age. Without it every response expired after the 300-second default read age, whatever the plugin asked for. The ttl is now passed through, and the cache read passes cache_ttl as max_age for entries written without one. The class docstring describes what the helper actually does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(style): one scale range for the schema, element_scale and LogoHelper The generated Scale field allowed 0.1 to 10, element_style's reader capped at 10 with no floor, and LogoHelper accepted 0.05 to 8 and reset anything else to 1.0. A logo scale of 9, which the form accepts, drew at the shipped size. MIN_ELEMENT_SCALE / MAX_ELEMENT_SCALE (0.1, 10.0) in src.element_style are now the schema bounds and the clamp every reader applies through coerce_scale(): a positive number outside the range is clamped, and anything that is not a finite positive number means the default. That also stops element_scale() passing NaN through, since min(nan, 10.0) is nan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(logos): placeholder lands at the requested path; empty logos list download_missing_logo() wrote its fallback placeholder to <normalize_abbreviation(abbr)>.png in the logo directory rather than to the logo_path the caller passed, so it could return True while nothing existed where the plugin looks (e.g. "TA&M.png" vs "TAANDM.png"). create_placeholder_logo() takes an optional filepath, and download_missing_logo passes the requested one. download_missing_logo_for_team() only caught KeyError, so a team whose "logos" list is empty raised IndexError; it now treats KeyError, IndexError and TypeError as "no logo URL". The placeholder is drawn with PLACEHOLDER_SIZE / PLACEHOLDER_BG, the constants is_placeholder_logo() recognises it by, instead of repeated literals. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(fonts): resolve bundled font paths against the install root TextHelper's default font_dir, the logo placeholder's font and FontManager's font_overrides.json were all relative to the process cwd, so a process started anywhere but the install root (the plugin safety harness, a manual run, a unit without WorkingDirectory) drew with PIL's default face and read no overrides. They now go through font_layout.resolve_asset_path; the overrides file sits in the install root's config/. The resolver docstrings described an order the code does not follow: resolve_asset_path never consults the cwd, and sports_shared's _resolve_font_path tries the cwd first. Both docstrings now say what the code does, and _resolve_font_path calls resolve_asset_path instead of probing FontManager for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sync): the web UI reads the sync status file the display writes sync_manager writes its status to tempfile.gettempdir(), but GET /api/v3/sync/status read a hardcoded /tmp/led_matrix_sync_status.json and defaulted the port to a literal 5765. Wherever TMPDIR is set (or on any non-/tmp host) the page only ever showed "starting". The endpoint now uses sync_manager.STATUS_FILE and SYNC_PORT. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(http): the rankings resolver sends the project's User-Agent DynamicTeamResolver fetched ESPN rankings with a bare requests.get, so it sent python-requests' default User-Agent, which ESPN rejects; the AP_TOP_N favourites then resolved to nothing. It now sends DEFAULT_HTTP_HEADERS. BaseOddsManager carried its own copy of the User-Agent string and now uses the same shared headers (which also adds Accept-Language). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(backup): record the core release and read the configured plugin dir The manifest's ledmatrix_version came from a VERSION file that does not exist, then from .git/HEAD: a 12-character sha, or "ref: refs/he" when the branch's ref was packed. It is now src.__version__. list_installed_plugins() scanned a hardcoded plugin-repos/, so on an install whose plugin_system.plugins_directory points elsewhere, plugins missing from plugin_state.json were left out of the backup. It now reads the configured directory from config/config.json, defaulting to plugin-repos. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(startup): report a missing display section once A config without a display section produced three errors for the one problem ("Missing required configuration key: display", "Display configuration is missing or empty" and "Display configuration is missing"), and an empty one produced two. _validate_config now reports it once, as a missing key or an empty section, and _validate_display_config leaves it to that. The module docstring said the validator fails fast; nothing in the display service calls raise_on_errors(), so it now says the errors are reported and startup continues. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(wifi): share the copied blocks and name the AP constants - _parse_nmcli_wifi_list() is the one parser behind _scan_nmcli and _scan_nmcli_cached. - _verify_connected(), _wait_for_device_idle(), _failsafe_ap() and _mark_forced() replace blocks that were pasted two or three times in the connect and enable-AP paths. The device-idle wait now checks before its first one-second sleep instead of after it. - _check_command() calls _find_command_path() instead of repeating it. - AP_IP, PORTAL_PORT, AP_PROFILE_NAME and AP_PROFILE_NAMES name values that were spelled out 14, 12, 8 and 2 times; the two deletion loops now walk the same tuple. The iwconfig status path compares the AP address exactly: startswith() also skipped 192.168.4.10-19. - Dropped a second WIFI.SIGNAL query that repeated the first, a no-op "if ssid: continue", the try/except around _connect_wpa_supplicant's constant return, and a second save of a scan scan_networks already saves. - _ensure_wifi_radio_enabled's docstring says it returns True when the radio state cannot be read at all. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(config): drop dead branches and history comments in ConfigManager - The module docstring pointed plugin authors at update_plugin_config(), which does not exist; it now names save_config_atomic() and save_raw_file_content(). - load_config's FileNotFoundError handler tested the message for "config_secrets.json", but a missing secrets file is handled where it is read, so only config.json reaches it; the check is gone. - save_raw_file_content's `file_type == "main" or "secrets"` guard was always true (anything else raised earlier). - get_raw_file_content('secrets') already returns {} for a missing file, so the os.path.exists() in front of two calls to it is gone. - Comments that narrated earlier behaviour are rewritten as what the code does now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(background-data): present-tense comments, drop unused API - Comments that told the history of each fix (what "used to" happen, "the old per-delivery release") now state the invariant the code keeps. - get_statistics() no longer reports a constant 'queue_size': 0, and the uncalled clear_completed_requests() is gone (_cleanup_completed_requests does that job on every completion). Neither is referenced in core, the web UI or the plugin monorepo. shutdown_background_service() has no production caller either, but it is the only way to tear down the get_background_service() singleton, which the tests rely on, so it stays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(odds): drop the unread cache_ttl and merge the odds_data branches BaseOddsManager loaded base_odds_manager.cache_ttl from config and never used it: cached odds live for the update interval (get_odds' ttl=interval). No core or monorepo code reads the attribute, so it is gone along with its log line. The two consecutive `if odds_data:` blocks are one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(backup): one table for the single-file sections config, secrets, wifi and ytm_auth were each spelled out in create, preview, validate and restore. _SINGLE_FILE_SECTIONS lists them once, with the RestoreOptions flag that restores each, and all four walk it. Restore error messages keep their wording ("Failed to restore <file name>"). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(fonts): drop FontManager's write-only state and duplicate logs - fonts_config, font_metadata and font_dependencies were written and never read; the performance_stats keys font_load_times, render_times, total_renders and the per-call "resolve" timings (_record_performance_metric) likewise. get_performance_stats() reads only the counters that remain. Nothing in core or the plugin monorepo references any of them. - A failed BDF load was logged twice, by _load_bdf_font and again by get_font; get_font's line is the one kept. - Removed "NEW:" and commented-out cozette entries, the "Copy font to assets/fonts" comment on code that copies nothing, and local imports of names the module already imports. The deprecated add_font() now resolves assets/fonts against the install root. The @deprecated methods stay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(text-helper): cache loaded fonts; drop the pre-textlength fallback TextHelper declared _font_cache, cleared it and reported its size, but never stored anything in it. load_fonts() now keeps each (file, size) it loads there, so clear_font_cache() and get_font_cache_stats() mean what they say and repeated load_fonts() calls reuse the fonts. get_text_width() no longer catches AttributeError for Pillow releases without ImageDraw.textlength; requirements.txt pins Pillow>=12.2. The class docstring describes what the helper does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(common): fix wrong docstrings in api_helper, permission_utils, snapshot_policy - permission_utils called 0o2775 "sticky bit"; the 2 is setgid, which is what makes new files take the directory's group. - snapshot_policy pointed at web_interface/blueprints/api_v3.py, which is a package now; the health check is in api_v3/misc.py. - APIHelper.clear_cache() lost a history note and a fallback to a clear() method that neither CacheManager nor the testing MockCacheManager has. The session headers are built from DEFAULT_HTTP_HEADERS instead of a copy of them, and the module docstring says what the module offers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(sports): present-tense comments in the shared scoreboard renderers - sports_scroll and sports_game_renderer comments that referred to "this PR", "the old flat 128px card" or what the renderer "previously" did now describe the current behaviour and its reason. - The block explaining why non-finite settings are rejected sat above _score_reserve_width; it describes _center_gap_width and now lives in it. - unshare_element_fonts wrapped its import of font_layout.load_truetype in an `except ImportError` that cannot fire inside core; the import stays at call time so tests can spy on the pinned loader. - sports_card docstrings that told the history of a fix say what the code does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(sports-shared): drop dead code, name the ESPN limit - _get_weeks_data asked for limit=1000, which fetch_espn_scoreboard clamps to ESPN_MAX_LIMIT anyway; it now names that constant. Its unused `immediate_events = []` is gone. - _get_season_schedule_dates() returned ("", "") and has no caller in core or the plugin monorepo. - _should_log keeps its warning_type parameter (part of the inherited signature, though nothing in core or the monorepo calls it) and its docstring says the cooldown is shared across types. - An unused ImageFont import is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(sync): one follower-mode switch, shared panel defaults - The class docstring said the leader sends PNG frames. Frames go over UDP as raw RGB; PNG is only the Vegas scroll image sent over TCP. It now describes both paths. - _enter_follower_mode() replaces the two copies of "note the leader, switch from standalone to follower, log, write status" in the frame and scroll-position handlers. - The rows/cols fallbacks use DEFAULT_ROWS / DEFAULT_COLS from src.display_geometry, as chain_length already did. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(style): drop _layout_axis, name the layout group title - ElementStyleResolver._layout_axis() had no caller in core or the plugin monorepo. - _element_block_from_spec checked spec['size'] was a dict again after size_spec already had; it reads size_spec. - The "Layout Offsets" title written into three generated schema blocks is _LAYOUT_TITLE. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(logo-helper): say what the placeholder draws; name the 1.5 box factor - _create_placeholder_logo's docstring said it draws the team abbreviation; it draws an outlined grey box and nothing else. The docstring says so, and the "in a real implementation you'd want text" comments are gone. - The 1.5 x panel default logo box, written out six times, is DEFAULT_LOGO_BOX_FACTOR. - ImageDraw is imported with Image at the top of the module. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(logos): drop dead code and a duplicate regex in logo_downloader - _SAFE_LEAGUE_CODE_RE was the same pattern as _SAFE_LEAGUE_RE; both checks use the one. - get_logo_filename_variations reassigned the TA&M case to the list it already had; the function returns the two names directly. - _get_team_name_variations() had no caller in core or the plugin monorepo. - fetch_single_team's docstring was copied from fetch_teams_data; a log message read "for{team_id}". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor: drop the Pillow<9.1 resample shim and a catch-and-reraise - adaptive_images fell back to Image.LANCZOS/NEAREST for Pillow < 9.1; requirements.txt pins Pillow>=12.2. RESAMPLE_LANCZOS and RESAMPLE_NEAREST keep their names (src.common re-exports them). - CacheManager.save_cache caught CacheError only to re-raise it; the disk write is now called directly, with the same result. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(api-helper): stop the real CacheManager's cleanup thread The cache-lifetime tests built a CacheManager and left its cleanup thread's class-wide claim on the directory in place, which broke test_cache_cleanup_thread_ownership when it ran later in the session. The fixture now stops the thread on teardown. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): core-common Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
850 lines
34 KiB
Python
850 lines
34 KiB
Python
"""
|
|
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 json
|
|
import hashlib
|
|
import urllib.parse
|
|
import urllib.request
|
|
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 src.common.permission_utils import (
|
|
ensure_directory_permissions,
|
|
get_assets_dir_mode,
|
|
get_config_dir_mode,
|
|
)
|
|
from typing import Dict, Tuple, Optional, Union, Any, List
|
|
from src.deprecation import deprecated
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
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)
|
|
|
|
# Counters behind get_performance_stats().
|
|
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"
|
|
}
|
|
|
|
# Size tokens for convenience
|
|
self.size_tokens = {
|
|
"xs": 6, "sm": 8, "md": 10, "lg": 12, "xl": 14, "xxl": 16
|
|
}
|
|
|
|
# Font overrides storage (for manual overrides)
|
|
# 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
|
|
|
|
@deprecated("3.7.0")
|
|
def get_manager_fonts(self, manager_id: Optional[str] = None) -> Dict[str, Any]:
|
|
"""
|
|
Get registered fonts for a specific manager or all managers.
|
|
|
|
Args:
|
|
manager_id: Optional manager ID, if None returns all
|
|
|
|
Returns:
|
|
Dictionary of registered fonts
|
|
"""
|
|
if manager_id:
|
|
return self.manager_fonts.get(manager_id, {})
|
|
return self.manager_fonts.copy()
|
|
|
|
@deprecated("3.7.0")
|
|
def get_detected_fonts(self) -> Dict[str, Dict[str, Any]]:
|
|
"""Get all detected font usage across managers."""
|
|
return self.detected_fonts.copy()
|
|
|
|
# ==================== 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
|
|
|
|
# Check if already downloaded
|
|
if cache_path.exists():
|
|
logger.info(f"Using cached font: {cache_path}")
|
|
return str(cache_path)
|
|
|
|
# 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}")
|
|
urllib.request.urlretrieve(url, cache_path) # nosec B310 - scheme validated above
|
|
|
|
# Handle zip files
|
|
if url.endswith('.zip'):
|
|
extract_dir = self.temp_font_dir / f"{family}_{url_hash}"
|
|
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
|
|
for file in extract_dir.iterdir():
|
|
if file.suffix.lower() in ['.ttf', '.otf', '.bdf']:
|
|
return str(file)
|
|
|
|
return str(cache_path)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error downloading font from {url}: {e}")
|
|
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)
|
|
|
|
@deprecated("3.7.0")
|
|
def unregister_plugin_fonts(self, plugin_id: str) -> bool:
|
|
"""Unregister all fonts for a plugin."""
|
|
try:
|
|
if plugin_id in self.plugin_fonts:
|
|
# Remove from plugin catalogs
|
|
if plugin_id in self.plugin_font_catalogs:
|
|
for family in self.plugin_font_catalogs[plugin_id]:
|
|
namespaced_family = f"{plugin_id}::{family}"
|
|
if namespaced_family in self.font_catalog:
|
|
del self.font_catalog[namespaced_family]
|
|
|
|
del self.plugin_font_catalogs[plugin_id]
|
|
|
|
# Remove plugin manifest
|
|
del self.plugin_fonts[plugin_id]
|
|
|
|
# Clear related cache entries
|
|
self._clear_plugin_font_cache(plugin_id)
|
|
|
|
logger.info(f"Unregistered fonts for plugin {plugin_id}")
|
|
return True
|
|
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error unregistering plugin fonts: {e}")
|
|
return False
|
|
|
|
def _clear_plugin_font_cache(self, plugin_id: str):
|
|
"""Clear font cache entries for a specific plugin."""
|
|
keys_to_remove = [key for key in self.font_cache.keys() if key.startswith(f"{plugin_id}::")]
|
|
for key in keys_to_remove:
|
|
del self.font_cache[key]
|
|
|
|
@deprecated("3.7.0")
|
|
def get_plugin_fonts(self, plugin_id: str) -> List[str]:
|
|
"""Get list of font families registered by a plugin."""
|
|
if plugin_id in self.plugin_font_catalogs:
|
|
return list(self.plugin_font_catalogs[plugin_id].keys())
|
|
return []
|
|
|
|
# ==================== 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
|
|
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)
|
|
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()
|
|
|
|
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
|
|
|
|
# ==================== Override Management ====================
|
|
|
|
@deprecated("3.7.0")
|
|
def set_override(self, element_key: str, family: str = None, size_px: int = None):
|
|
"""Set font override for a specific element."""
|
|
if element_key not in self.font_overrides:
|
|
self.font_overrides[element_key] = {}
|
|
|
|
if family is not None:
|
|
self.font_overrides[element_key]["family"] = family
|
|
if size_px is not None:
|
|
self.font_overrides[element_key]["size_px"] = size_px
|
|
|
|
# Remove empty overrides
|
|
if not self.font_overrides[element_key]:
|
|
del self.font_overrides[element_key]
|
|
else:
|
|
self._save_overrides()
|
|
|
|
self.clear_cache()
|
|
logger.info(f"Font override set for {element_key}: {self.font_overrides.get(element_key, {})}")
|
|
|
|
@deprecated("3.7.0")
|
|
def remove_override(self, element_key: str):
|
|
"""Remove font override for a specific element."""
|
|
if element_key in self.font_overrides:
|
|
del self.font_overrides[element_key]
|
|
self._save_overrides()
|
|
self.clear_cache()
|
|
logger.info(f"Font override removed for {element_key}")
|
|
|
|
@deprecated("3.7.0")
|
|
def get_overrides(self) -> Dict[str, Dict[str, str]]:
|
|
"""Get current font overrides."""
|
|
return self.font_overrides.copy()
|
|
|
|
# ==================== 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 = {}
|
|
|
|
def _save_overrides(self):
|
|
"""Save current font overrides to file."""
|
|
try:
|
|
font_overrides_path = Path(self.font_overrides_file)
|
|
ensure_directory_permissions(font_overrides_path.parent, get_config_dir_mode())
|
|
with open(self.font_overrides_file, 'w') as f:
|
|
json.dump(self.font_overrides, f, indent=2)
|
|
logger.info(f"Saved {len(self.font_overrides)} font overrides")
|
|
except Exception as e:
|
|
logger.error(f"Could not save font overrides: {e}")
|
|
|
|
# ==================== Utility Methods ====================
|
|
|
|
def clear_cache(self):
|
|
"""Clear font and metrics cache."""
|
|
self.font_cache.clear()
|
|
self.metrics_cache.clear()
|
|
logger.info("Font cache cleared")
|
|
|
|
@deprecated("3.7.0", "read font_catalog")
|
|
def get_available_fonts(self) -> Dict[str, str]:
|
|
"""Get dictionary of available font families and their paths."""
|
|
return self.font_catalog.copy()
|
|
|
|
@deprecated("3.7.0")
|
|
def get_size_tokens(self) -> Dict[str, int]:
|
|
"""Get available size tokens."""
|
|
return self.size_tokens.copy()
|
|
|
|
@deprecated("3.7.0")
|
|
def get_performance_stats(self) -> Dict[str, Any]:
|
|
"""Get performance statistics."""
|
|
uptime = time.time() - self.performance_stats["start_time"]
|
|
return {
|
|
"uptime_seconds": uptime,
|
|
"cache_hits": self.performance_stats["cache_hits"],
|
|
"cache_misses": self.performance_stats["cache_misses"],
|
|
"cache_hit_rate": (
|
|
self.performance_stats["cache_hits"] /
|
|
(self.performance_stats["cache_hits"] + self.performance_stats["cache_misses"])
|
|
if (self.performance_stats["cache_hits"] + self.performance_stats["cache_misses"]) > 0 else 0
|
|
),
|
|
"total_fonts_cached": len(self.font_cache),
|
|
"total_metrics_cached": len(self.metrics_cache),
|
|
"failed_loads": self.performance_stats["failed_loads"],
|
|
"total_fonts_available": len(self.font_catalog),
|
|
"plugin_fonts": len(self.plugin_fonts),
|
|
"manager_fonts": len(self.manager_fonts),
|
|
"detected_fonts": len(self.detected_fonts)
|
|
}
|
|
|
|
@deprecated("3.7.0", "read font_catalog")
|
|
def get_font_catalog(self) -> Dict[str, str]:
|
|
"""Get the current font catalog."""
|
|
return self.font_catalog.copy()
|
|
|
|
@deprecated("3.7.0")
|
|
def add_font(self, font_file_path: str, family_name: str) -> bool:
|
|
"""Add ``font_file_path`` to the catalog as ``family_name``. The file
|
|
stays where it is; only assets/fonts is created if it is missing."""
|
|
try:
|
|
# Validate font file
|
|
if not os.path.exists(font_file_path):
|
|
logger.error(f"Font file not found: {font_file_path}")
|
|
return False
|
|
|
|
# Check if family name already exists
|
|
if family_name in self.font_catalog:
|
|
logger.warning(f"Font family '{family_name}' already exists")
|
|
return False
|
|
|
|
fonts_dir = Path(resolve_asset_path("assets/fonts"))
|
|
ensure_directory_permissions(fonts_dir, get_assets_dir_mode())
|
|
|
|
# Add to catalog
|
|
self.font_catalog[family_name] = font_file_path
|
|
self.clear_cache()
|
|
logger.info(f"Added font {family_name}: {font_file_path}")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error adding font {family_name}: {e}")
|
|
return False
|
|
|
|
@deprecated("3.7.0")
|
|
def remove_font(self, family_name: str) -> bool:
|
|
"""Remove a font from the catalog."""
|
|
try:
|
|
if family_name not in self.font_catalog:
|
|
logger.warning(f"Font family '{family_name}' not found")
|
|
return False
|
|
|
|
# Check if font is currently in use
|
|
in_use = False
|
|
for override in self.font_overrides.values():
|
|
if override.get("family") == family_name:
|
|
in_use = True
|
|
break
|
|
|
|
if in_use:
|
|
logger.error(f"Cannot remove font '{family_name}' - it is currently in use")
|
|
return False
|
|
|
|
del self.font_catalog[family_name]
|
|
self.clear_cache()
|
|
logger.info(f"Removed font {family_name}")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error removing font {family_name}: {e}")
|
|
return False
|
|
|
|
@deprecated("3.7.0")
|
|
def validate_font(self, font_path: str) -> Dict[str, Any]:
|
|
"""Validate a font file."""
|
|
try:
|
|
if not os.path.exists(font_path):
|
|
return {"valid": False, "error": "Font file not found"}
|
|
|
|
if font_path.endswith('.bdf'):
|
|
# Try to load BDF font
|
|
freetype.Face(font_path)
|
|
return {"valid": True, "type": "bdf", "family": "unknown"}
|
|
elif font_path.endswith('.ttf'):
|
|
# Try to load TTF font
|
|
load_truetype(font_path, 12)
|
|
return {"valid": True, "type": "ttf", "family": "unknown"}
|
|
else:
|
|
return {"valid": False, "error": "Unsupported font format"}
|
|
|
|
except Exception as e:
|
|
return {"valid": False, "error": str(e)}
|