- Extract internal Font Family Name using fontTools to fix Arial fallback issue. - Implement JSON caching for system font mapping to improve startup performance. - Update requirements.txt with fonttools dependency. - Update README.md with new font resolution features. - Update .gitignore to exclude font_cache.json.
291 lines
10 KiB
Python
291 lines
10 KiB
Python
"""
|
|
Font utilities for ComfyUI nodes.
|
|
Provides cross-platform font discovery and management functionality.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import glob
|
|
import re
|
|
import json
|
|
from typing import List, Dict, Optional
|
|
from pathlib import Path
|
|
|
|
try:
|
|
from fontTools import ttLib
|
|
except ImportError:
|
|
ttLib = None
|
|
|
|
CACHE_FILE = Path(__file__).parent / "font_cache.json"
|
|
|
|
class FontManager:
|
|
"""Centralized font discovery and management for ComfyUI nodes."""
|
|
|
|
def __init__(self):
|
|
self._name_to_path: Dict[str, str] = {}
|
|
self._scanned = False
|
|
self._load_cache()
|
|
|
|
def _load_cache(self) -> None:
|
|
"""Load font name mapping from JSON cache if it exists."""
|
|
if CACHE_FILE.exists():
|
|
try:
|
|
with open(CACHE_FILE, "r", encoding="utf-8") as f:
|
|
self._name_to_path = json.load(f)
|
|
if self._name_to_path:
|
|
self._scanned = True
|
|
except Exception as e:
|
|
print(f"Error loading font cache: {e}")
|
|
|
|
def _save_cache(self) -> None:
|
|
"""Save current font mapping to JSON cache."""
|
|
try:
|
|
with open(CACHE_FILE, "w", encoding="utf-8") as f:
|
|
json.dump(self._name_to_path, f, indent=4)
|
|
except Exception as e:
|
|
print(f"Error saving font cache: {e}")
|
|
|
|
def _get_font_family_name(self, font_path: str) -> Optional[str]:
|
|
"""Extract the Font Family name using fontTools."""
|
|
if ttLib is None:
|
|
return None
|
|
|
|
try:
|
|
# Use lazy=True to avoid loading the entire font into memory
|
|
# For .ttc files, we just look at the first font (fontNumber=0)
|
|
font = ttLib.TTFont(font_path, fontNumber=0, lazy=True)
|
|
|
|
name_record = None
|
|
if 'name' in font:
|
|
for record in font['name'].names:
|
|
# Name ID 1 = Font Family
|
|
if record.nameID == 1:
|
|
# Priority: Windows Platform (3) + US English (1033)
|
|
if record.platformID == 3 and record.langID == 1033:
|
|
name_record = record
|
|
break
|
|
# Fallback to any English or the first record found
|
|
elif record.langID == 1033 or name_record is None:
|
|
name_record = record
|
|
|
|
if name_record:
|
|
return name_record.toUnicode()
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
def get_available_fonts(self) -> List[str]:
|
|
"""
|
|
Returns a sorted list of available font names for dropdown menus.
|
|
Caches results for performance.
|
|
"""
|
|
if not self._scanned:
|
|
self._scan_system_fonts()
|
|
|
|
return sorted(self._name_to_path.keys())
|
|
|
|
def get_font_path(self, font_name: str) -> str:
|
|
"""
|
|
Converts a font name to its actual file path.
|
|
Returns the original name if not found in our cache (backward compatibility).
|
|
"""
|
|
if not self._scanned:
|
|
self._scan_system_fonts()
|
|
|
|
return self._name_to_path.get(font_name, font_name)
|
|
|
|
def get_font_variant_path(self, font_name: str, bold: bool = False, italic: bool = False) -> str:
|
|
"""
|
|
Best-effort lookup for bold/italic variants of a selected font.
|
|
|
|
If a matching variant cannot be found, returns the regular font path.
|
|
"""
|
|
if not self._scanned:
|
|
self._scan_system_fonts()
|
|
|
|
base_path = self.get_font_path(font_name)
|
|
if not bold and not italic:
|
|
return base_path
|
|
|
|
if bold and italic:
|
|
return (
|
|
self._find_variant_path(font_name, base_path, bold=True, italic=True)
|
|
or self._find_variant_path(font_name, base_path, bold=True, italic=False)
|
|
or self._find_variant_path(font_name, base_path, bold=False, italic=True)
|
|
or base_path
|
|
)
|
|
|
|
return self._find_variant_path(font_name, base_path, bold=bold, italic=italic) or base_path
|
|
|
|
def _normalize_font_name(self, value: str) -> str:
|
|
return re.sub(r"[^a-z0-9]+", "", (value or "").lower())
|
|
|
|
def _find_variant_path(self, font_name: str, base_path: str, bold: bool = False, italic: bool = False) -> Optional[str]:
|
|
normalized_targets = []
|
|
|
|
if font_name:
|
|
normalized_targets.append(self._normalize_font_name(font_name))
|
|
|
|
if base_path and os.path.exists(base_path):
|
|
base_stem = Path(base_path).stem
|
|
normalized_targets.append(self._normalize_font_name(base_stem))
|
|
|
|
normalized_targets = [t for t in dict.fromkeys(normalized_targets) if t]
|
|
if not normalized_targets:
|
|
return None
|
|
|
|
bold_markers = ["bold", "semibold", "demibold", "extrabold", "black", "heavy"]
|
|
italic_markers = ["italic", "oblique", "slanted", "kursiv"]
|
|
|
|
best_match = None
|
|
best_score = None
|
|
|
|
for candidate_name, candidate_path in self._name_to_path.items():
|
|
normalized_name = self._normalize_font_name(candidate_name)
|
|
if not normalized_name:
|
|
continue
|
|
|
|
if not any(target in normalized_name or normalized_name in target for target in normalized_targets):
|
|
continue
|
|
|
|
if bold and not any(marker in normalized_name for marker in bold_markers):
|
|
continue
|
|
if italic and not any(marker in normalized_name for marker in italic_markers):
|
|
continue
|
|
|
|
score = len(normalized_name)
|
|
if bold and "bold" in normalized_name:
|
|
score -= 10
|
|
if italic and "italic" in normalized_name:
|
|
score -= 10
|
|
for target in normalized_targets:
|
|
if normalized_name == target:
|
|
score -= 2
|
|
elif normalized_name.startswith(target) or target.startswith(normalized_name):
|
|
score -= 1
|
|
|
|
if best_score is None or score < best_score:
|
|
best_score = score
|
|
best_match = candidate_path
|
|
|
|
return best_match
|
|
|
|
def _scan_system_fonts(self) -> None:
|
|
"""Platform-specific font discovery."""
|
|
if self._scanned and self._name_to_path:
|
|
return
|
|
|
|
self._name_to_path = {}
|
|
|
|
if sys.platform == "win32":
|
|
self._scan_windows_fonts()
|
|
elif sys.platform == "darwin": # macOS
|
|
self._scan_macos_fonts()
|
|
else: # Linux and other Unix-like systems
|
|
self._scan_linux_fonts()
|
|
|
|
# Add some common fallback fonts
|
|
self._add_fallback_fonts()
|
|
|
|
# Save results to cache for next time
|
|
self._save_cache()
|
|
self._scanned = True
|
|
|
|
def _scan_windows_fonts(self) -> None:
|
|
"""Scan Windows system fonts directory."""
|
|
try:
|
|
fonts_dir = Path(os.environ.get("WINDIR", "C:\\Windows")) / "Fonts"
|
|
if fonts_dir.exists():
|
|
self._scan_directory(fonts_dir)
|
|
except Exception:
|
|
pass
|
|
|
|
def _scan_macos_fonts(self) -> None:
|
|
"""Scan macOS system fonts directories."""
|
|
font_dirs = [
|
|
Path("/System/Library/Fonts"),
|
|
Path("/Library/Fonts"),
|
|
Path.home() / "Library/Fonts"
|
|
]
|
|
|
|
for fonts_dir in font_dirs:
|
|
if fonts_dir.exists():
|
|
self._scan_directory(fonts_dir)
|
|
|
|
def _scan_linux_fonts(self) -> None:
|
|
"""Scan Linux system fonts directories."""
|
|
font_dirs = [
|
|
Path("/usr/share/fonts"),
|
|
Path("/usr/local/share/fonts"),
|
|
Path.home() / ".fonts",
|
|
Path.home() / ".local/share/fonts"
|
|
]
|
|
|
|
for fonts_dir in font_dirs:
|
|
if fonts_dir.exists():
|
|
self._scan_directory(fonts_dir)
|
|
|
|
def _scan_directory(self, fonts_dir: Path) -> None:
|
|
"""Scan a directory for font files."""
|
|
patterns = ["*.ttf", "*.otf", "*.ttc", "*.woff", "*.woff2"]
|
|
|
|
for pattern in patterns:
|
|
for font_file in fonts_dir.glob(f"**/{pattern}"):
|
|
try:
|
|
# Try to get the internal font family name
|
|
font_path = str(font_file)
|
|
font_name = self._get_font_family_name(font_path)
|
|
|
|
# Fallback to filename stem if metadata extraction fails
|
|
if not font_name:
|
|
font_name = font_file.stem
|
|
|
|
# Only add if we don't already have this name (avoid duplicates)
|
|
if font_name not in self._name_to_path:
|
|
self._name_to_path[font_name] = font_path
|
|
except Exception:
|
|
continue
|
|
|
|
def _add_fallback_fonts(self) -> None:
|
|
"""Add common fallback fonts that might not be detected."""
|
|
fallbacks = {
|
|
"Arial": "Arial",
|
|
"Times New Roman": "Times New Roman",
|
|
"Courier New": "Courier New",
|
|
"Helvetica": "Helvetica",
|
|
"Verdana": "Verdana",
|
|
"Georgia": "Georgia",
|
|
"Trebuchet MS": "Trebuchet MS",
|
|
"Impact": "Impact",
|
|
"Comic Sans MS": "Comic Sans MS",
|
|
"Arial Black": "Arial Black",
|
|
"Tahoma": "Tahoma",
|
|
"Lucida Sans Unicode": "Lucida Sans Unicode",
|
|
"MS Sans Serif": "MS Sans Serif",
|
|
"MS Serif": "MS Serif",
|
|
"Palatino Linotype": "Palatino Linotype",
|
|
"Arial Narrow": "Arial Narrow"
|
|
}
|
|
|
|
for name, path in fallbacks.items():
|
|
if name not in self._name_to_path:
|
|
self._name_to_path[name] = path
|
|
|
|
|
|
# Global instance for easy reuse across nodes
|
|
font_manager = FontManager()
|
|
|
|
|
|
def get_available_fonts() -> List[str]:
|
|
"""Convenience function to get available fonts."""
|
|
return font_manager.get_available_fonts()
|
|
|
|
|
|
def get_font_path(font_name: str) -> str:
|
|
"""Convenience function to get font path from name."""
|
|
return font_manager.get_font_path(font_name)
|
|
|
|
|
|
def get_font_variant_path(font_name: str, bold: bool = False, italic: bool = False) -> str:
|
|
"""Convenience function to get a bold/italic font variant path if available."""
|
|
return font_manager.get_font_variant_path(font_name, bold=bold, italic=italic) |