# -*- coding: utf-8 -*- # Copyright 2014 MMD Tools authors # This file was originally part of the MMD Tools add-on for Blender # You can find MMD Tools here: https://github.com/MMD-Blender/blender_mmd_tools # Neoneko has modified this file to work with Avatar Toolkit and may of made changes or improvements. # MMD Tools is licensed under the terms of the GNU General Public License version 3 (GPLv3) same as Avatar Toolkit. import csv import time from typing import List, Tuple, Dict, Optional, Any, Generator, Union, TextIO, Iterator, Set import bpy from bpy.types import Text, Context from .bpyutils import FnContext from ..logging_setup import logger # Type definitions for translation tuples TranslationTuple = Tuple[str, str] TranslationList = List[TranslationTuple] jp_half_to_full_tuples: TranslationList = ( ("ヴ", "ヴ"), ("ガ", "ガ"), ("ギ", "ギ"), ("グ", "グ"), ("ゲ", "ゲ"), ("ゴ", "ゴ"), ("ザ", "ザ"), ("ジ", "ジ"), ("ズ", "ズ"), ("ゼ", "ゼ"), ("ゾ", "ゾ"), ("ダ", "ダ"), ("ヂ", "ヂ"), ("ヅ", "ヅ"), ("デ", "デ"), ("ド", "ド"), ("バ", "バ"), ("パ", "パ"), ("ビ", "ビ"), ("ピ", "ピ"), ("ブ", "ブ"), ("プ", "プ"), ("ベ", "ベ"), ("ペ", "ペ"), ("ボ", "ボ"), ("ポ", "ポ"), ("。", "。"), ("「", "「"), ("」", "」"), ("、", "、"), ("・", "・"), ("ヲ", "ヲ"), ("ァ", "ァ"), ("ィ", "ィ"), ("ゥ", "ゥ"), ("ェ", "ェ"), ("ォ", "ォ"), ("ャ", "ャ"), ("ュ", "ュ"), ("ョ", "ョ"), ("ッ", "ッ"), ("ー", "ー"), ("ア", "ア"), ("イ", "イ"), ("ウ", "ウ"), ("エ", "エ"), ("オ", "オ"), ("カ", "カ"), ("キ", "キ"), ("ク", "ク"), ("ケ", "ケ"), ("コ", "コ"), ("サ", "サ"), ("シ", "シ"), ("ス", "ス"), ("セ", "セ"), ("ソ", "ソ"), ("タ", "タ"), ("チ", "チ"), ("ツ", "ツ"), ("テ", "テ"), ("ト", "ト"), ("ナ", "ナ"), ("ニ", "ニ"), ("ヌ", "ヌ"), ("ネ", "ネ"), ("ノ", "ノ"), ("ハ", "ハ"), ("ヒ", "ヒ"), ("フ", "フ"), ("ヘ", "ヘ"), ("ホ", "ホ"), ("マ", "マ"), ("ミ", "ミ"), ("ム", "ム"), ("メ", "メ"), ("モ", "モ"), ("ヤ", "ヤ"), ("ユ", "ユ"), ("ヨ", "ヨ"), ("ラ", "ラ"), ("リ", "リ"), ("ル", "ル"), ("レ", "レ"), ("ロ", "ロ"), ("ワ", "ワ"), ("ン", "ン"), ) jp_to_en_tuples: TranslationList = [ ("全ての親", "ParentNode"), ("操作中心", "ControlNode"), ("センター", "Center"), ("センター", "Center"), ("グループ", "Group"), ("グルーブ", "Groove"), ("キャンセル", "Cancel"), ("上半身", "UpperBody"), ("下半身", "LowerBody"), ("手首", "Wrist"), ("足首", "Ankle"), ("首", "Neck"), ("頭", "Head"), ("顔", "Face"), ("下顎", "Chin"), ("下あご", "Chin"), ("あご", "Jaw"), ("顎", "Jaw"), ("両目", "Eyes"), ("目", "Eye"), ("眉", "Eyebrow"), ("舌", "Tongue"), ("涙", "Tears"), ("泣き", "Cry"), ("歯", "Teeth"), ("照れ", "Blush"), ("青ざめ", "Pale"), ("ガーン", "Gloom"), ("汗", "Sweat"), ("怒", "Anger"), ("感情", "Emotion"), ("符", "Marks"), ("暗い", "Dark"), ("腰", "Waist"), ("髪", "Hair"), ("三つ編み", "Braid"), ("胸", "Breast"), ("乳", "Boob"), ("おっぱい", "Tits"), ("筋", "Muscle"), ("腹", "Belly"), ("鎖骨", "Clavicle"), ("肩", "Shoulder"), ("腕", "Arm"), ("うで", "Arm"), ("ひじ", "Elbow"), ("肘", "Elbow"), ("手", "Hand"), ("親指", "Thumb"), ("人指", "IndexFinger"), ("人差指", "IndexFinger"), ("中指", "MiddleFinger"), ("薬指", "RingFinger"), ("小指", "LittleFinger"), ("足", "Leg"), ("ひざ", "Knee"), ("つま", "Toe"), ("袖", "Sleeve"), ("新規", "New"), ("ボーン", "Bone"), ("捩", "Twist"), ("回転", "Rotation"), ("軸", "Axis"), ("ネクタイ", "Necktie"), ("ネクタイ", "Necktie"), ("ヘッドセット", "Headset"), ("飾り", "Accessory"), ("リボン", "Ribbon"), ("襟", "Collar"), ("紐", "String"), ("コード", "Cord"), ("イヤリング", "Earring"), ("メガネ", "Eyeglasses"), ("眼鏡", "Glasses"), ("帽子", "Hat"), ("スカート", "Skirt"), ("スカート", "Skirt"), ("パンツ", "Pantsu"), ("シャツ", "Shirt"), ("フリル", "Frill"), ("マフラー", "Muffler"), ("マフラー", "Muffler"), ("服", "Clothes"), ("ブーツ", "Boots"), ("ねこみみ", "CatEars"), ("ジップ", "Zip"), ("ジップ", "Zip"), ("ダミー", "Dummy"), ("ダミー", "Dummy"), ("基", "Category"), ("あほ毛", "Antenna"), ("アホ毛", "Antenna"), ("モミアゲ", "Sideburn"), ("もみあげ", "Sideburn"), ("ツインテ", "Twintail"), ("おさげ", "Pigtail"), ("ひらひら", "Flutter"), ("調整", "Adjustment"), ("補助", "Aux"), ("右", "Right"), ("左", "Left"), ("前", "Front"), ("後ろ", "Behind"), ("後", "Back"), ("横", "Side"), ("中", "Middle"), ("上", "Upper"), ("下", "Lower"), ("親", "Parent"), ("先", "Tip"), ("パーツ", "Part"), ("光", "Light"), ("戻", "Return"), ("羽", "Wing"), ("根", "Base"), # ideally 'Root' but to avoid confusion ("毛", "Strand"), ("尾", "Tail"), ("尻", "Butt"), # full-width unicode forms I think: https://en.wikipedia.org/wiki/Halfwidth_and_fullwidth_forms ("0", "0"), ("1", "1"), ("2", "2"), ("3", "3"), ("4", "4"), ("5", "5"), ("6", "6"), ("7", "7"), ("8", "8"), ("9", "9"), ("a", "a"), ("b", "b"), ("c", "c"), ("d", "d"), ("e", "e"), ("f", "f"), ("g", "g"), ("h", "h"), ("i", "i"), ("j", "j"), ("k", "k"), ("l", "l"), ("m", "m"), ("n", "n"), ("o", "o"), ("p", "p"), ("q", "q"), ("r", "r"), ("s", "s"), ("t", "t"), ("u", "u"), ("v", "v"), ("w", "w"), ("x", "x"), ("y", "y"), ("z", "z"), ("A", "A"), ("B", "B"), ("C", "C"), ("D", "D"), ("E", "E"), ("F", "F"), ("G", "G"), ("H", "H"), ("I", "I"), ("J", "J"), ("K", "K"), ("L", "L"), ("M", "M"), ("N", "N"), ("O", "O"), ("P", "P"), ("Q", "Q"), ("R", "R"), ("S", "S"), ("T", "T"), ("U", "U"), ("V", "V"), ("W", "W"), ("X", "X"), ("Y", "Y"), ("Z", "Z"), ("+", "+"), ("-", "-"), ("_", "_"), ("/", "/"), (".", "_"), # probably should be combined with the global 'use underscore' option ] 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: if tuple[0] in name: name = name.replace(tuple[0], tuple[1]) logger.debug(f"Translation result: {name}") return name 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() if isinstance(csvfile, bpy.types.Text): logger.debug(f"Loading translator from Text object: {csvfile.name}") translator.load_from_stream(csvfile) elif isinstance(csvfile, dict): logger.debug(f"Loading translator from dictionary with {len(csvfile)} entries") translator.csv_tuples.extend(csvfile.items()) elif csvfile in bpy.data.texts.keys(): logger.debug(f"Loading translator from text data: {csvfile}") translator.load_from_stream(bpy.data.texts[csvfile]) else: logger.debug(f"Loading translator from file: {csvfile}") translator.load(csvfile) if not keep_order: translator.sort() translator.update() return translator class MMDTranslator: """Handles translation of Japanese text to English for MMD models.""" def __init__(self) -> None: self.__csv_tuples: List[Tuple[str, str]] = [] self.__fails: Dict[str, str] = {} @staticmethod def default_csv_filepath() -> str: """Get the default CSV filepath for translations.""" return __file__[:-3] + ".csv" @staticmethod 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()) csv_text = bpy.data.texts.get(text_name, None) if csv_text is None: csv_text = bpy.data.texts.new(text_name) return csv_text @staticmethod 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: if pair[0] in name: name = name.replace(pair[0], pair[1]) return name @property def csv_tuples(self) -> List[Tuple[str, str]]: """Get the CSV tuples.""" return self.__csv_tuples @property def fails(self) -> Dict[str, str]: """Get the failed translations.""" return self.__fails 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)) def update(self) -> None: """Update the CSV tuples, removing duplicates.""" from collections import OrderedDict 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]) self.__csv_tuples.clear() self.__csv_tuples.extend(tuples_dict.values()) logger.info("Translation update - removed items: %d (of %d)", count_old - len(self.__csv_tuples), count_old) 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) def is_translated(self, name: str) -> bool: """Check if a string is already translated (contains only ASCII characters).""" try: name.encode("ascii", errors="strict") except UnicodeEncodeError: return False return 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: name = self.half_to_full(name) name_new = self.replace_from_tuples(name, self.__csv_tuples) if default is not None and not self.is_translated(name_new): logger.warning(f"Translation failed for: {name}") self.__fails[name] = name_new return default return name_new def save_fails(self, text_name: Optional[str] = None) -> Text: """Save failed translations to a Text object.""" text_name = text_name or (__name__ + ".fails") txt = self.get_csv_text(text_name) fmt = '"%s","%s"' 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)) logger.info(f"Saved {len(items)} failed translations to {text_name}") return txt def load_from_stream(self, csvfile: Union[Text, Iterator[str]] = None) -> None: """Load translations from a stream.""" csvfile = csvfile or self.get_csv_text() if isinstance(csvfile, bpy.types.Text): csvfile = (l.body + "\n" for l in csvfile.lines) spamreader = csv.reader(csvfile, delimiter=",", skipinitialspace=True) csv_tuples = [tuple(row) for row in spamreader if len(row) >= 2] self.__csv_tuples = csv_tuples logger.info("Loaded %d translation items", len(self.__csv_tuples)) 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() lineterminator = "\r\n" if isinstance(csvfile, bpy.types.Text): csvfile.clear() lineterminator = "\n" spamwriter = csv.writer(csvfile, delimiter=",", lineterminator=lineterminator, quoting=csv.QUOTE_ALL) spamwriter.writerows(self.__csv_tuples) logger.info("Saved %d translation items", len(self.__csv_tuples)) def load(self, filepath: Optional[str] = None) -> None: """Load translations from a file.""" filepath = filepath or self.default_csv_filepath() logger.info("Loading CSV file: %s", filepath) try: 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: Optional[str] = None) -> None: """Save translations to a file.""" filepath = filepath or self.default_csv_filepath() logger.info("Saving CSV file: %s", filepath) try: 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: """Handles dictionary enumeration for UI.""" __items_ttl: float = 0.0 __items_cache: Optional[List[Tuple[str, str, str, int]]] = None @staticmethod 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(): return DictionaryEnum.__items_cache DictionaryEnum.__items_ttl = time.time() + 5 DictionaryEnum.__items_cache = items = [] if "import" in prop.bl_rna.identifier: items.append(("DISABLED", "Disabled", "", 0)) 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")): items.append((txt_name, txt_name, f"bpy.data.texts['{txt_name}']", "TEXT", len(items))) import os folder = FnContext.get_addon_preferences_attribute(context, "dictionary_folder", "") if os.path.isdir(folder): for filename in sorted(x for x in os.listdir(folder) if x.lower().endswith(".csv")): filepath = os.path.join(folder, filename) if os.path.isfile(filepath): items.append((filepath, filename, filepath, "FILE", len(items))) if "dictionary" in prop: prop["dictionary"] = min(prop["dictionary"], len(items) - 1) logger.debug(f"Found {len(items)} dictionary items") return items @staticmethod def get_translator(dictionary: str) -> Optional[MMDTranslator]: """Get a translator for the specified dictionary.""" if dictionary == "DISABLED": logger.debug("Translation disabled") return None if dictionary == "INTERNAL": logger.debug("Using internal dictionary") return getTranslator(dict(jp_to_en_tuples)) logger.debug(f"Using dictionary: {dictionary}") return getTranslator(dictionary)