Files
font2pic/scripts/generate-font-list.py
2026-02-11 17:17:24 +08:00

321 lines
9.4 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.

#!/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()