22ecb67296
Reference lines: - Kept per metric (dict keyed by metric_id) so switching metrics and coming back preserves them, instead of clearing on every switch - Spectrogram lines read/edit/drag in Hz: the renderer installs Hz<->row-index transforms (the heatmap y-axis is a row index), so value, label, the edit dialog, and the new-line default all speak frequency. Curve metrics stay identity. Round-trip verified (1000 Hz -> row -> 1000 Hz) - Line label and drag-readback always in the metric's natural units Controls: - Relative-time abs/rel is now a checkbox toggle, not a dropdown - Removed the font control panel; UI font is locked to M PLUS 1 Code @ 10pt via font_manager.apply_fixed_font (system-default fallback). Deleted font_control_widget.py Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
534 lines
19 KiB
Python
534 lines
19 KiB
Python
"""
|
|
Font management system with CJK fallback support.
|
|
Handles matplotlib and Qt font configuration with licensing-safe approach.
|
|
"""
|
|
|
|
import os
|
|
import logging
|
|
import platform
|
|
from pathlib import Path
|
|
from typing import List, Optional, Dict, Any
|
|
|
|
import matplotlib.pyplot as plt
|
|
import matplotlib.font_manager as fm
|
|
from PyQt5.QtCore import QCoreApplication
|
|
from PyQt5.QtGui import QFontDatabase, QFont
|
|
|
|
|
|
class FontManager:
|
|
"""
|
|
Centralized font management for CJK character support.
|
|
|
|
Provides licensing-safe font fallback by:
|
|
1. Loading fonts from local fonts/ directory (gitignored)
|
|
2. Falling back to system CJK fonts
|
|
3. Gracefully degrading to default fonts
|
|
"""
|
|
|
|
def __init__(self, fonts_dir: str = "fonts"):
|
|
"""
|
|
Initialize font manager.
|
|
|
|
Args:
|
|
fonts_dir: Directory name for custom fonts (relative to project root)
|
|
"""
|
|
self.logger = logging.getLogger(__name__)
|
|
self.fonts_dir = Path(__file__).parent / fonts_dir
|
|
self.loaded_fonts: Dict[str, str] = {}
|
|
self._matplotlib_configured = False
|
|
self._qt_configured = False
|
|
|
|
# CJK font preferences by platform and type
|
|
self.system_font_fallbacks = {
|
|
'Windows': {
|
|
'serif': ['Yu Mincho', 'MS Mincho', '游明朝', 'MS 明朝'],
|
|
'sans-serif': ['Yu Gothic UI', 'Meiryo', 'MS Gothic', '游ゴシック', 'メイリオ', 'MS ゴシック'],
|
|
'monospace': ['MS Gothic', 'MS ゴシック']
|
|
},
|
|
'Darwin': { # macOS
|
|
'serif': ['Hiragino Mincho ProN', 'Yu Mincho', 'Times New Roman'],
|
|
'sans-serif': ['Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Yu Gothic', 'Arial Unicode MS'],
|
|
'monospace': ['Menlo', 'Monaco', 'Courier New']
|
|
},
|
|
'Linux': {
|
|
'serif': ['Noto Serif CJK JP', 'Source Han Serif', 'DejaVu Serif'],
|
|
'sans-serif': ['Noto Sans CJK JP', 'Source Han Sans', 'DejaVu Sans'],
|
|
'monospace': ['Noto Sans Mono CJK JP', 'Source Code Pro', 'DejaVu Sans Mono']
|
|
}
|
|
}
|
|
|
|
def initialize(self) -> bool:
|
|
"""
|
|
Initialize font system for both matplotlib and Qt.
|
|
|
|
Returns:
|
|
bool: True if initialization was successful
|
|
"""
|
|
try:
|
|
self.logger.info("Initializing font management system...")
|
|
|
|
# Load custom fonts if available
|
|
custom_fonts_loaded = self._load_custom_fonts()
|
|
|
|
# Configure matplotlib
|
|
matplotlib_success = self._configure_matplotlib()
|
|
|
|
# Configure Qt
|
|
qt_success = self._configure_qt()
|
|
|
|
success = matplotlib_success and qt_success
|
|
|
|
if success:
|
|
self.logger.info(f"Font system initialized successfully. Custom fonts: {custom_fonts_loaded}")
|
|
else:
|
|
self.logger.warning("Font system initialized with some issues")
|
|
|
|
return success
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Font system initialization failed: {e}")
|
|
return False
|
|
|
|
def _load_custom_fonts(self) -> int:
|
|
"""
|
|
Load fonts from the fonts/ directory if it exists.
|
|
|
|
Returns:
|
|
int: Number of custom fonts loaded
|
|
"""
|
|
if not self.fonts_dir.exists():
|
|
self.logger.info(f"Custom fonts directory {self.fonts_dir} not found - using system fonts")
|
|
return 0
|
|
|
|
font_extensions = {'.ttf', '.otf', '.ttc'}
|
|
fonts_loaded = 0
|
|
|
|
try:
|
|
for font_file in self.fonts_dir.iterdir():
|
|
if font_file.suffix.lower() in font_extensions:
|
|
try:
|
|
# Load for matplotlib
|
|
fm.fontManager.addfont(str(font_file))
|
|
|
|
# Load for Qt
|
|
font_id = QFontDatabase.addApplicationFont(str(font_file))
|
|
if font_id != -1:
|
|
font_families = QFontDatabase.applicationFontFamilies(font_id)
|
|
for family in font_families:
|
|
self.loaded_fonts[family] = str(font_file)
|
|
|
|
fonts_loaded += 1
|
|
self.logger.debug(f"Loaded custom font: {font_file.name} -> {font_families}")
|
|
else:
|
|
self.logger.warning(f"Failed to load font for Qt: {font_file.name}")
|
|
|
|
except Exception as e:
|
|
self.logger.warning(f"Failed to load custom font {font_file.name}: {e}")
|
|
|
|
if fonts_loaded > 0:
|
|
# Clear matplotlib's font cache to recognize new fonts
|
|
try:
|
|
# Try the common method for refreshing font cache
|
|
if hasattr(fm.fontManager, '_load_fontmanager'):
|
|
fm.fontManager._load_fontmanager(try_read_cache=False)
|
|
else:
|
|
# For newer matplotlib versions, just reinitialize
|
|
fm.fontManager.__init__()
|
|
except Exception as font_cache_error:
|
|
self.logger.debug(f"Font cache refresh failed (non-critical): {font_cache_error}")
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error loading custom fonts: {e}")
|
|
|
|
return fonts_loaded
|
|
|
|
def _configure_matplotlib(self) -> bool:
|
|
"""Configure matplotlib with appropriate CJK font fallbacks."""
|
|
try:
|
|
current_system = platform.system()
|
|
fallback_fonts = self.system_font_fallbacks.get(current_system,
|
|
self.system_font_fallbacks['Linux'])
|
|
|
|
# Build font list: custom fonts + system fallbacks + matplotlib defaults
|
|
font_list = []
|
|
|
|
# Add custom fonts first (highest priority)
|
|
font_list.extend(self.loaded_fonts.keys())
|
|
|
|
# Add system CJK fonts
|
|
font_list.extend(fallback_fonts['sans-serif'])
|
|
|
|
# Add matplotlib defaults as final fallback
|
|
font_list.extend(['DejaVu Sans', 'Arial', 'sans-serif'])
|
|
|
|
# Update matplotlib configuration
|
|
plt.rcParams['font.sans-serif'] = font_list
|
|
plt.rcParams['font.family'] = 'sans-serif'
|
|
|
|
# Ensure matplotlib can handle Unicode
|
|
plt.rcParams['axes.unicode_minus'] = False
|
|
|
|
self.logger.info(f"Matplotlib configured with font list: {font_list[:3]}...")
|
|
self._matplotlib_configured = True
|
|
return True
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Matplotlib font configuration failed: {e}")
|
|
return False
|
|
|
|
def _configure_qt(self) -> bool:
|
|
"""Configure Qt application with appropriate CJK fonts."""
|
|
try:
|
|
app = QCoreApplication.instance()
|
|
if not app:
|
|
self.logger.warning("No Qt application instance found - Qt font configuration skipped")
|
|
return True
|
|
|
|
# Get best available CJK font
|
|
cjk_font = self._get_best_cjk_font()
|
|
|
|
if cjk_font:
|
|
# Set application-wide font
|
|
font = QFont(cjk_font)
|
|
app.setFont(font)
|
|
self.logger.info(f"Qt configured with CJK font: {cjk_font}")
|
|
else:
|
|
self.logger.info("Qt using default system font (no specific CJK font found)")
|
|
|
|
self._qt_configured = True
|
|
return True
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Qt font configuration failed: {e}")
|
|
return False
|
|
|
|
def _get_best_cjk_font(self) -> Optional[str]:
|
|
"""
|
|
Find the best available CJK font for the current system.
|
|
|
|
Returns:
|
|
str or None: Name of best available CJK font
|
|
"""
|
|
# Check custom fonts first
|
|
for font_name in self.loaded_fonts.keys():
|
|
if self._is_cjk_capable(font_name):
|
|
return font_name
|
|
|
|
# Check system fonts
|
|
current_system = platform.system()
|
|
system_fonts = self.system_font_fallbacks.get(current_system,
|
|
self.system_font_fallbacks['Linux'])
|
|
|
|
available_fonts = set(fm.get_font_names())
|
|
for font_name in system_fonts['sans-serif']:
|
|
if font_name in available_fonts:
|
|
return font_name
|
|
|
|
return None
|
|
|
|
def _is_cjk_capable(self, font_name: str) -> bool:
|
|
"""
|
|
Check if a font supports CJK characters.
|
|
Simple heuristic based on font name.
|
|
"""
|
|
cjk_indicators = [
|
|
'cjk', 'japanese', 'chinese', 'korean', 'han', 'noto', 'yu',
|
|
'hiragino', 'meiryo', 'gothic', 'mincho', '游', 'メイリオ',
|
|
'ゴシック', '明朝'
|
|
]
|
|
font_lower = font_name.lower()
|
|
return any(indicator in font_lower for indicator in cjk_indicators)
|
|
|
|
def get_cjk_safe_title(self, title: str, fallback_encoding: str = 'utf-8') -> str:
|
|
"""
|
|
Ensure title string is safe for display with current font configuration.
|
|
|
|
Args:
|
|
title: Original title string
|
|
fallback_encoding: Encoding to use for problematic characters
|
|
|
|
Returns:
|
|
str: Safe title string for display
|
|
"""
|
|
if not title:
|
|
return title
|
|
|
|
try:
|
|
# Test if the string can be encoded/decoded properly
|
|
title.encode(fallback_encoding).decode(fallback_encoding)
|
|
return title
|
|
except UnicodeError:
|
|
# If there are encoding issues, create a safe fallback
|
|
safe_title = title.encode(fallback_encoding, errors='replace').decode(fallback_encoding)
|
|
self.logger.debug(f"Title encoding adjusted: {title[:50]}... -> {safe_title[:50]}...")
|
|
return safe_title
|
|
|
|
def create_fonts_directory_if_needed(self) -> Path:
|
|
"""
|
|
Create the fonts directory and return its path.
|
|
Useful for setup instructions.
|
|
|
|
Returns:
|
|
Path: Path to the fonts directory
|
|
"""
|
|
self.fonts_dir.mkdir(exist_ok=True)
|
|
return self.fonts_dir
|
|
|
|
def get_font_installation_instructions(self) -> str:
|
|
"""
|
|
Generate user instructions for installing CJK fonts.
|
|
|
|
Returns:
|
|
str: Multi-line instruction string
|
|
"""
|
|
fonts_path = self.create_fonts_directory_if_needed()
|
|
|
|
instructions = f"""CJK Font Installation Instructions:
|
|
|
|
1. Create or use the fonts directory: {fonts_path.absolute()}
|
|
|
|
2. Download CJK fonts (legally) from sources like:
|
|
- Google Fonts (Noto Sans CJK, free & open source)
|
|
- Adobe Source Han fonts (free & open source)
|
|
- System fonts from your OS (if redistribution is allowed)
|
|
|
|
3. Place .ttf, .otf, or .ttc font files in the fonts/ directory
|
|
|
|
4. Restart the application to load the new fonts
|
|
|
|
Note: The fonts/ directory is gitignored to avoid licensing issues.
|
|
System CJK fonts will be used as fallback if available.
|
|
|
|
Current system: {platform.system()}
|
|
Recommended fonts: {', '.join(self.system_font_fallbacks.get(platform.system(), {}).get('sans-serif', ['System default'])[:3])}
|
|
"""
|
|
return instructions
|
|
|
|
def get_available_system_fonts(self) -> List[str]:
|
|
"""
|
|
Get list of available system fonts that are good candidates for selection.
|
|
|
|
Returns:
|
|
List[str]: List of available system font names
|
|
"""
|
|
current_system = platform.system()
|
|
fallback_fonts = self.system_font_fallbacks.get(current_system,
|
|
self.system_font_fallbacks['Linux'])
|
|
|
|
# Combine all font categories
|
|
preferred_fonts = []
|
|
for category in ['sans-serif', 'serif', 'monospace']:
|
|
preferred_fonts.extend(fallback_fonts.get(category, []))
|
|
|
|
# Get actually available fonts on the system
|
|
available_fonts = set(fm.get_font_names())
|
|
|
|
# Filter to only fonts that are actually available
|
|
system_candidates = []
|
|
for font_name in preferred_fonts:
|
|
if font_name in available_fonts:
|
|
system_candidates.append(font_name)
|
|
|
|
# Remove duplicates while preserving order
|
|
seen = set()
|
|
unique_candidates = []
|
|
for font in system_candidates:
|
|
if font not in seen:
|
|
seen.add(font)
|
|
unique_candidates.append(font)
|
|
|
|
return unique_candidates
|
|
|
|
def get_default_system_font_name(self) -> str:
|
|
"""
|
|
Get the name of the default system font for display purposes.
|
|
|
|
Returns:
|
|
str: Human-readable name of the default system font
|
|
"""
|
|
from PyQt5.QtGui import QFont
|
|
from PyQt5.QtCore import QCoreApplication
|
|
|
|
# Try to get the actual default font name from Qt
|
|
app = QCoreApplication.instance()
|
|
if app:
|
|
default_font = QFont()
|
|
return default_font.family()
|
|
|
|
# Fallback to platform-specific defaults
|
|
current_system = platform.system()
|
|
if current_system == 'Windows':
|
|
return 'Segoe UI'
|
|
elif current_system == 'Darwin':
|
|
return 'SF Pro Display'
|
|
else:
|
|
return 'DejaVu Sans'
|
|
|
|
def select_startup_font(self) -> tuple[str, str]:
|
|
"""
|
|
Select the appropriate font for application startup.
|
|
Priority: First custom font alphabetically, then first system font, then default.
|
|
|
|
Returns:
|
|
tuple: (font_name, font_type) where font_type is 'custom', 'system', or 'default'
|
|
"""
|
|
# Check for custom fonts first
|
|
if self.loaded_fonts:
|
|
custom_font_names = sorted(self.loaded_fonts.keys())
|
|
selected_font = custom_font_names[0]
|
|
self.logger.info(f"Startup font selected (custom): {selected_font}")
|
|
return selected_font, 'custom'
|
|
|
|
# Check for system fonts
|
|
system_fonts = self.get_available_system_fonts()
|
|
if system_fonts:
|
|
selected_font = system_fonts[0]
|
|
self.logger.info(f"Startup font selected (system): {selected_font}")
|
|
return selected_font, 'system'
|
|
|
|
# Default fallback
|
|
default_font = self.get_default_system_font_name()
|
|
self.logger.info(f"Startup font selected (default): {default_font}")
|
|
return default_font, 'default'
|
|
|
|
def apply_font_selection(self, font_name: str, font_type: str) -> bool:
|
|
"""
|
|
Apply a font selection to both matplotlib and Qt.
|
|
|
|
Args:
|
|
font_name: Name of the font to apply
|
|
font_type: Type of font ('custom', 'system', or 'default')
|
|
|
|
Returns:
|
|
bool: True if successful
|
|
"""
|
|
try:
|
|
if font_type == 'default':
|
|
# Reset to default configuration
|
|
self._configure_matplotlib()
|
|
self._configure_qt()
|
|
else:
|
|
# Apply specific font
|
|
self._set_specific_font(font_name)
|
|
|
|
self.logger.info(f"Font applied successfully: {font_name} ({font_type})")
|
|
return True
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error applying font selection: {e}")
|
|
return False
|
|
|
|
def _set_specific_font(self, font_name: str):
|
|
"""Set a specific font for both matplotlib and Qt."""
|
|
from PyQt5.QtCore import QCoreApplication
|
|
from PyQt5.QtGui import QFont
|
|
|
|
# Update Qt font
|
|
app = QCoreApplication.instance()
|
|
if app:
|
|
font = QFont(font_name)
|
|
app.setFont(font)
|
|
|
|
# Update matplotlib font (insert at front of font list)
|
|
current_fonts = plt.rcParams['font.sans-serif'].copy()
|
|
if font_name in current_fonts:
|
|
current_fonts.remove(font_name)
|
|
current_fonts.insert(0, font_name)
|
|
plt.rcParams['font.sans-serif'] = current_fonts
|
|
|
|
def get_status_report(self) -> Dict[str, Any]:
|
|
"""
|
|
Generate a status report of the font system.
|
|
|
|
Returns:
|
|
dict: Status information
|
|
"""
|
|
startup_font, startup_type = self.select_startup_font()
|
|
|
|
return {
|
|
'matplotlib_configured': self._matplotlib_configured,
|
|
'qt_configured': self._qt_configured,
|
|
'custom_fonts_loaded': len(self.loaded_fonts),
|
|
'custom_font_families': list(self.loaded_fonts.keys()),
|
|
'available_system_fonts': self.get_available_system_fonts(),
|
|
'default_system_font': self.get_default_system_font_name(),
|
|
'startup_font': startup_font,
|
|
'startup_font_type': startup_type,
|
|
'fonts_directory_exists': self.fonts_dir.exists(),
|
|
'fonts_directory_path': str(self.fonts_dir.absolute()),
|
|
'current_matplotlib_fonts': plt.rcParams.get('font.sans-serif', [])[:5],
|
|
'current_system': platform.system()
|
|
}
|
|
|
|
|
|
# Global font manager instance
|
|
_font_manager: Optional[FontManager] = None
|
|
|
|
|
|
def get_font_manager() -> FontManager:
|
|
"""
|
|
Get the global font manager instance.
|
|
Creates one if it doesn't exist.
|
|
|
|
Returns:
|
|
FontManager: Global font manager instance
|
|
"""
|
|
global _font_manager
|
|
if _font_manager is None:
|
|
_font_manager = FontManager()
|
|
return _font_manager
|
|
|
|
|
|
def initialize_fonts() -> bool:
|
|
"""
|
|
Initialize the global font system.
|
|
Call this early in application startup.
|
|
|
|
Returns:
|
|
bool: True if successful
|
|
"""
|
|
return get_font_manager().initialize()
|
|
|
|
|
|
def safe_title(title: str) -> str:
|
|
"""
|
|
Convenience function to get CJK-safe title.
|
|
|
|
Args:
|
|
title: Original title
|
|
|
|
Returns:
|
|
str: Safe title for display
|
|
"""
|
|
return get_font_manager().get_cjk_safe_title(title)
|
|
|
|
|
|
def apply_fixed_font(family: str = "M PLUS 1 Code", size: int = 10) -> str:
|
|
"""Lock the Qt application font to `family` at `size`pt.
|
|
|
|
Falls back to the system default family if `family` isn't available (loaded
|
|
from fonts/ or installed). pyqtgraph and the Qt widgets both read the app
|
|
font, so this is all the plot/UI need. Returns the family actually used.
|
|
"""
|
|
logger = logging.getLogger(__name__)
|
|
app = QCoreApplication.instance()
|
|
if app is None:
|
|
logger.warning("apply_fixed_font called before QApplication exists")
|
|
return family
|
|
|
|
try:
|
|
available = family in set(QFontDatabase().families())
|
|
except Exception:
|
|
available = False
|
|
|
|
if available:
|
|
font = QFont(family)
|
|
chosen = family
|
|
else:
|
|
font = QFont() # system default family
|
|
chosen = font.defaultFamily()
|
|
logger.info(f"Font '{family}' not found; using system default '{chosen}'")
|
|
font.setPointSize(size)
|
|
app.setFont(font)
|
|
logger.info(f"Application font locked to '{chosen}' at {size}pt")
|
|
return chosen |