feat(fonts): add dynamic font selection and font manager improvements (#232)

* feat(fonts): add dynamic font selection and font manager improvements

- Add font-selector widget for dynamic font selection in plugin configs
- Enhance /api/v3/fonts/catalog with filename, display_name, and type
- Add /api/v3/fonts/preview endpoint for server-side font rendering
- Add /api/v3/fonts/<family> DELETE endpoint with system font protection
- Fix /api/v3/fonts/upload to actually save uploaded font files
- Update font manager tab with dynamic dropdowns, server-side preview, and font deletion
- Add new BDF fonts: 6x10, 6x12, 6x13, 7x13, 7x14, 8x13, 9x15, 9x18, 10x20 (with bold/oblique variants)
- Add tom-thumb, helvR12, clR6x12, texgyre-27 fonts

Plugin authors can use x-widget: "font-selector" in schemas to enable
dynamic font selection that automatically shows all available fonts.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(fonts): security fixes and code quality improvements

- Fix README.md typos and add language tags to code fences
- Remove duplicate delete_font function causing Flask endpoint collision
- Add safe integer parsing for size parameter in preview endpoint
- Fix path traversal vulnerability in /fonts/preview endpoint
- Fix path traversal vulnerability in /fonts/<family> DELETE endpoint
- Fix XSS vulnerability in fonts.html by using DOM APIs instead of innerHTML
- Move baseUrl to shared scope to fix ReferenceError in multiple functions

Security improvements:
- Validate font filenames reject path separators and '..'
- Validate paths are within fonts_dir before file operations
- Use textContent and data attributes instead of inline onclick handlers
- Restrict file extensions to known font types

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(fonts): address code issues and XSS vulnerabilities

- Move `import re` to module level, remove inline imports
- Remove duplicate font_file assignment in upload_font()
- Remove redundant validation with inconsistent allowed extensions
- Remove redundant PathLib import, use already-imported Path
- Fix XSS vulnerabilities in fonts.html by using DOM APIs instead of
  innerHTML with template literals for user-controlled data

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(fonts): add size limits to font preview endpoint

Add input validation to prevent DoS via large image generation:
- MAX_TEXT_CHARS (100): Limit text input length
- MAX_TEXT_LINES (3): Limit number of newlines
- MAX_DIM (1024): Limit max width/height
- MAX_PIXELS (500000): Limit total pixel count

Validates text early before processing and checks computed
dimensions after bbox calculation but before image allocation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(fonts): improve error handling, catalog keys, and BDF preview

- Add structured logging for cache invalidation failures instead of
  silent pass (FontUpload, FontDelete, FontCatalog contexts)
- Use filename as unique catalog key to prevent collisions when
  multiple font files share the same family_name from metadata
- Return explicit error for BDF font preview instead of showing
  misleading preview with default font

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(fonts): address nitpick issues in font management

Frontend (fonts.html):
- Remove unused escapeHtml function (dead code)
- Add max-attempts guard (50 retries) to initialization loop
- Add response.ok checks before JSON parsing in deleteFont,
  addFontOverride, deleteFontOverride, uploadSelectedFonts
- Use is_system flag from API instead of hardcoded client-side list

Backend (api_v3.py):
- Move SYSTEM_FONTS to module-level frozenset for single source of truth
- Add is_system flag to font catalog entries
- Simplify delete_font system font check using frozenset lookup

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(fonts): align frontend upload validation with backend

- Add .otf to accepted file extensions (HTML accept attribute, JS filter)
- Update validation regex to allow hyphens (matching backend)
- Preserve hyphens in auto-generated font family names
- Update UI text to reflect all supported formats

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(fonts): fix lint errors and missing variable

- Remove unused exception binding in set_cached except block
- Define font_family_lower before case-insensitive fallback loop
- Add response.ok check to font preview fetch (consistent with other handlers)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(fonts): address nitpick code quality issues

- Add return type hints to get_font_preview and delete_font endpoints
- Catch specific PIL exceptions (IOError/OSError) when loading fonts
- Replace innerHTML with DOM APIs for trash icon (consistency)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(fonts): remove unused exception bindings in cache-clearing blocks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-02-11 18:21:27 -05:00
committed by GitHub
parent b99be88cec
commit 448a15c1e6
30 changed files with 1051336 additions and 95 deletions

View File

@@ -1,6 +1,7 @@
from flask import Blueprint, request, jsonify, Response, send_from_directory
import json
import os
import re
import sys
import subprocess
import time
@@ -37,6 +38,20 @@ operation_history = None
# Get project root directory (web_interface/../..)
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
# System fonts that cannot be deleted (used by catalog API and delete endpoint)
SYSTEM_FONTS = frozenset([
'pressstart2p-regular', 'pressstart2p',
'4x6-font', '4x6',
'5by7.regular', '5by7', '5x7',
'5x8', '6x9', '6x10', '6x12', '6x13', '6x13b', '6x13o',
'7x13', '7x13b', '7x13o', '7x14', '7x14b',
'8x13', '8x13b', '8x13o',
'9x15', '9x15b', '9x18', '9x18b',
'10x20',
'matrixchunky8', 'matrixlight6', 'tom-thumb',
'clr6x12', 'helvr12', 'texgyre-27'
])
api_v3 = Blueprint('api_v3', __name__)
def _ensure_cache_manager():
@@ -5388,9 +5403,31 @@ def get_fonts_catalog():
# Store relative path from project root
relative_path = str(filepath.relative_to(PROJECT_ROOT))
catalog[family_name] = {
font_type = 'ttf' if filename.endswith('.ttf') else 'otf' if filename.endswith('.otf') else 'bdf'
# Generate human-readable display name from family_name
display_name = family_name.replace('-', ' ').replace('_', ' ')
# Add space before capital letters for camelCase names
display_name = re.sub(r'([a-z])([A-Z])', r'\1 \2', display_name)
# Add space before numbers that follow letters
display_name = re.sub(r'([a-zA-Z])(\d)', r'\1 \2', display_name)
# Clean up multiple spaces
display_name = ' '.join(display_name.split())
# Use filename (without extension) as unique key to avoid collisions
# when multiple files share the same family_name from font metadata
catalog_key = os.path.splitext(filename)[0]
# Check if this is a system font (cannot be deleted)
is_system = catalog_key.lower() in SYSTEM_FONTS
catalog[catalog_key] = {
'filename': filename,
'family_name': family_name,
'display_name': display_name,
'path': relative_path,
'type': 'ttf' if filename.endswith('.ttf') else 'otf' if filename.endswith('.otf') else 'bdf',
'type': font_type,
'is_system': is_system,
'metadata': metadata if metadata else None
}
@@ -5399,7 +5436,7 @@ def get_fonts_catalog():
try:
set_cached('fonts_catalog', catalog, ttl_seconds=300)
except Exception:
pass # Cache write failed, but continue
logger.error("[FontCatalog] Failed to cache fonts_catalog", exc_info=True)
return jsonify({'status': 'success', 'data': {'catalog': catalog}})
except Exception as e:
@@ -5476,28 +5513,282 @@ def upload_font():
if not is_valid:
return jsonify({'status': 'error', 'message': error_msg}), 400
font_file = request.files['font_file']
font_family = request.form.get('font_family', '')
if not font_file or not font_family:
if not font_family:
return jsonify({'status': 'error', 'message': 'Font file and family name required'}), 400
# Validate file type
allowed_extensions = ['.ttf', '.bdf']
file_extension = font_file.filename.lower().split('.')[-1]
if f'.{file_extension}' not in allowed_extensions:
return jsonify({'status': 'error', 'message': 'Only .ttf and .bdf files are allowed'}), 400
# Validate font family name
if not font_family.replace('_', '').replace('-', '').isalnum():
return jsonify({'status': 'error', 'message': 'Font family name must contain only letters, numbers, underscores, and hyphens'}), 400
# This would integrate with the actual font system to save the file
# For now, just return success
return jsonify({'status': 'success', 'message': f'Font {font_family} uploaded successfully', 'font_family': font_family})
# Save the font file to assets/fonts directory
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
fonts_dir.mkdir(parents=True, exist_ok=True)
# Create filename from family name
original_ext = os.path.splitext(font_file.filename)[1].lower()
safe_filename = f"{font_family}{original_ext}"
filepath = fonts_dir / safe_filename
# Check if file already exists
if filepath.exists():
return jsonify({'status': 'error', 'message': f'Font with name {font_family} already exists'}), 400
# Save the file
font_file.save(str(filepath))
# Clear font catalog cache
try:
from web_interface.cache import delete_cached
delete_cached('fonts_catalog')
except ImportError as e:
logger.warning("[FontUpload] Cache module not available: %s", e)
except Exception:
logger.error("[FontUpload] Failed to clear fonts_catalog cache", exc_info=True)
return jsonify({
'status': 'success',
'message': f'Font {font_family} uploaded successfully',
'font_family': font_family,
'filename': safe_filename,
'path': f'assets/fonts/{safe_filename}'
})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 500
@api_v3.route('/fonts/preview', methods=['GET'])
def get_font_preview() -> tuple[Response, int] | Response:
"""Generate a preview image of text rendered with a specific font"""
try:
from PIL import Image, ImageDraw, ImageFont
import io
import base64
# Limits to prevent DoS via large image generation on constrained devices
MAX_TEXT_CHARS = 100
MAX_TEXT_LINES = 3
MAX_DIM = 1024 # Max width or height in pixels
MAX_PIXELS = 500000 # Max total pixels (e.g., ~700x700)
font_filename = request.args.get('font', '')
text = request.args.get('text', 'Sample Text 123')
bg_color = request.args.get('bg', '000000')
fg_color = request.args.get('fg', 'ffffff')
# Validate text length and line count early
if len(text) > MAX_TEXT_CHARS:
return jsonify({'status': 'error', 'message': f'Text exceeds maximum length of {MAX_TEXT_CHARS} characters'}), 400
if text.count('\n') >= MAX_TEXT_LINES:
return jsonify({'status': 'error', 'message': f'Text exceeds maximum of {MAX_TEXT_LINES} lines'}), 400
# Safe integer parsing for size
try:
size = int(request.args.get('size', 12))
except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': 'Invalid font size'}), 400
if not font_filename:
return jsonify({'status': 'error', 'message': 'Font filename required'}), 400
# Validate size
if size < 4 or size > 72:
return jsonify({'status': 'error', 'message': 'Font size must be between 4 and 72'}), 400
# Security: Validate font_filename to prevent path traversal
# Only allow alphanumeric, hyphen, underscore, and dot (for extension)
safe_name = Path(font_filename).name # Strip any directory components
if safe_name != font_filename or '..' in font_filename:
return jsonify({'status': 'error', 'message': 'Invalid font filename'}), 400
# Validate extension
allowed_extensions = ['.ttf', '.otf', '.bdf']
has_valid_ext = any(safe_name.lower().endswith(ext) for ext in allowed_extensions)
name_without_ext = safe_name.rsplit('.', 1)[0] if '.' in safe_name else safe_name
# Find the font file
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
if not fonts_dir.exists():
return jsonify({'status': 'error', 'message': 'Fonts directory not found'}), 404
font_path = fonts_dir / safe_name
if not font_path.exists() and not has_valid_ext:
# Try finding by family name (without extension)
for ext in allowed_extensions:
potential_path = fonts_dir / f"{name_without_ext}{ext}"
if potential_path.exists():
font_path = potential_path
break
# Final security check: ensure path is within fonts_dir
try:
font_path.resolve().relative_to(fonts_dir.resolve())
except ValueError:
return jsonify({'status': 'error', 'message': 'Invalid font path'}), 400
if not font_path.exists():
return jsonify({'status': 'error', 'message': f'Font file not found: {font_filename}'}), 404
# Parse colors
try:
bg_rgb = tuple(int(bg_color[i:i+2], 16) for i in (0, 2, 4))
fg_rgb = tuple(int(fg_color[i:i+2], 16) for i in (0, 2, 4))
except (ValueError, IndexError):
bg_rgb = (0, 0, 0)
fg_rgb = (255, 255, 255)
# Load font
font = None
if str(font_path).endswith('.bdf'):
# BDF fonts require complex per-glyph rendering via freetype
# Return explicit error rather than showing misleading preview with default font
return jsonify({
'status': 'error',
'message': 'BDF font preview not supported. BDF fonts will render correctly on the LED matrix.'
}), 400
else:
# TTF/OTF fonts
try:
font = ImageFont.truetype(str(font_path), size)
except (IOError, OSError) as e:
# IOError/OSError raised for invalid/corrupt font files
logger.warning("[FontPreview] Failed to load font %s: %s", font_path, e)
font = ImageFont.load_default()
# Calculate text size
temp_img = Image.new('RGB', (1, 1))
temp_draw = ImageDraw.Draw(temp_img)
bbox = temp_draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
# Create image with padding
padding = 10
img_width = max(text_width + padding * 2, 100)
img_height = max(text_height + padding * 2, 30)
# Validate resulting image size to prevent memory/CPU spikes
if img_width > MAX_DIM or img_height > MAX_DIM:
return jsonify({'status': 'error', 'message': 'Requested image too large'}), 400
if img_width * img_height > MAX_PIXELS:
return jsonify({'status': 'error', 'message': 'Requested image too large'}), 400
img = Image.new('RGB', (img_width, img_height), bg_rgb)
draw = ImageDraw.Draw(img)
# Center text
x = (img_width - text_width) // 2
y = (img_height - text_height) // 2
draw.text((x, y), text, font=font, fill=fg_rgb)
# Convert to base64
buffer = io.BytesIO()
img.save(buffer, format='PNG')
buffer.seek(0)
img_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
return jsonify({
'status': 'success',
'data': {
'image': f'data:image/png;base64,{img_base64}',
'width': img_width,
'height': img_height
}
})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 500
@api_v3.route('/fonts/<font_family>', methods=['DELETE'])
def delete_font(font_family: str) -> tuple[Response, int] | Response:
"""Delete a user-uploaded font file"""
try:
# Security: Validate font_family to prevent path traversal
# Reject if it contains path separators or ..
if '..' in font_family or '/' in font_family or '\\' in font_family:
return jsonify({'status': 'error', 'message': 'Invalid font family name'}), 400
# Only allow safe characters: alphanumeric, hyphen, underscore, dot
if not re.match(r'^[a-zA-Z0-9_\-\.]+$', font_family):
return jsonify({'status': 'error', 'message': 'Invalid font family name'}), 400
# Check if this is a system font (uses module-level SYSTEM_FONTS frozenset)
if font_family.lower() in SYSTEM_FONTS:
return jsonify({'status': 'error', 'message': 'Cannot delete system fonts'}), 403
# Find and delete the font file
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
# Ensure fonts directory exists
if not fonts_dir.exists() or not fonts_dir.is_dir():
return jsonify({'status': 'error', 'message': 'Fonts directory not found'}), 404
deleted = False
deleted_filename = None
# Only try valid font extensions (no empty string to avoid matching directories)
for ext in ['.ttf', '.otf', '.bdf']:
potential_path = fonts_dir / f"{font_family}{ext}"
# Security: Verify path is within fonts_dir
try:
potential_path.resolve().relative_to(fonts_dir.resolve())
except ValueError:
continue # Path escapes fonts_dir, skip
if potential_path.exists() and potential_path.is_file():
potential_path.unlink()
deleted = True
deleted_filename = f"{font_family}{ext}"
break
if not deleted:
# Try case-insensitive match within fonts directory
font_family_lower = font_family.lower()
for filename in os.listdir(fonts_dir):
# Only consider files with valid font extensions
if not any(filename.lower().endswith(ext) for ext in ['.ttf', '.otf', '.bdf']):
continue
name_without_ext = os.path.splitext(filename)[0]
if name_without_ext.lower() == font_family_lower:
filepath = fonts_dir / filename
# Security: Verify path is within fonts_dir
try:
filepath.resolve().relative_to(fonts_dir.resolve())
except ValueError:
continue # Path escapes fonts_dir, skip
if filepath.is_file():
filepath.unlink()
deleted = True
deleted_filename = filename
break
if not deleted:
return jsonify({'status': 'error', 'message': f'Font not found: {font_family}'}), 404
# Clear font catalog cache
try:
from web_interface.cache import delete_cached
delete_cached('fonts_catalog')
except ImportError as e:
logger.warning("[FontDelete] Cache module not available: %s", e)
except Exception:
logger.error("[FontDelete] Failed to clear fonts_catalog cache", exc_info=True)
return jsonify({
'status': 'success',
'message': f'Font {deleted_filename} deleted successfully'
})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 500
@api_v3.route('/plugins/assets/upload', methods=['POST'])
def upload_plugin_asset():
"""Upload asset files for a plugin"""
@@ -6044,15 +6335,6 @@ def list_plugin_assets():
import traceback
return jsonify({'status': 'error', 'message': str(e), 'traceback': traceback.format_exc()}), 500
@api_v3.route('/fonts/delete/<font_family>', methods=['DELETE'])
def delete_font(font_family):
"""Delete font"""
try:
# This would integrate with the actual font system
return jsonify({'status': 'success', 'message': f'Font {font_family} deleted'})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)}), 500
@api_v3.route('/logs', methods=['GET'])
def get_logs():
"""Get system logs from journalctl"""

View File

@@ -0,0 +1,298 @@
/**
* LEDMatrix Font Selector Widget
*
* Dynamic font selector that fetches available fonts from the API.
* Automatically shows all fonts in assets/fonts/ directory.
*
* Schema example:
* {
* "font": {
* "type": "string",
* "title": "Font Family",
* "x-widget": "font-selector",
* "x-options": {
* "placeholder": "Select a font...",
* "showPreview": false,
* "filterTypes": ["ttf", "bdf"]
* },
* "default": "PressStart2P-Regular.ttf"
* }
* }
*
* @module FontSelectorWidget
*/
(function() {
'use strict';
const base = window.BaseWidget ? new window.BaseWidget('FontSelector', '1.0.0') : null;
// Cache for font catalog to avoid repeated API calls
let fontCatalogCache = null;
let fontCatalogPromise = null;
function escapeHtml(text) {
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
}
function triggerChange(fieldId, value) {
if (base) {
base.triggerChange(fieldId, value);
} else {
const event = new CustomEvent('widget-change', {
detail: { fieldId, value },
bubbles: true,
cancelable: true
});
document.dispatchEvent(event);
}
}
/**
* Generate a human-readable display name from font filename
* @param {string} filename - Font filename (e.g., "PressStart2P-Regular.ttf")
* @returns {string} Display name (e.g., "Press Start 2P Regular")
*/
function generateDisplayName(filename) {
if (!filename) return '';
// Remove extension
let name = filename.replace(/\.(ttf|bdf|otf)$/i, '');
// Handle common patterns
// Split on hyphens and underscores
name = name.replace(/[-_]/g, ' ');
// Add space before capital letters (camelCase/PascalCase)
name = name.replace(/([a-z])([A-Z])/g, '$1 $2');
// Add space before numbers that follow letters
name = name.replace(/([a-zA-Z])(\d)/g, '$1 $2');
// Clean up multiple spaces
name = name.replace(/\s+/g, ' ').trim();
return name;
}
/**
* Fetch font catalog from API (with caching)
* @returns {Promise<Array>} Array of font objects
*/
async function fetchFontCatalog() {
// Return cached data if available
if (fontCatalogCache) {
return fontCatalogCache;
}
// Return existing promise if fetch is in progress
if (fontCatalogPromise) {
return fontCatalogPromise;
}
// Fetch from API
fontCatalogPromise = fetch('/api/v3/fonts/catalog')
.then(response => {
if (!response.ok) {
throw new Error(`Failed to fetch font catalog: ${response.status}`);
}
return response.json();
})
.then(data => {
// Handle different response structures
let fonts = [];
if (data.data && data.data.fonts) {
// New format: { data: { fonts: [...] } }
fonts = data.data.fonts;
} else if (data.data && data.data.catalog) {
// Alternative format: { data: { catalog: {...} } }
const catalog = data.data.catalog;
fonts = Object.entries(catalog).map(([family, info]) => ({
filename: info.filename || family,
family: family,
display_name: info.display_name || generateDisplayName(info.filename || family),
path: info.path,
type: info.type || 'unknown'
}));
} else if (Array.isArray(data)) {
// Direct array format
fonts = data;
}
// Sort fonts alphabetically by display name
fonts.sort((a, b) => {
const nameA = (a.display_name || a.filename || '').toLowerCase();
const nameB = (b.display_name || b.filename || '').toLowerCase();
return nameA.localeCompare(nameB);
});
fontCatalogCache = fonts;
fontCatalogPromise = null;
return fonts;
})
.catch(error => {
console.error('[FontSelectorWidget] Error fetching font catalog:', error);
fontCatalogPromise = null;
return [];
});
return fontCatalogPromise;
}
/**
* Clear the font catalog cache (call when fonts are uploaded/deleted)
*/
function clearFontCache() {
fontCatalogCache = null;
fontCatalogPromise = null;
}
// Expose cache clearing function globally
window.clearFontSelectorCache = clearFontCache;
// Guard against missing global registry
if (!window.LEDMatrixWidgets || typeof window.LEDMatrixWidgets.register !== 'function') {
console.error('[FontSelectorWidget] LEDMatrixWidgets registry not available');
return;
}
window.LEDMatrixWidgets.register('font-selector', {
name: 'Font Selector Widget',
version: '1.0.0',
render: async function(container, config, value, options) {
const fieldId = sanitizeId(options.fieldId || container.id || 'font-select');
const xOptions = config['x-options'] || config['x_options'] || {};
const placeholder = xOptions.placeholder || 'Select a font...';
const filterTypes = xOptions.filterTypes || null; // e.g., ['ttf', 'bdf']
const showPreview = xOptions.showPreview === true;
const disabled = xOptions.disabled === true;
const required = xOptions.required === true;
const currentValue = value !== null && value !== undefined ? String(value) : '';
// Show loading state
container.innerHTML = `
<div id="${fieldId}_widget" class="font-selector-widget" data-field-id="${fieldId}">
<select id="${fieldId}_input"
name="${escapeHtml(options.name || fieldId)}"
disabled
class="form-select w-full rounded-md border-gray-300 shadow-sm bg-gray-100 text-black">
<option value="">Loading fonts...</option>
</select>
</div>
`;
try {
// Fetch fonts from API
const fonts = await fetchFontCatalog();
// Filter by type if specified
let filteredFonts = fonts;
if (filterTypes && Array.isArray(filterTypes)) {
filteredFonts = fonts.filter(font => {
const fontType = (font.type || '').toLowerCase();
return filterTypes.some(t => t.toLowerCase() === fontType);
});
}
// Build select HTML
let html = `<div id="${fieldId}_widget" class="font-selector-widget" data-field-id="${fieldId}">`;
html += `
<select id="${fieldId}_input"
name="${escapeHtml(options.name || fieldId)}"
${disabled ? 'disabled' : ''}
${required ? 'required' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('font-selector').onChange('${fieldId}')"
class="form-select w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 ${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'} text-black">
`;
// Placeholder option
if (placeholder && !required) {
html += `<option value="" ${!currentValue ? 'selected' : ''}>${escapeHtml(placeholder)}</option>`;
}
// Font options
for (const font of filteredFonts) {
const fontValue = font.filename || font.family;
const displayName = font.display_name || generateDisplayName(fontValue);
const fontType = font.type ? ` (${font.type.toUpperCase()})` : '';
const isSelected = String(fontValue) === currentValue;
html += `<option value="${escapeHtml(String(fontValue))}" ${isSelected ? 'selected' : ''}>${escapeHtml(displayName)}${escapeHtml(fontType)}</option>`;
}
html += '</select>';
// Optional preview area
if (showPreview) {
html += `
<div id="${fieldId}_preview" class="mt-2 p-2 bg-gray-800 rounded text-white text-center" style="min-height: 30px;">
<span style="font-family: monospace;">Preview</span>
</div>
`;
}
// Error message area
html += `<div id="${fieldId}_error" class="text-sm text-red-600 mt-1 hidden"></div>`;
html += '</div>';
container.innerHTML = html;
} catch (error) {
console.error('[FontSelectorWidget] Error rendering:', error);
container.innerHTML = `
<div id="${fieldId}_widget" class="font-selector-widget" data-field-id="${fieldId}">
<select id="${fieldId}_input"
name="${escapeHtml(options.name || fieldId)}"
class="form-select w-full rounded-md border-gray-300 shadow-sm bg-white text-black">
<option value="${escapeHtml(currentValue)}" selected>${escapeHtml(currentValue || 'Error loading fonts')}</option>
</select>
<div class="text-sm text-red-600 mt-1">Failed to load font list</div>
</div>
`;
}
},
getValue: function(fieldId) {
const safeId = sanitizeId(fieldId);
const input = document.getElementById(`${safeId}_input`);
return input ? input.value : '';
},
setValue: function(fieldId, value) {
const safeId = sanitizeId(fieldId);
const input = document.getElementById(`${safeId}_input`);
if (input) {
input.value = value !== null && value !== undefined ? String(value) : '';
}
},
handlers: {
onChange: function(fieldId) {
const widget = window.LEDMatrixWidgets.get('font-selector');
triggerChange(fieldId, widget.getValue(fieldId));
}
},
// Expose utility functions
utils: {
clearCache: clearFontCache,
fetchCatalog: fetchFontCatalog,
generateDisplayName: generateDisplayName
}
});
console.log('[FontSelectorWidget] Font selector widget registered');
})();

View File

@@ -4908,6 +4908,7 @@
<script src="{{ url_for('static', filename='v3/js/widgets/number-input.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/textarea.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/select-dropdown.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/font-selector.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/toggle-switch.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/radio-group.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/date-picker.js') }}" defer></script>

View File

@@ -28,14 +28,14 @@
<!-- Font Upload -->
<div class="bg-gray-50 rounded-lg p-4 mb-8">
<h3 class="text-md font-medium text-gray-900 mb-4">Upload Custom Fonts</h3>
<p class="text-sm text-gray-600 mb-4">Upload your own TTF or BDF font files to use in your LED matrix display.</p>
<p class="text-sm text-gray-600 mb-4">Upload your own TTF, OTF, or BDF font files to use in your LED matrix display.</p>
<div class="font-upload-area" id="font-upload-area">
<div class="upload-dropzone" id="upload-dropzone">
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 mb-3"></i>
<p class="text-gray-600">Drag and drop font files here, or click to select</p>
<p class="text-sm text-gray-500">Supports .ttf and .bdf files</p>
<input type="file" id="font-file-input" accept=".ttf,.bdf" multiple style="display: none;">
<p class="text-sm text-gray-500">Supports .ttf, .otf, and .bdf files</p>
<input type="file" id="font-file-input" accept=".ttf,.otf,.bdf" multiple style="display: none;">
</div>
</div>
@@ -49,8 +49,8 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Font Family Name</label>
<input type="text" id="upload-font-family" class="form-control" placeholder="e.g., my_custom_font">
<p class="text-sm text-gray-600 mt-1">Custom name for this font (letters, numbers, underscores only)</p>
<input type="text" id="upload-font-family" class="form-control" placeholder="e.g., my-custom-font">
<p class="text-sm text-gray-600 mt-1">Custom name for this font (letters, numbers, underscores, hyphens)</p>
</div>
<div class="flex items-end">
<button type="button" id="upload-fonts-btn" class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2">
@@ -112,9 +112,7 @@
<label class="block text-sm font-medium text-gray-700 mb-1">Font Family</label>
<select id="override-family" class="form-control text-sm">
<option value="">Use default</option>
<option value="press_start">Press Start 2P</option>
<option value="four_by_six">4x6 Font</option>
<option value="matrix_light_6">Matrix Light 6</option>
<!-- Dynamically populated from font catalog -->
</select>
</div>
@@ -152,7 +150,10 @@
<h3 class="text-md font-medium text-gray-900 mb-4">Font Preview</h3>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-2 gap-6">
<div>
<canvas id="font-preview-canvas" width="400" height="100" class="border border-gray-300 bg-black rounded"></canvas>
<div id="font-preview-container" class="border border-gray-300 bg-black rounded p-4 min-h-[100px] flex items-center justify-center">
<img id="font-preview-image" src="" alt="Font preview" class="max-w-full" style="display: none;">
<span id="font-preview-loading" class="text-gray-400">Select a font to preview</span>
</div>
</div>
<div class="space-y-3">
<div>
@@ -163,9 +164,7 @@
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Font Family</label>
<select id="preview-family" class="form-control text-sm">
<option value="press_start">Press Start 2P</option>
<option value="four_by_six">4x6 Font</option>
<option value="matrix_light_6">Matrix Light 6</option>
<!-- Dynamically populated from font catalog -->
</select>
</div>
<div>
@@ -209,27 +208,43 @@
window.fontTokens = window.fontTokens || {};
window.fontOverrides = window.fontOverrides || {};
window.selectedFontFiles = window.selectedFontFiles || [];
// Create references that can be reassigned
var fontCatalog = window.fontCatalog;
var fontTokens = window.fontTokens;
var fontOverrides = window.fontOverrides;
var selectedFontFiles = window.selectedFontFiles;
// Base URL for API calls (shared scope)
var baseUrl = window.location.origin;
// Retry counter for initialization
var initRetryCount = 0;
var MAX_INIT_RETRIES = 50; // 5 seconds max (50 * 100ms)
function initializeFontsTab() {
// Allow re-initialization on each HTMX content swap
// The window._fontsScriptLoaded guard prevents function redeclaration
const detectedEl = document.getElementById('detected-fonts');
const availableEl = document.getElementById('available-fonts');
if (!detectedEl || !availableEl) {
initRetryCount++;
if (initRetryCount >= MAX_INIT_RETRIES) {
console.error('Fonts tab elements not found after max retries, giving up');
return;
}
console.log('Fonts tab elements not found, retrying...', {
detectedFonts: !!detectedEl,
availableFonts: !!availableEl
availableFonts: !!availableEl,
attempt: initRetryCount
});
setTimeout(initializeFontsTab, 100);
return;
}
// Reset retry counter on successful init
initRetryCount = 0;
// showNotification is provided by the notification widget (notification.js)
// Fallback only if widget hasn't loaded yet
@@ -368,7 +383,6 @@ async function loadFontData() {
try {
// Use absolute URLs to ensure they work when loaded via HTMX
const baseUrl = window.location.origin;
const [catalogRes, tokensRes, overridesRes] = await Promise.all([
fetch(`${baseUrl}/api/v3/fonts/catalog`),
fetch(`${baseUrl}/api/v3/fonts/tokens`),
@@ -488,21 +502,146 @@ function updateAvailableFontsDisplay() {
return;
}
const lines = Object.entries(fontCatalog).map(([name, fontInfo]) => {
const fontEntries = Object.entries(fontCatalog).map(([name, fontInfo]) => {
const fontPath = typeof fontInfo === 'string' ? fontInfo : (fontInfo?.path || '');
// Only prefix with "assets/fonts/" if path is a bare filename (no "/" and doesn't start with "assets/")
// If path is absolute (starts with "/") or already has "assets/" prefix, use as-is
const fullPath = (fontPath.startsWith('/') || fontPath.startsWith('assets/'))
? fontPath
: `assets/fonts/${fontPath}`;
return `${name}: ${fullPath}`;
const filename = typeof fontInfo === 'object' ? (fontInfo.filename || name) : name;
const displayName = typeof fontInfo === 'object' ? (fontInfo.display_name || name) : name;
const fontType = typeof fontInfo === 'object' ? (fontInfo.type || '').toUpperCase() : '';
// Use is_system flag from API (single source of truth)
const isSystem = typeof fontInfo === 'object' ? (fontInfo.is_system === true) : false;
return { name, filename, displayName, fontType, fontPath, isSystem };
}).sort((a, b) => a.displayName.localeCompare(b.displayName));
// Build list using DOM APIs to prevent XSS
container.innerHTML = '';
fontEntries.forEach(font => {
const row = document.createElement('div');
row.className = 'flex items-center justify-between py-1 border-b border-gray-700 last:border-0';
const nameSpan = document.createElement('span');
nameSpan.className = 'truncate flex-1';
nameSpan.textContent = font.displayName;
if (font.fontType) {
const typeSpan = document.createElement('span');
typeSpan.className = 'text-gray-500 ml-1';
typeSpan.textContent = `(${font.fontType})`;
nameSpan.appendChild(typeSpan);
}
row.appendChild(nameSpan);
if (font.isSystem) {
const systemBadge = document.createElement('span');
systemBadge.className = 'text-gray-600 text-xs ml-2';
systemBadge.textContent = '[system]';
row.appendChild(systemBadge);
} else {
const deleteBtn = document.createElement('button');
deleteBtn.className = 'text-red-400 hover:text-red-300 text-xs ml-2';
deleteBtn.title = 'Delete font';
deleteBtn.textContent = '[delete]';
deleteBtn.dataset.fontName = font.name;
deleteBtn.addEventListener('click', function() {
deleteFont(this.dataset.fontName);
});
row.appendChild(deleteBtn);
}
container.appendChild(row);
});
container.textContent = lines.join('\n');
}
async function deleteFont(fontFamily) {
if (!confirm(`Are you sure you want to delete the font "${fontFamily}"? This action cannot be undone.`)) {
return;
}
try {
const response = await fetch(`${baseUrl}/api/v3/fonts/${encodeURIComponent(fontFamily)}`, {
method: 'DELETE'
});
if (!response.ok) {
const text = await response.text();
let message;
try {
const errorData = JSON.parse(text);
message = errorData.message || `Server error: ${response.status}`;
} catch {
message = `Server error: ${response.status}`;
}
showNotification(message, 'error');
return;
}
const data = await response.json();
if (data.status === 'success') {
showNotification(data.message || `Font "${fontFamily}" deleted successfully`, 'success');
// Refresh font data and UI
await loadFontData();
populateFontSelects();
// Clear font-selector widget cache if available
if (typeof window.clearFontSelectorCache === 'function') {
window.clearFontSelectorCache();
}
} else {
showNotification(data.message || `Failed to delete font "${fontFamily}"`, 'error');
}
} catch (error) {
console.error('Error deleting font:', error);
showNotification(`Error deleting font: ${error.message}`, 'error');
}
}
function populateFontSelects() {
// This would populate the select options with actual font data
// For now, using placeholder options
// Populate font family dropdowns from catalog
const overrideSelect = document.getElementById('override-family');
const previewSelect = document.getElementById('preview-family');
if (!overrideSelect || !previewSelect) return;
// Get font entries sorted by display name
const fontEntries = Object.entries(fontCatalog).map(([key, info]) => {
const filename = typeof info === 'object' ? (info.filename || key) : key;
const displayName = typeof info === 'object' ? (info.display_name || key) : key;
const fontType = typeof info === 'object' ? (info.type || 'unknown').toUpperCase() : '';
return { key, filename, displayName, fontType };
}).sort((a, b) => a.displayName.localeCompare(b.displayName));
// Build options using DOM APIs to prevent XSS
// Clear and add default option for override select
overrideSelect.innerHTML = '';
const defaultOption = document.createElement('option');
defaultOption.value = '';
defaultOption.textContent = 'Use default';
overrideSelect.appendChild(defaultOption);
// Clear preview select
previewSelect.innerHTML = '';
// Add font options to both selects
fontEntries.forEach(font => {
const typeLabel = font.fontType ? ` (${font.fontType})` : '';
const overrideOpt = document.createElement('option');
overrideOpt.value = font.filename;
overrideOpt.textContent = font.displayName + typeLabel;
overrideSelect.appendChild(overrideOpt);
const previewOpt = document.createElement('option');
previewOpt.value = font.filename;
previewOpt.textContent = font.displayName + typeLabel;
previewSelect.appendChild(previewOpt);
});
// Select first font in preview if available
if (fontEntries.length > 0) {
previewSelect.value = fontEntries[0].filename;
}
console.log(`Populated font selects with ${fontEntries.length} fonts`);
}
async function addFontOverride() {
@@ -536,6 +675,19 @@ async function addFontOverride() {
})
});
if (!response.ok) {
const text = await response.text();
let message;
try {
const errorData = JSON.parse(text);
message = errorData.message || `Server error: ${response.status}`;
} catch {
message = `Server error: ${response.status}`;
}
showNotification('Error adding font override: ' + message, 'error');
return;
}
const data = await response.json();
if (data.status === 'success') {
showNotification('Font override added successfully', 'success');
@@ -564,6 +716,19 @@ async function deleteFontOverride(elementKey) {
method: 'DELETE'
});
if (!response.ok) {
const text = await response.text();
let message;
try {
const errorData = JSON.parse(text);
message = errorData.message || `Server error: ${response.status}`;
} catch {
message = `Server error: ${response.status}`;
}
showNotification('Error removing font override: ' + message, 'error');
return;
}
const data = await response.json();
if (data.status === 'success') {
showNotification('Font override removed successfully', 'success');
@@ -587,7 +752,9 @@ function displayCurrentOverrides() {
return;
}
container.innerHTML = Object.entries(fontOverrides).map(([elementKey, override]) => {
// Build list using DOM APIs to prevent XSS
container.innerHTML = '';
Object.entries(fontOverrides).forEach(([elementKey, override]) => {
const elementName = getElementDisplayName(elementKey);
const settings = [];
@@ -600,18 +767,37 @@ function displayCurrentOverrides() {
settings.push(`Size: ${override.size_px}px`);
}
return `
<div class="flex items-center justify-between p-3 bg-white rounded border">
<div>
<div class="font-medium text-gray-900">${elementName}</div>
<div class="text-sm text-gray-600">${settings.join(', ')}</div>
</div>
<button onclick="deleteFontOverride('${elementKey}')" class="btn bg-red-600 hover:bg-red-700 text-white px-3 py-1 text-sm">
<i class="fas fa-trash mr-1"></i>Remove
</button>
</div>
`;
}).join('');
const row = document.createElement('div');
row.className = 'flex items-center justify-between p-3 bg-white rounded border';
const infoDiv = document.createElement('div');
const nameDiv = document.createElement('div');
nameDiv.className = 'font-medium text-gray-900';
nameDiv.textContent = elementName;
const settingsDiv = document.createElement('div');
settingsDiv.className = 'text-sm text-gray-600';
settingsDiv.textContent = settings.join(', ');
infoDiv.appendChild(nameDiv);
infoDiv.appendChild(settingsDiv);
const deleteBtn = document.createElement('button');
deleteBtn.className = 'btn bg-red-600 hover:bg-red-700 text-white px-3 py-1 text-sm';
const trashIcon = document.createElement('i');
trashIcon.className = 'fas fa-trash mr-1';
deleteBtn.appendChild(trashIcon);
deleteBtn.appendChild(document.createTextNode('Remove'));
deleteBtn.dataset.elementKey = elementKey;
deleteBtn.addEventListener('click', function() {
deleteFontOverride(this.dataset.elementKey);
});
row.appendChild(infoDiv);
row.appendChild(deleteBtn);
container.appendChild(row);
});
}
function getElementDisplayName(elementKey) {
@@ -639,30 +825,75 @@ function getFontDisplayName(fontKey) {
return names[fontKey] || fontKey;
}
function updateFontPreview() {
const canvas = document.getElementById('font-preview-canvas');
const text = document.getElementById('preview-text').value || 'Sample Text';
const family = document.getElementById('preview-family').value;
const size = document.getElementById('preview-size').value;
async function updateFontPreview() {
const previewImage = document.getElementById('font-preview-image');
const loadingText = document.getElementById('font-preview-loading');
const textInput = document.getElementById('preview-text');
const familySelect = document.getElementById('preview-family');
const sizeSelect = document.getElementById('preview-size');
if (!canvas) return;
if (!previewImage || !loadingText) return;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
const text = textInput?.value || 'Sample Text 123';
const family = familySelect?.value || '';
const sizeToken = sizeSelect?.value || 'md';
const sizePx = fontTokens[sizeToken] || 10;
// Set background
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (!family) {
previewImage.style.display = 'none';
loadingText.style.display = 'block';
loadingText.textContent = 'Select a font to preview';
return;
}
// Set font properties
const fontSize = fontTokens[size] || 8;
ctx.fillStyle = '#ffffff';
ctx.font = `${fontSize}px monospace`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// Show loading state
loadingText.textContent = 'Loading preview...';
loadingText.style.display = 'block';
previewImage.style.display = 'none';
// Draw text in center
ctx.fillText(text, canvas.width / 2, canvas.height / 2);
try {
const params = new URLSearchParams({
font: family,
text: text,
size: sizePx,
bg: '000000',
fg: 'ffffff'
});
const response = await fetch(`${baseUrl}/api/v3/fonts/preview?${params}`);
if (!response.ok) {
const text = await response.text();
let message;
try {
const errorData = JSON.parse(text);
message = errorData.message || `Server error: ${response.status}`;
} catch {
message = `Server error: ${response.status}`;
}
loadingText.textContent = message;
loadingText.style.display = 'block';
previewImage.style.display = 'none';
return;
}
const data = await response.json();
if (data.status === 'success' && data.data?.image) {
previewImage.src = data.data.image;
previewImage.style.display = 'block';
loadingText.style.display = 'none';
} else {
loadingText.textContent = data.message || 'Failed to load preview';
loadingText.style.display = 'block';
previewImage.style.display = 'none';
}
} catch (error) {
console.error('Error loading font preview:', error);
loadingText.textContent = 'Error loading preview';
loadingText.style.display = 'block';
previewImage.style.display = 'none';
}
}
function initializeFontUpload() {
@@ -671,13 +902,14 @@ function initializeFontUpload() {
function handleFileSelection(event) {
const files = Array.from(event.target.files);
const validExtensions = ['ttf', 'otf', 'bdf'];
const validFiles = files.filter(file => {
const extension = file.name.toLowerCase().split('.').pop();
return extension === 'ttf' || extension === 'bdf';
return validExtensions.includes(extension);
});
if (validFiles.length === 0) {
showNotification('Please select valid .ttf or .bdf font files', 'warning');
showNotification('Please select valid .ttf, .otf, or .bdf font files', 'warning');
return;
}
@@ -696,18 +928,26 @@ function showUploadForm() {
const selectedFilesContainer = document.getElementById('selected-files');
const fontFamilyInput = document.getElementById('upload-font-family');
// Show selected files
selectedFilesContainer.innerHTML = selectedFontFiles.map(file => `
<div class="flex items-center justify-between p-2 bg-gray-100 rounded">
<span class="text-sm">${file.name} (${(file.size / 1024).toFixed(1)} KB)</span>
</div>
`).join('');
// Show selected files using DOM APIs to prevent XSS
selectedFilesContainer.innerHTML = '';
selectedFontFiles.forEach(file => {
const row = document.createElement('div');
row.className = 'flex items-center justify-between p-2 bg-gray-100 rounded';
const span = document.createElement('span');
span.className = 'text-sm';
span.textContent = `${file.name} (${(file.size / 1024).toFixed(1)} KB)`;
row.appendChild(span);
selectedFilesContainer.appendChild(row);
});
// Auto-generate font family name from first file
if (selectedFontFiles.length === 1) {
const filename = selectedFontFiles[0].name;
const nameWithoutExt = filename.substring(0, filename.lastIndexOf('.'));
fontFamilyInput.value = nameWithoutExt.toLowerCase().replace(/[^a-z0-9]/g, '_');
// Preserve hyphens, convert other special chars to underscores
fontFamilyInput.value = nameWithoutExt.toLowerCase().replace(/[^a-z0-9-]/g, '_');
}
uploadForm.style.display = 'block';
@@ -734,9 +974,9 @@ async function uploadSelectedFonts() {
return;
}
// Validate font family name
if (!/^[a-z0-9_]+$/i.test(fontFamily)) {
showNotification('Font family name can only contain letters, numbers, and underscores', 'warning');
// Validate font family name (must match backend validation)
if (!/^[a-z0-9_-]+$/i.test(fontFamily)) {
showNotification('Font family name can only contain letters, numbers, underscores, and hyphens', 'warning');
return;
}
@@ -755,6 +995,19 @@ async function uploadSelectedFonts() {
body: formData
});
if (!response.ok) {
const text = await response.text();
let message;
try {
const errorData = JSON.parse(text);
message = errorData.message || `Server error: ${response.status}`;
} catch {
message = `Server error: ${response.status}`;
}
showNotification(`Error uploading "${file.name}": ${message}`, 'error');
continue;
}
const data = await response.json();
if (data.status === 'success') {
@@ -773,6 +1026,10 @@ async function uploadSelectedFonts() {
populateFontSelects();
cancelFontUpload();
hideUploadProgress();
// Clear font-selector widget cache so new fonts appear in plugin configs
if (typeof window.clearFontSelectorCache === 'function') {
window.clearFontSelectorCache();
}
} catch (error) {
console.error('Error uploading fonts:', error);
@@ -850,7 +1107,12 @@ function updateUploadProgress(percent) {
border: 1px solid #d1d5db;
}
#font-preview-canvas {
#font-preview-container {
max-width: 100%;
min-height: 100px;
}
#font-preview-image {
max-width: 100%;
height: auto;
}

View File

@@ -537,7 +537,7 @@
{% else %}
{% set str_widget = prop.get('x-widget') or prop.get('x_widget') %}
{% set str_value = value if value is not none else (prop.default if prop.default is defined else '') %}
{% if str_widget in ['text-input', 'textarea', 'select-dropdown', 'toggle-switch', 'radio-group', 'date-picker', 'slider', 'color-picker', 'email-input', 'url-input', 'password-input'] %}
{% if str_widget in ['text-input', 'textarea', 'select-dropdown', 'toggle-switch', 'radio-group', 'date-picker', 'slider', 'color-picker', 'email-input', 'url-input', 'password-input', 'font-selector'] %}
{# Render widget container #}
<div id="{{ field_id }}_container" class="{{ str_widget }}-container"></div>
<script>