""" 字体管理模块 负责查找和管理字体文件,支持预置字体、系统字体、ziti目录字体和Google Fonts """ import os import sys import platform from pathlib import Path from typing import Dict, List, Optional, Tuple import json # 尝试导入loguru,如果不可用则使用print作为替代 try: from loguru import logger except ImportError: # Fallback logger that uses print class logger: @staticmethod def info(msg, *args, **kwargs): print(f"[INFO] {msg}", file=sys.stderr) @staticmethod def warning(msg, *args, **kwargs): print(f"[WARN] {msg}", file=sys.stderr) @staticmethod def error(msg, *args, **kwargs): print(f"[ERROR] {msg}", file=sys.stderr) @staticmethod def debug(msg, *args, **kwargs): print(f"[DEBUG] {msg}", file=sys.stderr) class FontManager: """字体管理器""" def __init__(self): # 计算项目根目录(从 python/modules 向上两级) # __file__ 应该是 .../python/modules/font_manager.py current_file = Path(__file__).resolve() python_dir = current_file.parent.parent # python 目录 project_root = python_dir.parent # 项目根目录 self.bundled_fonts_dir = Path("fonts/bundled") self.fonts_metadata_file = self.bundled_fonts_dir / "fonts_metadata.json" # 🔧 修复ziti字体路径:支持ASAR打包环境 # 检查是否在打包环境中运行 app_root = os.environ.get('APP_ROOT', None) if app_root: ziti_path_bundle = Path(app_root) / "resources-bundles" / "ziti" if ziti_path_bundle.exists(): self.ziti_fonts_dir = ziti_path_bundle print(f"[font_manager] ziti bundle dir: {ziti_path_bundle}", file=sys.stderr) else: ziti_path = Path(app_root) / "extra" / "common" / "fonts" / "ziti" if ziti_path.exists(): self.ziti_fonts_dir = ziti_path print(f"[font_manager] ziti dir: {ziti_path}", file=sys.stderr) else: ziti_path_fallback1 = Path(app_root) / "app.asar.unpacked" / "ziti" if ziti_path_fallback1.exists(): self.ziti_fonts_dir = ziti_path_fallback1 print(f"[font_manager] ziti fallback dir: {ziti_path_fallback1}", file=sys.stderr) else: ziti_path_fallback2 = Path(app_root) / "ziti" self.ziti_fonts_dir = ziti_path_fallback2 print(f"[font_manager] ziti fallback path: {ziti_path_fallback2} (exists: {ziti_path_fallback2.exists()})", file=sys.stderr) else: self.ziti_fonts_dir = project_root / "ziti" print(f"[font_manager] dev ziti path: {self.ziti_fonts_dir}", file=sys.stderr) self._bundled_fonts_cache: Optional[Dict] = None self._system_fonts_cache: Optional[List[Dict]] = None self._ziti_fonts_cache: Optional[List[Dict]] = None # 新增:ziti字体缓存 # 诊断日志 logger.debug(f"FontManager 初始化: current_file={current_file}, python_dir={python_dir}, project_root={project_root}") logger.debug(f"ziti_fonts_dir={self.ziti_fonts_dir.absolute()}, exists={self.ziti_fonts_dir.exists()}") def get_bundled_fonts(self) -> List[Dict]: """获取预置字体列表""" if self._bundled_fonts_cache is not None: return self._bundled_fonts_cache.get("fonts", []) if not self.fonts_metadata_file.exists(): logger.warning(f"字体元数据文件不存在: {self.fonts_metadata_file}") return [] try: with open(self.fonts_metadata_file, "r", encoding="utf-8") as f: metadata = json.load(f) self._bundled_fonts_cache = metadata return metadata.get("fonts", []) except Exception as e: logger.error(f"读取字体元数据失败: {e}") return [] def scan_ziti_fonts(self) -> List[Dict]: """扫描ziti目录中的字体文件""" if self._ziti_fonts_cache is not None: return self._ziti_fonts_cache ziti_fonts = [] logger.info(f"正在扫描 ziti 目录: {self.ziti_fonts_dir.absolute()}") logger.info(f" 当前工作目录: {os.getcwd()}") logger.info(f" APP_ROOT环境变量: {os.environ.get('APP_ROOT', 'None')}") if not self.ziti_fonts_dir.exists(): logger.warning(f"❌ ziti目录不存在: {self.ziti_fonts_dir.absolute()}") logger.warning(f" 请检查路径配置和资源文件是否正确打包") return [] else: logger.info(f"✅ ziti目录存在,开始扫描字体文件...") try: # 支持的字体格式 font_extensions = ['.ttf', '.otf', '.ttc', '.woff', '.woff2'] # 扫描ziti目录中的所有字体文件 for font_file in self.ziti_fonts_dir.iterdir(): if font_file.is_file() and font_file.suffix.lower() in font_extensions: # 从文件名提取字体信息 font_name = font_file.stem # 尝试解析字体名称和变体 display_name = font_name family = font_name weight = 400 # 检测常见的字体变体 name_lower = font_name.lower() if 'bold' in name_lower: weight = 700 elif 'semibold' in name_lower: weight = 600 elif 'medium' in name_lower: weight = 500 elif 'light' in name_lower: weight = 300 elif 'thin' in name_lower: weight = 100 elif 'black' in name_lower or 'extrabold' in name_lower: weight = 900 # 清理family名称(移除变体后缀) for variant in ['-Bold', '-SemiBold', '-Medium', '-Light', '-Thin', '-Black', '-ExtraBold', '-Regular']: if family.endswith(variant): family = family[:-len(variant)] break ziti_fonts.append({ 'family': family, 'display_name': display_name, 'path': str(font_file.absolute()), 'source': 'ziti', 'weight': weight, 'category': 'sans-serif', # 默认分类 'languages': ['zh-CN', 'ja', 'en'] # 假设支持中日英 }) logger.info(f"✅ 从ziti目录扫描到 {len(ziti_fonts)} 个字体") if len(ziti_fonts) > 0: logger.debug(f" 扫描到的字体: {', '.join([f['family'] for f in ziti_fonts[:5]])}" + (f" 等({len(ziti_fonts)}个)" if len(ziti_fonts) > 5 else "")) except Exception as e: logger.error(f"❌ 扫描ziti目录字体失败: {e}") import traceback logger.error(f" 追踪: {traceback.format_exc()}") # 去重(按family和weight) seen = set() unique_fonts = [] for font in ziti_fonts: key = (font['family'], font.get('weight', 400)) if key not in seen: seen.add(key) unique_fonts.append(font) self._ziti_fonts_cache = unique_fonts return unique_fonts def scan_system_fonts(self) -> List[Dict]: """Do not scan OS fonts. Runtime must use bundled font package only.""" self._system_fonts_cache = [] return [] def find_font(self, font_family: str, font_weight: int = 400) -> Optional[str]: """Find a font file from bundled metadata or ziti only.""" bundled_font = self._find_bundled_font(font_family, font_weight) if bundled_font: return bundled_font ziti_font = self._find_ziti_font(font_family, font_weight) if ziti_font: return ziti_font return None def _find_bundled_font(self, font_family: str, font_weight: int) -> Optional[str]: """查找预置字体""" if not self.bundled_fonts_dir.exists(): return None bundled_fonts = self.get_bundled_fonts() # 查找匹配的字体 for font_info in bundled_fonts: if font_info.get('family') == font_family: font_dir = self.bundled_fonts_dir / font_info.get('path', '') # 根据font_weight选择变体 variant = self._get_variant_for_weight(font_weight) # 查找字体文件(支持TTF和OTF格式) for ext in [".ttf", ".otf"]: variant_file = font_dir / f"{variant}{ext}" if variant_file.exists(): return str(variant_file) # 如果找不到指定变体,尝试查找Regular for ext in [".ttf", ".otf"]: regular_file = font_dir / f"Regular{ext}" if regular_file.exists(): return str(regular_file) return None def _find_ziti_font(self, font_family: str, font_weight: int) -> Optional[str]: """查找ziti目录字体(支持模糊匹配和名称映射)""" if not font_family: return None ziti_fonts = self.scan_ziti_fonts() if not ziti_fonts: logger.debug(f"ziti目录中没有字体文件,无法查找: {font_family}") return None # 字体名称映射表(与前端保持一致) # 映射:显示名称/别名 -> 文件名(不包含扩展名) font_name_map = { 'Dymon手写体': 'Dymon-ShouXieTi', 'Dymon-ShouXieTi': 'Dymon-ShouXieTi', '猫啃杂糅体': 'MaokenAssortedSans', 'MaokenAssortedSans': 'MaokenAssortedSans', '猫啃杂糅体 Lite': 'MaokenAssortedSans-Lite', 'MaokenAssortedSans-Lite': 'MaokenAssortedSans-Lite', 'Murecho 黑体': 'Murecho-Black', 'Murecho-Black': 'Murecho-Black', 'Murecho 粗体': 'Murecho-Bold', 'Murecho-Bold': 'Murecho-Bold', '墨趣古风体': '墨趣古风体', '平方张亚玲黑方体': '平方张亚玲黑方体', '胡晓波骚包体': '胡晓波骚包体2.0', '胡晓波骚包体2.0': '胡晓波骚包体2.0', } # 标准化字体名称(移除空格、统一大小写) def normalize_name(name: str) -> str: name = name.lower() for ext in ['.ttf', '.otf', '.ttc', '.woff', '.woff2']: while name.endswith(ext): name = name[:-len(ext)] return name.replace(' ', '').replace('-', '').replace('_', '').lower() # 尝试通过映射表转换字体名称 mapped_font_family = font_name_map.get(font_family, font_family) normalized_target = normalize_name(mapped_font_family) logger.debug(f"查找ziti字体: '{font_family}' -> 映射: '{mapped_font_family}' -> 标准化: '{normalized_target}', 权重: {font_weight}") # 查找匹配的字体 best_match = None min_weight_diff = float('inf') for font_info in ziti_fonts: family = font_info.get('family', '') display_name = font_info.get('display_name', '') path = font_info.get('path', '') # 获取文件名(不含扩展名)用于匹配 file_stem = None if path: from pathlib import Path file_stem = Path(path).stem # 多种匹配方式:精确匹配、模糊匹配、文件名匹配 is_match = False match_type = None # 1. 精确匹配 if family == font_family or family == mapped_font_family: is_match = True match_type = f"精确匹配(family={family})" elif display_name == font_family or display_name == mapped_font_family: is_match = True match_type = f"精确匹配(display_name={display_name})" # 2. 模糊匹配(忽略大小写和空格) elif normalize_name(family) == normalized_target: is_match = True match_type = f"模糊匹配(family={family})" elif normalize_name(display_name) == normalized_target: is_match = True match_type = f"模糊匹配(display_name={display_name})" # 3. 文件名匹配 elif file_stem and normalize_name(file_stem) == normalized_target: is_match = True match_type = f"文件名匹配(file_stem={file_stem})" if is_match: font_weight_info = font_info.get('weight', 400) weight_diff = abs(font_weight_info - font_weight) logger.debug(f"找到匹配字体: {match_type}, 路径: {path}, 权重: {font_weight_info}, 权重差: {weight_diff}") # 找到权重最接近的字体 if weight_diff < min_weight_diff: min_weight_diff = weight_diff best_match = path if best_match: logger.info(f"找到ziti字体 '{font_family}': {best_match}") else: logger.warning(f"未找到ziti字体 '{font_family}',已扫描{len(ziti_fonts)}个字体文件") return best_match def _find_system_font(self, font_family: str, font_weight: int) -> Optional[str]: """查找系统字体""" system_fonts = self.scan_system_fonts() # 查找匹配的字体 for font_info in system_fonts: if font_info.get('family') == font_family: font_weight_info = font_info.get('weight', 400) # 如果权重匹配(或接近),返回字体路径 if abs(font_weight_info - font_weight) <= 100: return font_info.get('path') return None def _get_variant_for_weight(self, font_weight: int) -> str: """根据字体权重获取变体名称""" if font_weight >= 700: return "Bold" elif font_weight >= 600: return "SemiBold" elif font_weight >= 500: return "Medium" else: return "Regular" def get_all_available_fonts(self) -> List[Dict]: """获取所有可用字体(合并预置、ziti和系统字体)""" all_fonts = [] # 添加预置字体 bundled_fonts = self.get_bundled_fonts() for font_info in bundled_fonts: all_fonts.append({ 'value': font_info.get('family'), 'label': font_info.get('display_name', font_info.get('family')), 'source': 'bundled', 'category': font_info.get('category', 'sans-serif'), 'languages': font_info.get('languages', []) }) # 添加ziti目录字体 ziti_fonts = self.scan_ziti_fonts() seen_families = {f.get('family') for f in bundled_fonts} for font_info in ziti_fonts: family = font_info.get('family') if family not in seen_families: all_fonts.append({ 'value': family, 'label': font_info.get('display_name', family), 'source': 'ziti', 'category': font_info.get('category', 'sans-serif'), 'languages': font_info.get('languages', []), 'path': font_info.get('path') }) seen_families.add(family) return all_fonts # 全局字体管理器实例 _font_manager: Optional[FontManager] = None def get_font_manager() -> FontManager: """获取全局字体管理器实例""" global _font_manager if _font_manager is None: _font_manager = FontManager() return _font_manager