Files
WYF-koubo/python/modules/font_manager.py
T
2026-06-19 18:45:55 +08:00

397 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
字体管理模块
负责查找和管理字体文件,支持预置字体、系统字体、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