Files
cs2-easy-translate-tool/convert.py
T
2026-09-07 19:07:40 +03:00

257 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()