Update Translation.py
This commit is contained in:
+92
-37
@@ -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)
|
||||||
with open(filepath, "rt", encoding="utf-8", newline="") as csvfile:
|
try:
|
||||||
self.load_from_stream(csvfile)
|
with open(filepath, "rt", encoding="utf-8", newline="") as 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)
|
||||||
with open(filepath, "wt", encoding="utf-8", newline="") as csvfile:
|
try:
|
||||||
self.save_to_stream(csvfile)
|
with open(filepath, "wt", encoding="utf-8", newline="") as 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)
|
||||||
|
|||||||
Reference in New Issue
Block a user