#!/usr/bin/env python3 """ 生成字体清单文件(稳定 ID 版本) 扫描 fonts/ 目录下的所有字体文件,同时生成: 1. frontend/public/fonts.json 2. miniprogram/assets/fonts.json 3. miniprogram/assets/fonts.js ID 分配规则: 1. 已存在字体(按 relativePath 识别)保持原 ID 不变。 2. 新增字体按“上次游标”分配新 ID。 3. ID 从 0001 递增到 10000,之后循环分配(跳过当前正在使用的 ID)。 """ import os import json from pathlib import Path from urllib.parse import unquote, urlparse FONT_DIR = Path('fonts') WEB_FONTS_JSON = Path('frontend/public/fonts.json') MP_FONTS_JSON = Path('miniprogram/assets/fonts.json') MP_FONTS_JS = Path('miniprogram/assets/fonts.js') ID_STATE_FILE = Path('scripts/font-id-state.json') ID_MIN = 1 ID_MAX = 10000 def format_font_id(num): return str(num).zfill(4) def parse_font_id(raw): try: value = int(str(raw).strip()) except (TypeError, ValueError): return None if ID_MIN <= value <= ID_MAX: return value return None def increment_id(num): return ID_MIN if num >= ID_MAX else num + 1 def normalize_relative_path(raw_path): if not raw_path: return None text = str(raw_path).strip() if not text: return None parsed = urlparse(text) if parsed.scheme and parsed.netloc: text = parsed.path text = unquote(text) text = text.split('?', 1)[0].split('#', 1)[0] text = text.replace('\\', '/') if '/fonts/' in text: text = text.split('/fonts/', 1)[1] elif text.startswith('fonts/'): text = text[len('fonts/'):] elif text.startswith('/fonts'): text = text[len('/fonts'):] text = text.lstrip('/') return text or None def load_manifest_id_map(manifest_file): mapping = {} if not manifest_file.exists(): return mapping try: with open(manifest_file, 'r', encoding='utf-8') as f: data = json.load(f) except (OSError, json.JSONDecodeError): return mapping if not isinstance(data, list): return mapping for item in data: if not isinstance(item, dict): continue relative_path = normalize_relative_path( item.get('path') or item.get('relativePath') ) id_num = parse_font_id(item.get('id')) if relative_path and id_num: mapping[relative_path] = format_font_id(id_num) return mapping def load_id_state(state_file): default_state = { 'version': 1, 'nextId': ID_MIN, 'pathToId': {}, } if not state_file.exists(): return default_state try: with open(state_file, 'r', encoding='utf-8') as f: data = json.load(f) except (OSError, json.JSONDecodeError): return default_state if not isinstance(data, dict): return default_state path_to_id = {} raw_map = data.get('pathToId') if isinstance(raw_map, dict): for path_key, raw_id in raw_map.items(): relative_path = normalize_relative_path(path_key) id_num = parse_font_id(raw_id) if relative_path and id_num: path_to_id[relative_path] = format_font_id(id_num) next_id = parse_font_id(data.get('nextId')) or ID_MIN return { 'version': 1, 'nextId': next_id, 'pathToId': path_to_id, } def save_id_state(state, state_file): state_file.parent.mkdir(parents=True, exist_ok=True) normalized_map = dict(sorted(state['pathToId'].items(), key=lambda x: x[0])) data = { 'version': 1, 'nextId': state['nextId'], 'pathToId': normalized_map, } with open(state_file, 'w', encoding='utf-8') as f: json.dump(data, f, ensure_ascii=False, indent=2) f.write('\n') def bootstrap_state_mappings(state): # 优先级:state 文件 > 小程序现有清单 > web 现有清单 for manifest_path in (MP_FONTS_JSON, WEB_FONTS_JSON): mapping = load_manifest_id_map(manifest_path) for relative_path, font_id in mapping.items(): state['pathToId'].setdefault(relative_path, font_id) if parse_font_id(state.get('nextId')) is None: state['nextId'] = ID_MIN if state['nextId'] == ID_MIN and state['pathToId']: max_id = max(parse_font_id(v) for v in state['pathToId'].values()) state['nextId'] = increment_id(max_id) def allocate_new_id(start_id, used_ids): candidate = start_id for _ in range(ID_MAX): if candidate not in used_ids: return candidate, increment_id(candidate) candidate = increment_id(candidate) raise RuntimeError('可用 ID 已耗尽(0001-10000 全部被当前字体占用)') def scan_fonts(font_dir='fonts'): """扫描字体目录,返回字体信息列表""" fonts = [] font_dir_path = Path(font_dir) if not font_dir_path.exists(): print(f"字体目录不存在: {font_dir}") return fonts # 递归遍历 fonts 目录(支持多级分类) for font_file in sorted(font_dir_path.rglob('*')): if not font_file.is_file(): continue if font_file.suffix.lower() not in ['.ttf', '.otf']: continue relative_parent = font_file.parent.relative_to(font_dir_path) category_name = str(relative_parent).replace('\\', '/') if category_name == '.': category_name = '未分类' relative_path = font_file.relative_to(font_dir_path).as_posix() # 先生成基础信息,id 在后续统一按序号回填 font_info = { 'id': '', 'name': font_file.stem, 'filename': font_file.name, 'category': category_name, 'relativePath': relative_path, } fonts.append(font_info) # 固定顺序,保证分配冲突时结果稳定 fonts = sorted(fonts, key=lambda x: (x['category'], x['name'], x['filename'], x['relativePath'])) return fonts def assign_stable_ids(fonts): state = load_id_state(ID_STATE_FILE) bootstrap_state_mappings(state) cursor = parse_font_id(state.get('nextId')) or ID_MIN used_ids = set() new_count = 0 for font in fonts: relative_path = font['relativePath'] existing_id = state['pathToId'].get(relative_path) existing_num = parse_font_id(existing_id) if existing_num and existing_num not in used_ids: assigned_num = existing_num else: if existing_num and existing_num in used_ids: print( f"警告: 发现 ID 冲突,路径 {relative_path} 原 ID {existing_id} 将重新分配。" ) assigned_num, cursor = allocate_new_id(cursor, used_ids) state['pathToId'][relative_path] = format_font_id(assigned_num) new_count += 1 used_ids.add(assigned_num) font['id'] = format_font_id(assigned_num) state['nextId'] = cursor save_id_state(state, ID_STATE_FILE) return { 'newCount': new_count, 'nextId': format_font_id(cursor), 'stateFile': str(ID_STATE_FILE), } def build_manifest(fonts, path_prefix): """根据路径前缀构建对外清单""" prefix = f"/{str(path_prefix or '').strip('/')}" if prefix == '/': prefix = '' manifest = [] for font in fonts: manifest.append({ 'id': font['id'], 'name': font['name'], 'filename': font['filename'], 'category': font['category'], 'path': f"{prefix}/{font['relativePath']}", }) return manifest def write_fonts_json(fonts, output_file): """写入字体清单 JSON 文件""" os.makedirs(os.path.dirname(output_file), exist_ok=True) with open(output_file, 'w', encoding='utf-8') as f: json.dump(fonts, f, ensure_ascii=False, indent=2) print(f"字体清单已保存到: {output_file}") def write_fonts_js(fonts, output_file): """写入小程序可 require 的 JS 清单文件""" os.makedirs(os.path.dirname(output_file), exist_ok=True) content = "module.exports = " + json.dumps(fonts, ensure_ascii=False, indent=2) + "\n" with open(output_file, 'w', encoding='utf-8') as f: f.write(content) print(f"字体清单已保存到: {output_file}") def main(): """主函数""" # 扫描字体(唯一来源:仓库根目录 fonts/) fonts = scan_fonts(str(FONT_DIR)) print(f"找到 {len(fonts)} 个字体文件") allocation_result = assign_stable_ids(fonts) print( f"ID 分配完成:新增 {allocation_result['newCount']} 个," f"下次起始 ID {allocation_result['nextId']}," f"状态文件 {allocation_result['stateFile']}" ) # Web 清单:统一指向根目录 fonts web_fonts = build_manifest(fonts, '/fonts') write_fonts_json(web_fonts, str(WEB_FONTS_JSON)) # 小程序清单:同样指向根目录 fonts(与 web 共用一份字体目录) miniprogram_fonts = build_manifest(fonts, '/fonts') write_fonts_json(miniprogram_fonts, str(MP_FONTS_JSON)) write_fonts_js(miniprogram_fonts, str(MP_FONTS_JS)) # 统计信息 categories = {} for font in fonts: category = font['category'] categories[category] = categories.get(category, 0) + 1 print("\n按类别统计:") for category, count in sorted(categories.items()): print(f" {category}: {count} 个字体") if __name__ == '__main__': main()