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