321 lines
9.4 KiB
Python
321 lines
9.4 KiB
Python
#!/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()
|