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