517 lines
16 KiB
Python
517 lines
16 KiB
Python
# -*- 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)
|