загружаю

This commit is contained in:
2026-09-07 19:07:40 +03:00
commit 1485819556
16 changed files with 1526 additions and 0 deletions
+256
View File
@@ -0,0 +1,256 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
r"""
Универсальный скрипт для локализации CatSystem2 (Shift-JIS).
2 в 1: Извлечение в .nsv и обратная сборка в .txt с поддержкой поля translated_text.
Поддерживает аргументы: --txt2nsv и --nsv2txt
Формат хранения — NSV (Newline-Separated Values, см. nsv.py) вместо JSON:
компактнее на диске, лучше диффы в git. LLM (в translate.py) никогда не видит
NSV-экранирование — оно разбирается и собирается заново только здесь и в
nsv.py; наружу и в промпт всегда идут обычные Python-строки.
"""
import re
import sys
import shutil
from pathlib import Path
from nsv_io import load_nsv_file, save_nsv_file
BASE_DIR = Path(__file__).resolve().parent
SRC_ENCODING = "cp932" # Shift-JIS
PURE_CONTROL_TOKENS = {"\\r", "\\p", "\\n", "\\@"}
def is_text_line(raw_line: str) -> bool:
stripped = raw_line.strip()
if not stripped or stripped in PURE_CONTROL_TOKENS:
return False
if "[" in raw_line or "" in raw_line or "" in raw_line:
return True
return False
def parse_speaker_and_text(raw_line: str):
"""Строгий разбор формата CatSystem2: 'Имя\\tТекст' или '\\tТекст' (без имени).
Разделитель между именем и текстом — ровно один таб, ничего больше.
Если строка начинается с таба — имени нет, текст идёт с отступом.
"""
if raw_line.startswith("\t"):
return None, raw_line[1:]
if "\t" in raw_line:
speaker, text = raw_line.split("\t", 1)
return speaker, text
# Строка без единого таба под критерии is_text_line не должна попадать,
# но на случай мусорных данных не теряем её молча.
return None, raw_line
def extract_entries(lines):
entries = []
pending_pcm = None
pcm_re = re.compile(r"^\s*pcm\s+(\S+)\s*$")
for idx, raw in enumerate(lines):
line_no = idx + 1
stripped = raw.strip()
pcm_match = pcm_re.match(stripped)
if pcm_match:
pending_pcm = pcm_match.group(1)
continue
if is_text_line(raw):
speaker, text = parse_speaker_and_text(raw)
entries.append({
"line": line_no,
"speaker": speaker,
"pcm": pending_pcm,
"text": text,
"translated_text": "",
})
pending_pcm = None
return entries
def txt_to_nsv():
print("\n--- РЕЖИМ 1: ИЗВЛЕЧЕНИЕ В .nsv ---")
src_dir = BASE_DIR / (input("Папка с исходными .txt [txt]: ").strip() or "txt")
dst_dir = BASE_DIR / (input("Папка для .nsv [translate]: ").strip() or "translate")
if not src_dir.is_dir():
print(f"Ошибка: папка не найдена: {src_dir}")
return
dst_dir.mkdir(exist_ok=True)
txt_files = sorted(src_dir.glob("*.txt"))
ok_count, fail_count = 0, 0
for txt_file in txt_files:
nsv_file = dst_dir / (txt_file.stem + ".nsv")
try:
# errors="replace" здесь осознанно: если в исходном декомпилированном
# .txt попадётся байт, нечитаемый в cp932, лучше получить "?" и продолжить,
# чем упасть на самом первом шаге пайплайна. Итоговый .nsv всё равно
# редактируется человеком/LLM дальше, ошибка будет заметна визуально.
with open(txt_file, "r", encoding=SRC_ENCODING, errors="replace", newline="") as f:
lines = [ln.rstrip("\r\n") for ln in f]
entries = extract_entries(lines)
save_nsv_file(nsv_file, entries)
ok_count += 1
print(f"-> {txt_file.name} (строк: {len(entries)})")
except Exception as e:
fail_count += 1
print(f"[!] Ошибка {txt_file.name}: {e}")
print(f"\nГотово. Успешно: {ok_count}, Ошибок: {fail_count}\n")
def fix_llm_symbols(text: str) -> str:
"""Заменяет символы от LLM, от которых падает Shift-JIS"""
replacements = {
'ё': 'е', 'Ё': 'Е',
'': '', '': '-',
'«': '"', '»': '"',
'': '"', '': '"',
'': "'", '': "'",
'': '...',
'\u200b': '',
'\u00a0': ' ',
'\u2014': '',
'\u2013': '-',
}
for bad, good in replacements.items():
text = text.replace(bad, good)
return text
def nsv_to_txt():
print("\n--- РЕЖИМ 2: СБОРКА ИЗ .nsv В TXT ---")
print("Какое поле с текстом использовать?")
print(" 1 - Использовать базовое поле 'text'")
print(" 2 - Использовать поле 'translated_text' (с откатом к 'text')")
while True:
mode_input = input("Ваш выбор [2]: ").strip()
if not mode_input:
mode = 2
break
elif mode_input in ("1", "2"):
mode = int(mode_input)
break
else:
print("Пожалуйста, введите 1 или 2.")
txt_input = input("Папка с ОРИГИНАЛЬНЫМИ .txt [txt]: ").strip() or "txt"
nsv_input = input("Папка с ПЕРЕВЕДЕННЫМИ .nsv [translate]: ").strip() or "translate"
out_input = input("Куда сохранить готовые .txt [txt_translated]: ").strip() or "txt_translated"
txt_dir = BASE_DIR / txt_input
nsv_dir = BASE_DIR / nsv_input
out_dir = BASE_DIR / out_input
if not nsv_dir.is_dir() or not txt_dir.is_dir():
print("Ошибка: папки с исходниками или переводами не найдены.")
return
out_dir.mkdir(exist_ok=True)
nsv_files = sorted(nsv_dir.glob("*.nsv"))
ok_count, fail_count = 0, 0
for nsv_file in nsv_files:
txt_file = txt_dir / (nsv_file.stem + ".txt")
out_file = out_dir / txt_file.name
temp_txt_file = out_dir / f"~temp_{txt_file.name}"
if not txt_file.exists():
print(f"[!] Нет оригинального txt для {nsv_file.name}, пропускаю...")
continue
try:
shutil.copy2(txt_file, temp_txt_file)
with open(temp_txt_file, "r", encoding=SRC_ENCODING, errors="replace", newline="") as f:
orig_lines = f.readlines()
entries = load_nsv_file(nsv_file)
for entry in entries:
idx = entry["line"] - 1
if idx >= len(orig_lines):
continue
if mode == 2:
text_raw = entry.get("translated_text") or entry.get("text", "")
else:
text_raw = entry.get("text", "")
text = fix_llm_symbols(text_raw)
speaker = entry.get("speaker")
orig_line = orig_lines[idx]
newline_chars = "\r\n" if orig_line.endswith("\r\n") else "\n"
clean_orig = orig_line.rstrip("\r\n")
if speaker:
orig_lines[idx] = f"{speaker}\t{text}{newline_chars}"
else:
m = re.match(r"^(\s+)", clean_orig)
sep = m.group(1) if m else " "
orig_lines[idx] = f"{sep}{text}{newline_chars}"
# errors="strict": если после fix_llm_symbols остался символ,
# не представимый в cp932, лучше упасть с понятной ошибкой сейчас,
# чем молча получить "?" в игре после компиляции.
with open(out_file, "w", encoding=SRC_ENCODING, errors="strict", newline="") as f:
f.writelines(orig_lines)
ok_count += 1
print(f"<- {nsv_file.name} собран успешно")
except Exception as e:
fail_count += 1
print(f"[!] Ошибка {nsv_file.name}: {e}")
finally:
if temp_txt_file.exists():
try:
temp_txt_file.unlink()
except Exception:
pass
print(f"\nГотово. Успешно: {ok_count}, Ошибок: {fail_count}\n")
def main():
if "--txt2nsv" in sys.argv or "--txt2json" in sys.argv:
txt_to_nsv()
return
elif "--nsv2txt" in sys.argv or "--json2txt" in sys.argv:
nsv_to_txt()
return
print("=== Утилита локализации CatSystem2 ===")
print("1. Разобрать игру (TXT -> NSV)")
print("2. Собрать перевод (NSV -> TXT)")
choice = input("Ваш выбор (1 или 2): ").strip()
if choice == '1':
txt_to_nsv()
elif choice == '2':
nsv_to_txt()
else:
print("Неверный выбор. До свидания!")
if __name__ == "__main__":
main()