Files
aibis-dream/Tools/collect_font_characters.py
T

150 lines
4.8 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""收集剧本字符并生成字体字符集文件。
用法:
python Tools/collect_font_characters.py
"""
from __future__ import annotations
import os
import sys
import tempfile
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
SOURCE_RELATIVE_PATH = Path("Assets/Yarn/FP")
TARGET_RELATIVE_PATH = Path("Assets/Font/字符统计.txt")
LINE_WIDTH = 20
EXCLUDED_CHARACTERS = {"\ufeff", "\r", "\n"}
class CharacterCollectionError(RuntimeError):
"""字符统计失败。"""
def find_source_files(source_root: Path) -> tuple[list[Path], list[Path]]:
"""递归查找 Yarn 和 CSV 文件,并返回稳定排序后的路径。"""
if not source_root.is_dir():
raise CharacterCollectionError(f"剧本目录不存在: {source_root}")
yarn_files: list[Path] = []
csv_files: list[Path] = []
for path in source_root.rglob("*"):
if not path.is_file():
continue
suffix = path.suffix.lower()
if suffix == ".yarn":
yarn_files.append(path)
elif suffix == ".csv":
csv_files.append(path)
sort_key = lambda path: path.as_posix()
yarn_files.sort(key=sort_key)
csv_files.sort(key=sort_key)
return yarn_files, csv_files
def read_characters(path: Path) -> set[str]:
"""读取 UTF-8 文本并返回其中可写入字符集文件的字符。"""
try:
text = path.read_text(encoding="utf-8-sig")
except (OSError, UnicodeError) as error:
raise CharacterCollectionError(f"无法读取文件 {path}: {error}") from error
return {character for character in text if character not in EXCLUDED_CHARACTERS}
def collect_characters(
source_root: Path, target_path: Path
) -> tuple[set[str], int, int]:
"""合并全部剧本文件与现有字符统计文件中的字符。"""
yarn_files, csv_files = find_source_files(source_root)
characters: set[str] = set()
for path in [*yarn_files, *csv_files]:
characters.update(read_characters(path))
if target_path.exists():
characters.update(read_characters(target_path))
return characters, len(yarn_files), len(csv_files)
def format_characters(characters: set[str], line_width: int = LINE_WIDTH) -> str:
"""按 Unicode 码点排序,并按固定字符数生成 CRLF 文本。"""
if line_width <= 0:
raise ValueError("line_width 必须大于 0")
ordered = sorted(
character
for character in characters
if character not in EXCLUDED_CHARACTERS
)
lines = [
"".join(ordered[index : index + line_width])
for index in range(0, len(ordered), line_width)
]
return "\r\n".join(lines) + ("\r\n" if lines else "")
def write_atomic_utf8_bom(target_path: Path, content: str) -> None:
"""以 UTF-8 BOM 原子覆盖目标文件,避免失败时留下半截内容。"""
if not target_path.parent.is_dir():
raise CharacterCollectionError(f"输出目录不存在: {target_path.parent}")
temporary_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
mode="wb",
prefix=f".{target_path.name}.",
suffix=".tmp",
dir=target_path.parent,
delete=False,
) as temporary_file:
temporary_path = Path(temporary_file.name)
temporary_file.write(content.encode("utf-8-sig"))
temporary_file.flush()
os.fsync(temporary_file.fileno())
os.replace(temporary_path, target_path)
temporary_path = None
except (OSError, UnicodeError) as error:
raise CharacterCollectionError(f"无法写入文件 {target_path}: {error}") from error
finally:
if temporary_path is not None:
try:
temporary_path.unlink(missing_ok=True)
except OSError:
pass
def generate_character_file(project_root: Path = PROJECT_ROOT) -> tuple[int, int, int]:
"""收集项目字符、覆盖目标文件,并返回 Yarn/CSV/字符数量。"""
source_root = project_root / SOURCE_RELATIVE_PATH
target_path = project_root / TARGET_RELATIVE_PATH
characters, yarn_count, csv_count = collect_characters(source_root, target_path)
write_atomic_utf8_bom(target_path, format_characters(characters))
return yarn_count, csv_count, len(characters)
def main() -> int:
try:
yarn_count, csv_count, character_count = generate_character_file()
except CharacterCollectionError as error:
print(f"[错误] {error}", file=sys.stderr)
return 1
print(f"扫描 Yarn 文件: {yarn_count}")
print(f"扫描 CSV 文件: {csv_count}")
print(f"唯一字符数量: {character_count}")
print(f"已写入: {PROJECT_ROOT / TARGET_RELATIVE_PATH}")
return 0
if __name__ == "__main__":
sys.exit(main())