diff --git a/docs/building_plugins.md b/docs/building_plugins.md index 19231adcc..a258f5efa 100644 --- a/docs/building_plugins.md +++ b/docs/building_plugins.md @@ -98,9 +98,16 @@ plugins/{plugin_id}/ ├── icon.png # Plugin icon ├── settings.html # Optional: Plugin settings page (if applicable) ├── render/ # Optional: If generating images from html and css files, store them here + ├── fonts/ # Optional: Plugin-specific fonts (automatically discovered) + │ ├── CustomFont.ttf + │ └── CustomFont-Bold.ttf └── {other files/resources} # Any additional files or resources used by the plugin ``` +### Plugin Fonts + +Plugins can bundle fonts by creating a `fonts/` subdirectory. Fonts placed here are automatically discovered and made available system-wide. When the plugin is removed, its fonts are automatically removed as well. See [Font Management](./fonts.md) for details. + ## Prepopulating forms for Plugin Instances When a plugin is added to a playlist, a "Plugin Instance" is created, and its settings are stored in the `src/config/device.json` file. These settings can be updated from the playlist page, so the form in settings.html should be prepopulated with the existing settings. diff --git a/docs/fonts.md b/docs/fonts.md new file mode 100644 index 000000000..5a4c381a2 --- /dev/null +++ b/docs/fonts.md @@ -0,0 +1,268 @@ +# Font Management in InkyPi + +InkyPi supports dynamic font discovery, allowing you to add custom fonts simply by placing font files in the fonts directory. This guide explains how fonts work, how to add them, and how to use them in plugins. + +## Overview + +InkyPi automatically discovers fonts from multiple locations: +- **Global fonts:** `src/static/fonts/` (user/system customizations) +- **Plugin fonts:** `src/plugins/*/fonts/` (plugin-specific fonts) + +Fonts are available for both: +- **PIL-based rendering** (plugins that draw directly with Pillow) +- **HTML/CSS rendering** (plugins that use the `render_image()` method) + +All discovered fonts are automatically included in the base HTML template (`plugin.html`), making them available to all HTML-rendered plugins. Fonts are discovered in priority order: hardcoded (highest) → global → plugin (lowest). + +## Supported Font Formats + +- **TrueType Fonts** (`.ttf`) - Fully supported +- **OpenType Fonts** (`.otf`) - Fully supported + +Fonts can be placed directly in `src/static/fonts/` or in subdirectories (e.g., `src/static/fonts/MyFonts/`). + +## Adding Fonts + +### Method 1: Global Fonts (User/System Level) + +Place your font files in the `src/static/fonts/` directory: + +```bash +src/static/fonts/ +├── MyCustomFont.ttf +├── MyCustomFont-Bold.ttf +└── AnotherFont/ + └── AnotherFont-Regular.otf +``` + +**How it works:** +- InkyPi scans the fonts directory on first use +- Font metadata (family name, weight, style) is extracted from the font files using `fonttools` +- If `fonttools` is not available, metadata is inferred from filenames using naming conventions +- Fonts are automatically merged with built-in fonts +- Global fonts have higher priority than plugin fonts + +### Method 2: Plugin Fonts (Plugin-Specific) + +Plugins can bundle their own fonts by creating a `fonts/` subdirectory: + +```bash +src/plugins/myplugin/ +├── fonts/ +│ ├── PluginFont.ttf +│ └── PluginFont-Bold.ttf +├── myplugin.py +└── ... +``` + +**How it works:** +- InkyPi automatically scans all plugin directories for `fonts/` subdirectories +- Plugin fonts are discovered and made available system-wide +- When a plugin is removed, its fonts are automatically removed +- Plugin fonts have lower priority than global fonts (can be overridden) +- Perfect for third-party plugins that bundle fonts + +**Naming Conventions (fallback when fonttools unavailable):** +- `FontName-Regular.ttf` → family="FontName", weight="normal" +- `FontName-Bold.ttf` → family="FontName", weight="bold" +- `FontName-Italic.ttf` → family="FontName", style="italic" +- `FontName-BoldItalic.ttf` → family="FontName", weight="bold", style="italic" + +### Method 2: Metadata Override (Advanced) + +For fonts with unusual naming or when you need to override metadata, create a JSON file next to the font file: + +**Example:** `MyFont-Bold.ttf` → `MyFont-Bold.json` + +```json +{ + "family": "Custom Font Name", + "weight": "bold", + "style": "normal" +} +``` + +The JSON file takes precedence over automatic extraction. + +## Built-in Fonts + +InkyPi includes these fonts by default: + +- **Jost** (normal, bold) +- **DS-Digital** (normal) +- **Napoli** (normal) +- **Dogica** (normal, bold) + +Built-in fonts take precedence over discovered fonts if there are conflicts. + +## Using Fonts in Plugins + +### HTML/CSS Rendering (render_image) + +When using `render_image()` from `BasePlugin`, all fonts are automatically available: + +**In your HTML template:** + +```html +{% extends "plugin.html" %} + +{% block content %} +
+ This text uses MyCustomFont +
+{% endblock %} +``` + +**In your CSS file:** + +```css +.my-class { + font-family: "MyCustomFont", sans-serif; + font-weight: bold; /* Uses MyCustomFont-Bold if available */ +} +``` + +**Important:** The base template (`plugin.html`) automatically includes `@font-face` declarations for **all discovered fonts**, so you can use any font family name directly in your CSS without additional setup. + +### PIL-based Rendering (get_font) + +For plugins that draw directly with Pillow: + +```python +from utils.app_utils import get_font + +# Get a font by family name +font = get_font("MyCustomFont", font_size=50, font_weight="normal") + +# Use in drawing +from PIL import ImageDraw +draw = ImageDraw.Draw(image) +draw.text((x, y), "Hello", font=font, fill="black") +``` + +**Available weights:** +- `"normal"` (default) +- `"bold"` + +If a specific weight isn't available, the function falls back to the first available variant. + +### Settings Pages (Font Selection Dropdowns) + +To show available fonts in plugin settings: + +**1. Override `generate_settings_template()` in your plugin:** + +```python +from utils.app_utils import get_fonts + +class MyPlugin(BasePlugin): + def generate_settings_template(self): + template_params = super().generate_settings_template() + + # Get unique font family names + fonts = get_fonts() + font_families = sorted(set(f["font_family"] for f in fonts)) + template_params["available_fonts"] = font_families + + return template_params +``` + +**2. Use in your settings template:** + +```html + +``` + +**Or with JavaScript (if using dynamic forms):** + +```html + +``` + +## Font Discovery Process + +Fonts are discovered in priority order: + +1. **Hardcoded fonts** (`FONT_FAMILIES`) - Highest priority (system fonts) +2. **Global fonts** (`src/static/fonts/`) - Medium priority (user/system customizations) +3. **Plugin fonts** (`src/plugins/*/fonts/`) - Lowest priority (plugin-specific fonts) + +**Discovery steps:** +1. **On first access:** InkyPi scans all font directories recursively +2. **Metadata extraction:** Uses `fonttools` to read font file metadata (family name, weight, style) +3. **Fallback:** If `fonttools` unavailable, infers from filename +4. **Override check:** Looks for `.json` metadata files +5. **Merging:** Combines fonts in priority order (higher priority fonts override lower priority) +6. **Caching:** Results are cached for performance + +## Troubleshooting + +### Font not appearing in plugin + +1. **Check font file:** Ensure `.ttf` or `.otf` file exists in `src/static/fonts/` or `src/plugins/{plugin_id}/fonts/` +2. **Restart service:** Font discovery happens on first access; restart InkyPi to refresh +3. **Check logs:** Look for font discovery messages in logs +4. **Verify metadata:** Use a metadata JSON file if font name extraction fails + +### Font name incorrect + +- **Use metadata override:** Create a `.json` file next to the font file to specify the exact family name +- **Install fonttools:** More accurate metadata extraction: `pip install fonttools` + +### Font not loading in HTML + +- **Check CSS syntax:** Ensure font-family name matches exactly (case-sensitive) +- **Verify @font-face:** Check browser developer tools to see if `@font-face` declarations are present +- **Check template:** Ensure your HTML extends `plugin.html` or includes the font-face declarations + +## Reloading Fonts + +To force font rediscovery (e.g., after adding new fonts): + +```python +from utils.app_utils import reload_fonts +reload_fonts() +``` + +Or restart the InkyPi service. + +## Best Practices + +1. **Use descriptive filenames:** Follow naming conventions for better fallback support +2. **Organize in subdirectories:** Group related fonts in folders +3. **Include variants:** Add bold, italic variants for complete font families +4. **Test both rendering methods:** Verify fonts work in both PIL and HTML rendering +5. **Use metadata files:** For fonts with non-standard naming, use JSON metadata files +6. **Bundle fonts with plugins:** Use `fonts/` subdirectory in plugins for plugin-specific fonts +7. **Consider priority:** Global fonts override plugin fonts, so use global fonts for user customizations + +## Technical Details + +- **Font discovery:** Lazy-loaded on first access, cached thereafter +- **Discovery locations:** + - Global fonts: `src/static/fonts/` + - Plugin fonts: `src/plugins/*/fonts/` +- **Priority order:** Hardcoded → Global → Plugin (higher priority overrides lower) +- **Metadata source:** `fonttools` library (if available) or filename parsing +- **Storage:** Font metadata cached in memory (`_DISCOVERED_FONTS`) +- **Template integration:** All fonts automatically included in `plugin.html` via `font_faces` template variable +- **API functions:** + - `get_font(font_name, font_size, font_weight)` - Get PIL ImageFont object + - `get_fonts()` - Get list of all fonts for HTML rendering + - `reload_fonts()` - Force rediscovery diff --git a/install/requirements-dev.txt b/install/requirements-dev.txt index c66cf1989..c1566fc93 100644 --- a/install/requirements-dev.txt +++ b/install/requirements-dev.txt @@ -14,4 +14,5 @@ psutil==7.2.2 feedparser==6.0.11 waitress==3.0.2 astral>=3.1 +fonttools>=4.0.0 pytest==8.4.2 diff --git a/install/requirements.txt b/install/requirements.txt index 66c636ab4..20c7649bd 100644 --- a/install/requirements.txt +++ b/install/requirements.txt @@ -15,4 +15,5 @@ psutil==7.2.2 cysystemd==2.0.1 waitress==3.0.2 feedparser==6.0.11 +fonttools>=4.0.0 astral>=3.1 diff --git a/src/utils/app_utils.py b/src/utils/app_utils.py index 24a7ce6cd..5e24c35bb 100644 --- a/src/utils/app_utils.py +++ b/src/utils/app_utils.py @@ -2,12 +2,14 @@ import os import socket import subprocess +import json from pathlib import Path from PIL import Image, ImageDraw, ImageFont, ImageOps logger = logging.getLogger(__name__) +# Hardcoded font families (base fonts, can be overridden by discovered fonts) FONT_FAMILIES = { "Dogica": [{ "font-weight": "normal", @@ -33,6 +35,10 @@ }] } +# Cache for discovered fonts (lazy-loaded) +_DISCOVERED_FONTS = None +_FONTS_DIR = None + FONTS = { "ds-gigi": "DS-DIGI.TTF", "napoli": "Napoli.ttf", @@ -49,6 +55,373 @@ def resolve_path(file_path): src_path = Path(src_dir) return str(src_path / file_path) +def _get_fonts_directory(): + """Get the global fonts directory path.""" + global _FONTS_DIR + if _FONTS_DIR is None: + _FONTS_DIR = resolve_path(os.path.join("static", "fonts")) + return _FONTS_DIR + +def _get_plugin_fonts_directories(): + """Get list of plugin fonts directories. + + Scans all plugin directories for fonts/ subdirectories. + + Returns: + list: List of absolute paths to plugin fonts directories + """ + plugins_dir = resolve_path("plugins") + plugin_font_dirs = [] + + if not os.path.exists(plugins_dir): + return plugin_font_dirs + + plugins_base_path = Path(plugins_dir) + + # Scan each plugin directory for fonts/ subdirectory + for plugin_dir in plugins_base_path.iterdir(): + if plugin_dir.is_dir(): + fonts_dir = plugin_dir / "fonts" + if fonts_dir.is_dir(): + plugin_font_dirs.append(str(fonts_dir)) + + return plugin_font_dirs + +def _extract_font_metadata(font_path): + """ + Extract font metadata (family name, weight, style) from a font file. + + Uses fonttools if available (best practice), falls back to naming conventions. + + Args: + font_path: Path to the font file (.ttf or .otf) + + Returns: + dict with keys: 'family', 'weight', 'style', or None if extraction fails + """ + # Try using fonttools (best practice for font metadata extraction) + try: + from fontTools.ttLib import TTFont + + font = TTFont(font_path) + name_table = font.get('name') + + # Get font family name (nameID 1 = Font Family, nameID 16 = Typographic Family) + family_name = name_table.getBestFamilyName() or name_table.getBestSubFamilyName() + + # Get subfamily/weight info (nameID 2 = Font Subfamily, nameID 17 = Typographic Subfamily) + subfamily = name_table.getBestSubFamilyName() or "" + subfamily_lower = subfamily.lower() + + # Determine weight from subfamily name (common patterns) + weight = "normal" + if any(term in subfamily_lower for term in ["bold", "black", "heavy", "700", "800", "900"]): + weight = "bold" + elif any(term in subfamily_lower for term in ["light", "thin", "100", "200", "300"]): + weight = "normal" # Keep as normal for CSS compatibility + + # Determine style + style = "normal" + if "italic" in subfamily_lower or "oblique" in subfamily_lower: + style = "italic" + + # Also check OS/2 table for weight value if available + if 'OS/2' in font: + os2 = font['OS/2'] + us_weight = os2.usWeightClass + if us_weight >= 700: + weight = "bold" + elif us_weight <= 300: + weight = "normal" + + font.close() + + return { + "family": family_name, + "weight": weight, + "style": style + } + except ImportError: + # fonttools not available, use naming convention fallback + logger.debug("fonttools not available, using naming convention fallback") + return _extract_font_metadata_from_filename(font_path) + except Exception as e: + logger.warning(f"Failed to extract metadata from {font_path} using fonttools: {e}") + return _extract_font_metadata_from_filename(font_path) + +def _extract_font_metadata_from_filename(font_path): + """ + Fallback: Extract font metadata from filename using naming conventions. + + Common patterns: + - FontName-Regular.ttf -> family="FontName", weight="normal" + - FontName-Bold.ttf -> family="FontName", weight="bold" + - FontName-Italic.ttf -> family="FontName", style="italic" + - FontName-BoldItalic.ttf -> family="FontName", weight="bold", style="italic" + + Args: + font_path: Path to the font file + + Returns: + dict with keys: 'family', 'weight', 'style', or None if extraction fails + """ + filename = os.path.basename(font_path) + name_without_ext = os.path.splitext(filename)[0] + + # Common weight/style suffixes + weight = "normal" + style = "normal" + + name_lower = name_without_ext.lower() + + # Check for weight indicators + if any(term in name_lower for term in ["bold", "black", "heavy", "semibold", "semi-bold"]): + weight = "bold" + elif any(term in name_lower for term in ["light", "thin", "extralight"]): + weight = "normal" # Keep as normal for CSS compatibility + + # Check for style indicators + if "italic" in name_lower or "oblique" in name_lower: + style = "italic" + + # Extract family name by removing common suffixes + family = name_without_ext + for suffix in ["-Bold", "-Regular", "-Italic", "-Light", "-Black", "-Heavy", + "-SemiBold", "-Semi-Bold", "-BoldItalic", "-Bold-Italic"]: + if family.endswith(suffix): + family = family[:-len(suffix)] + break + + return { + "family": family, + "weight": weight, + "style": style + } + +def _load_metadata_override(font_path): + """ + Load metadata override from a JSON file if it exists. + + For a font file like "MyFont-Bold.ttf", checks for "MyFont-Bold.json" + in the same directory. This allows manual overrides for edge cases. + + Args: + font_path: Path to the font file + + Returns: + dict with override metadata, or None if no override file exists + """ + metadata_path = os.path.splitext(font_path)[0] + ".json" + if os.path.exists(metadata_path): + try: + with open(metadata_path, 'r', encoding='utf-8') as f: + metadata = json.load(f) + # Validate required fields + if "family" in metadata: + return metadata + except Exception as e: + logger.warning(f"Failed to load metadata override from {metadata_path}: {e}") + return None + +def _scan_fonts_in_directory(fonts_dir, base_path_for_relative=None): + """ + Scan a single directory for font files and extract metadata. + + Args: + fonts_dir: Absolute path to directory to scan + base_path_for_relative: Base path for calculating relative paths (defaults to fonts_dir) + + Returns: + dict: Dictionary mapping font family names to lists of variants + """ + discovered = {} + font_extensions = {'.ttf', '.otf', '.TTF', '.OTF'} + + if not os.path.exists(fonts_dir): + return discovered + + if base_path_for_relative is None: + base_path_for_relative = fonts_dir + + fonts_base_path = Path(fonts_dir) + font_files_found = 0 + + for font_file in fonts_base_path.rglob('*'): + if font_file.suffix in font_extensions: + font_files_found += 1 + try: + font_path = str(font_file) + relative_path = os.path.relpath(font_path, base_path_for_relative) + + # Check for metadata override first + metadata_override = _load_metadata_override(font_path) + + if metadata_override: + metadata = metadata_override + # Ensure file path is set correctly (relative to static/fonts for global, or plugin path for plugin fonts) + metadata['file'] = relative_path.replace('\\', '/') + else: + # Extract metadata from font file + metadata = _extract_font_metadata(font_path) + if not metadata: + logger.warning(f"Could not extract metadata from {font_path}, skipping") + continue + metadata['file'] = relative_path.replace('\\', '/') + + family = metadata['family'] + weight = metadata.get('weight', 'normal') + style = metadata.get('style', 'normal') + + # Initialize family if not exists + if family not in discovered: + discovered[family] = [] + + # Add variant + variant = { + "font-weight": weight, + "font-style": style, + "file": metadata['file'] + } + + # Avoid duplicates (same weight/style combination) + if not any(v.get("font-weight") == weight and v.get("font-style") == style + for v in discovered[family]): + discovered[family].append(variant) + + except Exception as e: + logger.warning(f"Error processing font file {font_file}: {e}") + import traceback + logger.debug(traceback.format_exc()) + continue + + logger.debug(f"Found {font_files_found} font file(s) in {fonts_dir}") + return discovered + +def _discover_fonts(): + """ + Discover fonts dynamically from multiple sources. + + Scans fonts in priority order: + 1. Global fonts directory (src/static/fonts/) + 2. Plugin fonts directories (src/plugins/*/fonts/) + + Fonts are merged with hardcoded FONT_FAMILIES (hardcoded takes precedence). + + Returns: + dict: Font families dictionary in the same format as FONT_FAMILIES + """ + global _DISCOVERED_FONTS + + if _DISCOVERED_FONTS is not None: + logger.debug(f"Using cached font discovery results ({len(_DISCOVERED_FONTS)} families)") + return _DISCOVERED_FONTS + + discovered = {} + total_font_files = 0 + + # 1. Scan global fonts directory (src/static/fonts/) + global_fonts_dir = _get_fonts_directory() + logger.debug(f"Scanning global fonts directory: {global_fonts_dir}") + global_fonts = _scan_fonts_in_directory(global_fonts_dir, global_fonts_dir) + total_font_files += sum(len(variants) for variants in global_fonts.values()) + + # Merge global fonts (higher priority than plugin fonts) + for family, variants in global_fonts.items(): + if family not in discovered: + discovered[family] = [] + # Add variants, avoiding duplicates + existing_weights_styles = { + (v.get("font-weight", "normal"), v.get("font-style", "normal")) + for v in discovered[family] + } + for variant in variants: + key = (variant.get("font-weight", "normal"), variant.get("font-style", "normal")) + if key not in existing_weights_styles: + discovered[family].append(variant) + + # 2. Scan plugin fonts directories (src/plugins/*/fonts/) + plugin_font_dirs = _get_plugin_fonts_directories() + logger.debug(f"Scanning {len(plugin_font_dirs)} plugin fonts directory(ies)") + + plugins_dir = resolve_path("plugins") + + for plugin_fonts_dir in plugin_font_dirs: + plugin_fonts = _scan_fonts_in_directory(plugin_fonts_dir, plugin_fonts_dir) + total_font_files += sum(len(variants) for variants in plugin_fonts.values()) + + # Merge plugin fonts (lower priority - can be overridden by global fonts) + for family, variants in plugin_fonts.items(): + if family not in discovered: + discovered[family] = [] + # Add variants, avoiding duplicates + existing_weights_styles = { + (v.get("font-weight", "normal"), v.get("font-style", "normal")) + for v in discovered[family] + } + for variant in plugin_fonts[family]: + key = (variant.get("font-weight", "normal"), variant.get("font-style", "normal")) + if key not in existing_weights_styles: + # Calculate relative path from plugins directory for plugin fonts + # variant['file'] is currently relative to plugin_fonts_dir, need to make it relative to plugins_dir + font_file_path = os.path.join(plugin_fonts_dir, variant['file']) + relative_to_plugins = os.path.relpath(font_file_path, plugins_dir).replace('\\', '/') + # Create a copy to avoid modifying the original variant dict + variant_copy = variant.copy() + variant_copy['file'] = relative_to_plugins + discovered[family].append(variant_copy) + + # Sort variants within each family for consistency + for family in discovered: + discovered[family].sort(key=lambda v: (v.get("font-weight", "normal"), v.get("font-style", "normal"))) + + _DISCOVERED_FONTS = discovered + logger.info(f"Font discovery complete: {len(discovered)} font families discovered from {total_font_files} font file(s) (global + {len(plugin_font_dirs)} plugin dir(s))") + + return discovered + +def _get_all_font_families(): + """ + Get merged font families (hardcoded + discovered). + + Hardcoded FONT_FAMILIES take precedence for overrides. + Discovered fonts are merged in, adding new families or variants. + + Returns: + dict: Merged font families dictionary + """ + discovered = _discover_fonts() + + # Start with hardcoded fonts (base/overrides) + merged = FONT_FAMILIES.copy() + + # Merge discovered fonts (discovered takes precedence for new families, + # but hardcoded takes precedence for existing families) + new_families = 0 + new_variants = 0 + for family, variants in discovered.items(): + if family not in merged: + # New family, add all variants + merged[family] = variants.copy() + new_families += 1 + logger.debug(f"Added new font family: {family} with {len(variants)} variant(s)") + else: + # Existing family: merge variants, avoiding duplicates + existing_weights_styles = { + (v.get("font-weight", "normal"), v.get("font-style", "normal")) + for v in merged[family] + } + for variant in variants: + key = (variant.get("font-weight", "normal"), variant.get("font-style", "normal")) + if key not in existing_weights_styles: + merged[family].append(variant) + new_variants += 1 + logger.debug(f"Added new variant to {family}: weight={variant.get('font-weight')}, style={variant.get('font-style')}") + + if new_families > 0 or new_variants > 0: + logger.debug(f"Merged fonts: {new_families} new families, {new_variants} new variants added") + + return merged + def get_ip_address(): with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: s.connect(("8.8.8.8", 80)) @@ -72,16 +445,44 @@ def is_connected(): return False def get_font(font_name, font_size=50, font_weight="normal"): - if font_name in FONT_FAMILIES: - font_variants = FONT_FAMILIES[font_name] + """ + Get a PIL ImageFont object for the specified font. + + Uses merged font families (hardcoded + discovered) to find the font. + Maintains backward compatibility with existing plugin code. + + Args: + font_name: Name of the font family + font_size: Size of the font in points + font_weight: Weight of the font ("normal" or "bold") + + Returns: + ImageFont object or None if font not found + """ + font_families = _get_all_font_families() + + if font_name in font_families: + font_variants = font_families[font_name] font_entry = next((entry for entry in font_variants if entry["font-weight"] == font_weight), None) if font_entry is None: font_entry = font_variants[0] # Default to first available variant if font_entry: - font_path = resolve_path(os.path.join("static", "fonts", font_entry["file"])) - return ImageFont.truetype(font_path, font_size) + # Handle both global fonts (relative to static/fonts) and plugin fonts (relative to plugins/) + font_file = font_entry["file"] + if font_file.startswith("plugins/"): + # Plugin font - path is already relative to plugins directory + font_path = resolve_path(font_file) + else: + # Global font - relative to static/fonts + font_path = resolve_path(os.path.join("static", "fonts", font_file)) + + try: + return ImageFont.truetype(font_path, font_size) + except Exception as e: + logger.error(f"Failed to load font file {font_path}: {e}") + return None else: logger.warning(f"Requested font weight not found: font_name={font_name}, font_weight={font_weight}") else: @@ -90,19 +491,72 @@ def get_font(font_name, font_size=50, font_weight="normal"): return None def get_fonts(): - fonts_list = [] - for font_family, variants in FONT_FAMILIES.items(): - for variant in variants: - fonts_list.append({ - "font_family": font_family, - "url": resolve_path(os.path.join("static", "fonts", variant["file"])), - "font_weight": variant.get("font-weight", "normal"), - "font_style": variant.get("font-style", "normal"), - }) - return fonts_list + """ + Get list of all available fonts for HTML/CSS rendering. + + Returns merged font families (hardcoded + discovered) in the format + expected by the HTML template system. Maintains backward compatibility. + + Returns: + list: List of font dictionaries with font_family, url, font_weight, font_style + """ + try: + font_families = _get_all_font_families() + fonts_list = [] + + for font_family, variants in font_families.items(): + for variant in variants: + # Handle both global fonts (relative to static/fonts) and plugin fonts (relative to plugins/) + font_file = variant["file"] + if font_file.startswith("plugins/"): + # Plugin font - path is already relative to plugins directory + font_url = resolve_path(font_file) + else: + # Global font - relative to static/fonts + font_url = resolve_path(os.path.join("static", "fonts", font_file)) + + fonts_list.append({ + "font_family": font_family, + "url": font_url, + "font_weight": variant.get("font-weight", "normal"), + "font_style": variant.get("font-style", "normal"), + }) + + logger.debug(f"get_fonts() returning {len(fonts_list)} font entries from {len(font_families)} families") + return fonts_list + except Exception as e: + logger.error(f"Error in get_fonts(): {e}", exc_info=True) + raise def get_font_path(font_name): - return resolve_path(os.path.join("static", "fonts", FONTS[font_name])) + """ + Get the file path for a font by its legacy name. + + Note: This function uses the legacy FONTS dict. For new code, + prefer using get_font() which supports dynamic font discovery. + + Args: + font_name: Legacy font identifier from FONTS dict + + Returns: + str: Absolute path to the font file + """ + if font_name in FONTS: + return resolve_path(os.path.join("static", "fonts", FONTS[font_name])) + else: + logger.warning(f"Legacy font name not found: {font_name}") + return None + +def reload_fonts(): + """ + Reload discovered fonts from the filesystem. + + Clears the font cache and forces a fresh discovery scan. + Useful when fonts are added/removed at runtime. + """ + global _DISCOVERED_FONTS + _DISCOVERED_FONTS = None + _discover_fonts() def generate_startup_image(dimensions=(800,480)): bg_color = (255,255,255)