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