Update Translation.py

This commit is contained in:
Yusarina
2025-04-16 16:17:57 +01:00
parent bb5a314796
commit 19c2ede791
+88 -33
View File
@@ -6,14 +6,20 @@
# MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. # MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit.
import csv import csv
import logging
import time import time
from typing import List, Tuple, Dict, Optional, Any, Generator, Union, TextIO, Iterator, Set
import bpy import bpy
from bpy.types import Text, Context
from .bpyutils import FnContext from .bpyutils import FnContext
from ..logging_setup import logger
jp_half_to_full_tuples = ( # Type definitions for translation tuples
TranslationTuple = Tuple[str, str]
TranslationList = List[TranslationTuple]
jp_half_to_full_tuples: TranslationList = (
("ヴ", ""), ("ヴ", ""),
("ガ", ""), ("ガ", ""),
("ギ", ""), ("ギ", ""),
@@ -103,7 +109,7 @@ jp_half_to_full_tuples = (
("", ""), ("", ""),
) )
jp_to_en_tuples = [ jp_to_en_tuples: TranslationList = [
("全ての親", "ParentNode"), ("全ての親", "ParentNode"),
("操作中心", "ControlNode"), ("操作中心", "ControlNode"),
("センター", "Center"), ("センター", "Center"),
@@ -293,22 +299,30 @@ jp_to_en_tuples = [
] ]
def translateFromJp(name): def translateFromJp(name: str) -> str:
"""Translate a Japanese name to English using the translation tuples."""
logger.debug(f"Translating from Japanese: {name}")
for tuple in jp_to_en_tuples: for tuple in jp_to_en_tuples:
if tuple[0] in name: if tuple[0] in name:
name = name.replace(tuple[0], tuple[1]) name = name.replace(tuple[0], tuple[1])
logger.debug(f"Translation result: {name}")
return name return name
def getTranslator(csvfile="", keep_order=False): def getTranslator(csvfile: Union[str, Dict[str, str], Text] = "", keep_order: bool = False) -> 'MMDTranslator':
"""Get a translator instance with the specified CSV file."""
translator = MMDTranslator() translator = MMDTranslator()
if isinstance(csvfile, bpy.types.Text): if isinstance(csvfile, bpy.types.Text):
logger.debug(f"Loading translator from Text object: {csvfile.name}")
translator.load_from_stream(csvfile) translator.load_from_stream(csvfile)
elif isinstance(csvfile, dict): elif isinstance(csvfile, dict):
logger.debug(f"Loading translator from dictionary with {len(csvfile)} entries")
translator.csv_tuples.extend(csvfile.items()) translator.csv_tuples.extend(csvfile.items())
elif csvfile in bpy.data.texts.keys(): elif csvfile in bpy.data.texts.keys():
logger.debug(f"Loading translator from text data: {csvfile}")
translator.load_from_stream(bpy.data.texts[csvfile]) translator.load_from_stream(bpy.data.texts[csvfile])
else: else:
logger.debug(f"Loading translator from file: {csvfile}")
translator.load(csvfile) translator.load(csvfile)
if not keep_order: if not keep_order:
@@ -318,16 +332,20 @@ def getTranslator(csvfile="", keep_order=False):
class MMDTranslator: class MMDTranslator:
def __init__(self): """Handles translation of Japanese text to English for MMD models."""
self.__csv_tuples = []
self.__fails = {} def __init__(self) -> None:
self.__csv_tuples: List[Tuple[str, str]] = []
self.__fails: Dict[str, str] = {}
@staticmethod @staticmethod
def default_csv_filepath(): def default_csv_filepath() -> str:
"""Get the default CSV filepath for translations."""
return __file__[:-3] + ".csv" return __file__[:-3] + ".csv"
@staticmethod @staticmethod
def get_csv_text(text_name=None): def get_csv_text(text_name: Optional[str] = None) -> Text:
"""Get or create a Text object for CSV data."""
text_name = text_name or bpy.path.basename(MMDTranslator.default_csv_filepath()) text_name = text_name or bpy.path.basename(MMDTranslator.default_csv_filepath())
csv_text = bpy.data.texts.get(text_name, None) csv_text = bpy.data.texts.get(text_name, None)
if csv_text is None: if csv_text is None:
@@ -335,69 +353,88 @@ class MMDTranslator:
return csv_text return csv_text
@staticmethod @staticmethod
def replace_from_tuples(name, tuples): def replace_from_tuples(name: str, tuples: List[Tuple[str, str]]) -> str:
"""Replace parts of a string based on translation tuples."""
for pair in tuples: for pair in tuples:
if pair[0] in name: if pair[0] in name:
name = name.replace(pair[0], pair[1]) name = name.replace(pair[0], pair[1])
return name return name
@property @property
def csv_tuples(self): def csv_tuples(self) -> List[Tuple[str, str]]:
"""Get the CSV tuples."""
return self.__csv_tuples return self.__csv_tuples
@property @property
def fails(self): def fails(self) -> Dict[str, str]:
"""Get the failed translations."""
return self.__fails return self.__fails
def sort(self): def sort(self) -> None:
"""Sort the CSV tuples by length (longest first) and then alphabetically."""
logger.debug("Sorting translation tuples")
self.__csv_tuples.sort(key=lambda row: (-len(row[0]), row)) self.__csv_tuples.sort(key=lambda row: (-len(row[0]), row))
def update(self): def update(self) -> None:
"""Update the CSV tuples, removing duplicates."""
from collections import OrderedDict from collections import OrderedDict
count_old = len(self.__csv_tuples) count_old = len(self.__csv_tuples)
tuples_dict = OrderedDict((row[0], row) for row in self.__csv_tuples if len(row) >= 2 and row[0]) tuples_dict = OrderedDict((row[0], row) for row in self.__csv_tuples if len(row) >= 2 and row[0])
self.__csv_tuples.clear() self.__csv_tuples.clear()
self.__csv_tuples.extend(tuples_dict.values()) self.__csv_tuples.extend(tuples_dict.values())
logging.info(" - removed items:\t%d\t(of %d)", count_old - len(self.__csv_tuples), count_old) logger.info("Translation update - removed items: %d (of %d)", count_old - len(self.__csv_tuples), count_old)
def half_to_full(self, name): def half_to_full(self, name: str) -> str:
"""Convert half-width Japanese characters to full-width."""
return self.replace_from_tuples(name, jp_half_to_full_tuples) return self.replace_from_tuples(name, jp_half_to_full_tuples)
def is_translated(self, name): def is_translated(self, name: str) -> bool:
"""Check if a string is already translated (contains only ASCII characters)."""
try: try:
name.encode("ascii", errors="strict") name.encode("ascii", errors="strict")
except UnicodeEncodeError: except UnicodeEncodeError:
return False return False
return True return True
def translate(self, name, default=None, from_full_width=True): def translate(self, name: str, default: Optional[str] = None, from_full_width: bool = True) -> str:
"""Translate a string from Japanese to English."""
logger.debug(f"Translating: {name}")
if from_full_width: if from_full_width:
name = self.half_to_full(name) name = self.half_to_full(name)
name_new = self.replace_from_tuples(name, self.__csv_tuples) name_new = self.replace_from_tuples(name, self.__csv_tuples)
if default is not None and not self.is_translated(name_new): if default is not None and not self.is_translated(name_new):
logger.warning(f"Translation failed for: {name}")
self.__fails[name] = name_new self.__fails[name] = name_new
return default return default
return name_new return name_new
def save_fails(self, text_name=None): def save_fails(self, text_name: Optional[str] = None) -> Text:
"""Save failed translations to a Text object."""
text_name = text_name or (__name__ + ".fails") text_name = text_name or (__name__ + ".fails")
txt = self.get_csv_text(text_name) txt = self.get_csv_text(text_name)
fmt = '"%s","%s"' fmt = '"%s","%s"'
items = sorted(self.__fails.items(), key=lambda row: (-len(row[0]), row)) items = sorted(self.__fails.items(), key=lambda row: (-len(row[0]), row))
txt.from_string("\n".join(fmt % (k, v) for k, v in items)) txt.from_string("\n".join(fmt % (k, v) for k, v in items))
logger.info(f"Saved {len(items)} failed translations to {text_name}")
return txt return txt
def load_from_stream(self, csvfile=None): def load_from_stream(self, csvfile: Union[Text, Iterator[str]] = None) -> None:
"""Load translations from a stream."""
csvfile = csvfile or self.get_csv_text() csvfile = csvfile or self.get_csv_text()
if isinstance(csvfile, bpy.types.Text): if isinstance(csvfile, bpy.types.Text):
csvfile = (l.body + "\n" for l in csvfile.lines) csvfile = (l.body + "\n" for l in csvfile.lines)
spamreader = csv.reader(csvfile, delimiter=",", skipinitialspace=True) spamreader = csv.reader(csvfile, delimiter=",", skipinitialspace=True)
csv_tuples = [tuple(row) for row in spamreader if len(row) >= 2] csv_tuples = [tuple(row) for row in spamreader if len(row) >= 2]
self.__csv_tuples = csv_tuples self.__csv_tuples = csv_tuples
logging.info(" - load items:\t%d", len(self.__csv_tuples)) logger.info("Loaded %d translation items", len(self.__csv_tuples))
def save_to_stream(self, csvfile=None): def save_to_stream(self, csvfile: Union[Text, TextIO] = None) -> None:
"""Save translations to a stream.
Args:
csvfile: The CSV file or stream to save to
"""
csvfile = csvfile or self.get_csv_text() csvfile = csvfile or self.get_csv_text()
lineterminator = "\r\n" lineterminator = "\r\n"
if isinstance(csvfile, bpy.types.Text): if isinstance(csvfile, bpy.types.Text):
@@ -405,27 +442,38 @@ class MMDTranslator:
lineterminator = "\n" lineterminator = "\n"
spamwriter = csv.writer(csvfile, delimiter=",", lineterminator=lineterminator, quoting=csv.QUOTE_ALL) spamwriter = csv.writer(csvfile, delimiter=",", lineterminator=lineterminator, quoting=csv.QUOTE_ALL)
spamwriter.writerows(self.__csv_tuples) spamwriter.writerows(self.__csv_tuples)
logging.info(" - save items:\t%d", len(self.__csv_tuples)) logger.info("Saved %d translation items", len(self.__csv_tuples))
def load(self, filepath=None): def load(self, filepath: Optional[str] = None) -> None:
"""Load translations from a file."""
filepath = filepath or self.default_csv_filepath() filepath = filepath or self.default_csv_filepath()
logging.info("Loading csv file:\t%s", filepath) logger.info("Loading CSV file: %s", filepath)
try:
with open(filepath, "rt", encoding="utf-8", newline="") as csvfile: with open(filepath, "rt", encoding="utf-8", newline="") as csvfile:
self.load_from_stream(csvfile) self.load_from_stream(csvfile)
except Exception as e:
logger.error(f"Failed to load CSV file: {e}")
def save(self, filepath=None): def save(self, filepath: Optional[str] = None) -> None:
"""Save translations to a file."""
filepath = filepath or self.default_csv_filepath() filepath = filepath or self.default_csv_filepath()
logging.info("Saving csv file:\t%s", filepath) logger.info("Saving CSV file: %s", filepath)
try:
with open(filepath, "wt", encoding="utf-8", newline="") as csvfile: with open(filepath, "wt", encoding="utf-8", newline="") as csvfile:
self.save_to_stream(csvfile) self.save_to_stream(csvfile)
except Exception as e:
logger.error(f"Failed to save CSV file: {e}")
class DictionaryEnum: class DictionaryEnum:
__items_ttl = 0.0 """Handles dictionary enumeration for UI."""
__items_cache = None
__items_ttl: float = 0.0
__items_cache: Optional[List[Tuple[str, str, str, int]]] = None
@staticmethod @staticmethod
def get_dictionary_items(prop, context): def get_dictionary_items(prop: Any, context: Context) -> List[Tuple[str, str, str, Union[int, str], int]]:
"""Get dictionary items for UI enumeration."""
if DictionaryEnum.__items_ttl > time.time(): if DictionaryEnum.__items_ttl > time.time():
return DictionaryEnum.__items_cache return DictionaryEnum.__items_cache
@@ -437,7 +485,7 @@ class DictionaryEnum:
items.append(("INTERNAL", "Internal Dictionary", "The dictionary defined in " + __name__, len(items))) items.append(("INTERNAL", "Internal Dictionary", "The dictionary defined in " + __name__, len(items)))
for txt_name in sorted(x.name for x in bpy.data.texts if x.name.lower().endswith(".csv")): for txt_name in sorted(x.name for x in bpy.data.texts if x.name.lower().endswith(".csv")):
items.append((txt_name, txt_name, "bpy.data.texts['%s']" % txt_name, "TEXT", len(items))) items.append((txt_name, txt_name, f"bpy.data.texts['{txt_name}']", "TEXT", len(items)))
import os import os
@@ -450,12 +498,19 @@ class DictionaryEnum:
if "dictionary" in prop: if "dictionary" in prop:
prop["dictionary"] = min(prop["dictionary"], len(items) - 1) prop["dictionary"] = min(prop["dictionary"], len(items) - 1)
logger.debug(f"Found {len(items)} dictionary items")
return items return items
@staticmethod @staticmethod
def get_translator(dictionary): def get_translator(dictionary: str) -> Optional[MMDTranslator]:
"""Get a translator for the specified dictionary."""
if dictionary == "DISABLED": if dictionary == "DISABLED":
logger.debug("Translation disabled")
return None return None
if dictionary == "INTERNAL": if dictionary == "INTERNAL":
logger.debug("Using internal dictionary")
return getTranslator(dict(jp_to_en_tuples)) return getTranslator(dict(jp_to_en_tuples))
logger.debug(f"Using dictionary: {dictionary}")
return getTranslator(dictionary) return getTranslator(dictionary)