diff --git a/src-python/config.py b/src-python/config.py index 3444a4a3..739e7c24 100644 --- a/src-python/config.py +++ b/src-python/config.py @@ -186,7 +186,7 @@ def _overlay_small_validator(val, inst): new[key] = float(v) elif key in ['display_duration','fadeout_duration'] and isinstance(v,int): new[key] = v - elif key in ['opacity','ui_scaling'] and isinstance(v,(int,float)): + elif key in ['opacity','ui_scaling', 'ruby_font_scale', 'ruby_line_spacing'] and isinstance(v,(int,float)): new[key] = float(v) return new @@ -202,7 +202,7 @@ def _overlay_large_validator(val, inst): new[key] = float(v) elif key in ['display_duration','fadeout_duration'] and isinstance(v,int): new[key] = v - elif key in ['opacity','ui_scaling'] and isinstance(v,(int,float)): + elif key in ['opacity','ui_scaling', 'ruby_font_scale', 'ruby_line_spacing'] and isinstance(v,(int,float)): new[key] = float(v) return new @@ -737,6 +737,8 @@ class Config: "opacity": 1.0, "ui_scaling": 1.0, "tracker": "HMD", + "ruby_font_scale": 0.5, + "ruby_line_spacing": 4, } self._OVERLAY_LARGE_LOG = False self._OVERLAY_LARGE_LOG_SETTINGS = { @@ -751,6 +753,8 @@ class Config: "opacity": 1.0, "ui_scaling": 1.0, "tracker": "LeftHand", + "ruby_font_scale": 0.5, + "ruby_line_spacing": 4, } self._OVERLAY_SHOW_ONLY_TRANSLATED_MESSAGES = False self._SEND_MESSAGE_TO_VRC = True diff --git a/src-python/controller.py b/src-python/controller.py index 63fc489d..60764865 100644 --- a/src-python/controller.py +++ b/src-python/controller.py @@ -270,7 +270,7 @@ class Controller: elif isinstance(message, str) and len(message) > 0: translation = [] - transliteration_message: List[Any] = [] + transliteration_message = [] transliteration_translation = [] if model.checkKeywords(message): self.run( @@ -383,7 +383,9 @@ class Controller: None, None, translation, - config.SELECTED_TARGET_LANGUAGES[config.SELECTED_TAB_NO] + config.SELECTED_TARGET_LANGUAGES[config.SELECTED_TAB_NO], + transliteration_message, + transliteration_translation ) model.updateOverlayLargeLog(overlay_image) else: @@ -392,7 +394,9 @@ class Controller: message, config.SELECTED_YOUR_LANGUAGES[config.SELECTED_TAB_NO]["1"]["language"], translation, - config.SELECTED_TARGET_LANGUAGES[config.SELECTED_TAB_NO] + config.SELECTED_TARGET_LANGUAGES[config.SELECTED_TAB_NO], + transliteration_message, + transliteration_translation ) model.updateOverlayLargeLog(overlay_image) @@ -426,7 +430,7 @@ class Controller: ) elif isinstance(message, str) and len(message) > 0: translation = [] - transliteration_message: List[Any] = [] + transliteration_message = [] transliteration_translation = [] if model.checkKeywords(message): self.run( @@ -511,6 +515,8 @@ class Controller: None, translation, config.SELECTED_YOUR_LANGUAGES[config.SELECTED_TAB_NO], + transliteration_message, + transliteration_translation ) model.updateOverlaySmallLog(overlay_image) else: @@ -519,6 +525,8 @@ class Controller: language, translation, config.SELECTED_YOUR_LANGUAGES[config.SELECTED_TAB_NO], + transliteration_message, + transliteration_translation ) model.updateOverlaySmallLog(overlay_image) @@ -530,6 +538,9 @@ class Controller: None, None, translation, + config.SELECTED_YOUR_LANGUAGES[config.SELECTED_TAB_NO], + transliteration_message, + transliteration_translation ) model.updateOverlayLargeLog(overlay_image) else: @@ -538,7 +549,9 @@ class Controller: message, language, translation, - config.SELECTED_YOUR_LANGUAGES[config.SELECTED_TAB_NO] + config.SELECTED_YOUR_LANGUAGES[config.SELECTED_TAB_NO], + transliteration_message, + transliteration_translation ) model.updateOverlayLargeLog(overlay_image) @@ -703,6 +716,8 @@ class Controller: None, translation, config.SELECTED_TARGET_LANGUAGES[config.SELECTED_TAB_NO], + transliteration_message, + transliteration_translation ) model.updateOverlayLargeLog(overlay_image) else: @@ -712,6 +727,8 @@ class Controller: config.SELECTED_YOUR_LANGUAGES[config.SELECTED_TAB_NO]["1"]["language"], translation, config.SELECTED_TARGET_LANGUAGES[config.SELECTED_TAB_NO], + transliteration_message, + transliteration_translation ) model.updateOverlayLargeLog(overlay_image) diff --git a/src-python/docs/overlay_ruby.md b/src-python/docs/overlay_ruby.md new file mode 100644 index 00000000..343e3416 --- /dev/null +++ b/src-python/docs/overlay_ruby.md @@ -0,0 +1,97 @@ +# 小型ログ ルビ表示機能 (Ruby Overlay for Small Log) + +## 概要 +小型ログ (Small Log Overlay) に日本語原文が含まれる場合、ローマ字(hepburn) を上段、ひらがな(hira) を下段として原文メッセージの上に 2 段のルビを表示できます。翻訳行には現段階ではルビを付与しません。 + +## 有効化条件 +- 原文 `message` が存在し空文字列でない。 +- `model.createOverlayImageSmallLog` 内で自動的に `convertMessageToTransliteration(..., hiragana=True, romaji=True)` を呼び出しトークン生成。 +- 生成されたトークンに `hepburn` または `hira` が含まれる。 + +## 大ログ (Large Log) への拡張 + +大ログについてもトークン単位のルビ描画をサポートしました。`createOverlayImageLargeLog` / `createTextboxLargeLog` 系の API に以下のような追加引数が入り、同等のルビ出力が可能です。 + +- transliteration_tokens: Optional[List[dict]] — 原文用トークン(orig/hira/hepburn) +- translation_transliteration_tokens: Optional[List[List[dict]] | List[dict]] — 翻訳ごとのトークン配列、もしくは先頭翻訳用の平坦な List[dict] +- ruby_font_scale: float — ルビのフォント倍率(原文フォントサイズに対するスケール) +- ruby_line_spacing: int — ルビ行間ピクセル + +対応挙動: + +- 原文のみが存在し、その原文にトークンがあれば原文にトークン単位ルビを振ります。 + +- 原文と翻訳の両方が存在する場合は、原則として原文のルビを抑止し、翻訳側にルビを振ります(翻訳側にトークンがある場合)。 + +- `translation_transliteration_tokens` は二通りの入力形式を受け付けます: + + - List[List[dict]] — 各翻訳行ごとの tokens 配列(推奨) + + - List[dict] — 平坦な tokens 配列(最初の翻訳行に適用されます) + +フォールバック: + +- トークン単位レイアウトで横幅がはみ出す or 改行がある場合は、既存のブロックルビ(romaji 上 / hira 下 を 1 行ブロックで表示)へ自動フォールバックします。 + +注意点: + +- 既存の表示ロジックの互換性を保つため、引数は省略可能です(None/[])。 +- フラットな `translation_transliteration_tokens` を渡す場合は最初の翻訳にのみ適用されます。複数翻訳に個別のルビを渡す場合は List[List[dict]] 形式で与えてください。 + +## 設定キー (`config.OVERLAY_SMALL_LOG_SETTINGS`) +| キー | 型 | 初期値 | 説明 | +| ---- | --- | ------ | ---- | +| ruby_font_scale | float | 0.5 | ルビ文字サイズ倍率 (原文フォントサイズ * 倍率)。安全範囲 0.05〜3.0 | +| ruby_line_spacing | int | 4 | ローマ字行とひらがな行の垂直スペース (px)。0〜200 | + +## レイアウト仕様 + +1. ルビブロック (romaji 上 / hiragana 下) を中央揃えで描画。 + +2. その下に従来の本文テキストボックスを縦方向に連結。 + +3. フォントファミリは本文と同一 (言語に対応する NotoSans 系)。 + +4. ルビが存在しない場合は従来表示のみ。 + +## フォールバック + +- ルビ生成中に例外が発生した場合はログを記録し、ルビ無しで本文のみ表示。 + +- トークンが空の場合(両方 False など)は従来表示。 + +## 例 + +以下は `createOverlayImageLargeLog` を使って、翻訳側にだけルビを渡す例(平坦な tokens を渡す場合と翻訳ごとの tokens を渡す場合): + +```python +# 平坦な tokens を渡して最初の翻訳に適用 +overlay.createOverlayImageLargeLog("receive", "こんにちは、世界!", "Japanese", ["Hello, World!"], ["English"], transliteration_tokens=[], translation_transliteration_tokens=[ + {"orig": "こんにちは", "hira": "こんにちは", "hepburn": "konnichiha"}, + {"orig": "世界", "hira": "せかい", "hepburn": "sekai"}, +]) + +# 翻訳ごとに tokens を与える(推奨) +overlay.createOverlayImageLargeLog("receive", "こんにちは、世界!", "Japanese", ["Hello, World!"], ["English"], transliteration_tokens=[], translation_transliteration_tokens=[ + [ + {"orig": "Hello", "hira": "", "hepburn": "Hello"}, + {"orig": "World", "hira": "", "hepburn": "World"}, + ] +]) +``` + +## 今後の拡張候補 +- 翻訳行へのルビ付与オプション。 +- トークン単位での幅センタリングと折り返し。 +- 高度な幅計測 (可変幅フォント対応改善)。 + +## 簡易テスト +`src-python/overlay_ruby_test.py` を実行すると `overlay_small_ruby_test.png` が生成され、縦順と配置を確認できます。 + +```bash +# PowerShell (仮想環境有効化後) +python src-python/overlay_ruby_test.py +``` + +## 注意 +UI スケーリングは OpenVR 側の表示サイズのみ変更し、画像内部フォントサイズは直接変更しません。ルビの視認性が低い場合は `ruby_font_scale` を調整してください。 diff --git a/src-python/model.py b/src-python/model.py index e8d35aec..b7dbded2 100644 --- a/src-python/model.py +++ b/src-python/model.py @@ -944,13 +944,33 @@ class Model: self.speaker_energy_recorder.stop() self.speaker_energy_recorder = None - def createOverlayImageSmallLog(self, message:Optional[str], your_language:Optional[str], translation:list, target_language:Optional[dict]) -> object: + def createOverlayImageSmallLog(self, message:Optional[str], your_language:Optional[str], translation:list, target_language:Optional[dict], transliteration_message:Optional[dict] = None, transliteration_translation:Optional[list] = None) -> object: self.ensure_initialized() - # target_language may be provided as dict or None + # Normalize target_language dict -> list target_language_list = [] if isinstance(target_language, dict): target_language_list = [data["language"] for data in target_language.values() if data.get("enable") is True] - return self.overlay_image.createOverlayImageSmallLog(message, your_language, translation, target_language_list) + + # Fetch ruby settings from config (with safe defaults if missing) + ruby_font_scale = config.OVERLAY_SMALL_LOG_SETTINGS.get("ruby_font_scale", 0.5) + ruby_line_spacing = config.OVERLAY_SMALL_LOG_SETTINGS.get("ruby_line_spacing", 4) + + # 翻訳行ルビ (任意) が指定されていれば渡す。後方互換のため None / 不正型は空リストに。 + if not isinstance(transliteration_message, list): + transliteration_message = [] + if not isinstance(transliteration_translation, list): + transliteration_translation = [[] for _ in translation] + + return self.overlay_image.createOverlayImageSmallLog( + message, + your_language, + translation, + target_language_list, + transliteration_message=transliteration_message, + transliteration_translation=transliteration_translation, + ruby_font_scale=ruby_font_scale, + ruby_line_spacing=ruby_line_spacing, + ) def createOverlayImageSmallMessage(self, message): self.ensure_initialized() @@ -1003,13 +1023,13 @@ class Model: if (self.overlay.settings[size]["ui_scaling"] != config.OVERLAY_SMALL_LOG_SETTINGS["ui_scaling"]): self.overlay.updateUiScaling(config.OVERLAY_SMALL_LOG_SETTINGS["ui_scaling"], size) - def createOverlayImageLargeLog(self, message_type:str, message:Optional[str], your_language:Optional[str], translation:list, target_language:Optional[dict]=None): + def createOverlayImageLargeLog(self, message_type:str, message:Optional[str], your_language:Optional[str], translation:list, target_language:Optional[dict]=None, transliteration_message:Optional[list]=None, transliteration_translation:Optional[list]=None) -> object: self.ensure_initialized() # normalize target_language dict -> list of language strings target_language_list = [] if isinstance(target_language, dict): target_language_list = [data["language"] for data in target_language.values() if data.get("enable") is True] - return self.overlay_image.createOverlayImageLargeLog(message_type, message, your_language, translation, target_language_list) + return self.overlay_image.createOverlayImageLargeLog(message_type, message, your_language, translation, target_language_list, transliteration_message, transliteration_translation) def createOverlayImageLargeMessage(self, message): self.ensure_initialized() diff --git a/src-python/models/overlay/overlay_image.py b/src-python/models/overlay/overlay_image.py index d307411e..c5f612de 100644 --- a/src-python/models/overlay/overlay_image.py +++ b/src-python/models/overlay/overlay_image.py @@ -37,6 +37,8 @@ class OverlayImage: self.root_path = os_path.join(os_path.dirname(__file__), "fonts") else: raise FileNotFoundError("Font directory not found.") + # Simple in-memory font cache to avoid repeated truetype loading cost. + self._font_cache = {} @staticmethod def concatenateImagesVertically(img1: Image, img2: Image, margin: int = 0) -> Image: @@ -71,29 +73,159 @@ class OverlayImage: } return colors - def createTextboxSmallLog(self, text: str, language: str, text_color: Tuple[int, int, int], base_width: int, base_height: int, font_size: int) -> Image: - font_family = self.LANGUAGES.get(language, self.LANGUAGES["Default"]) - img = Image.new("RGBA", (base_width, base_height), (0, 0, 0, 0)) - draw = ImageDraw.Draw(img) - + def _get_font(self, font_family: str, size: int) -> ImageFont.FreeTypeFont: font_path = os_path.join(self.root_path, font_family) - font = ImageFont.truetype(font_path, font_size) + key = (font_path, size) + if key not in self._font_cache: + self._font_cache[key] = ImageFont.truetype(font_path, size) + return self._font_cache[key] - text_width = draw.textlength(text, font) - character_width = text_width // len(text) - character_line_num = int((base_width // character_width) - 12) - if len(text) > character_line_num: - text = "\n".join([text[i:i + character_line_num] for i in range(0, len(text), character_line_num)]) - text_height = font_size * (len(text.split("\n")) + 1) + 20 + def createTextboxSmallLog(self, text: str, language: str, text_color: Tuple[int, int, int], base_width: int, base_height: int, font_size: int) -> Image: + if text is None: + text = "" + font_family = self.LANGUAGES.get(language, self.LANGUAGES["Default"]) + font = self._get_font(font_family, font_size) + + # Initial image for width measurement + img_tmp = Image.new("RGBA", (base_width, base_height), (0, 0, 0, 0)) + draw_tmp = ImageDraw.Draw(img_tmp) + try: + text_width = draw_tmp.textlength(text, font) if len(text) > 0 else 1 + character_width = max(1, text_width // max(1, len(text))) + character_line_num = int((base_width // character_width) - 12) + if len(text) > character_line_num and character_line_num > 0: + text = "\n".join([text[i:i + character_line_num] for i in range(0, len(text), character_line_num)]) + except Exception: + errorLogging() + lines = text.split("\n") if text else [""] + text_height = font_size * (len(lines) + 1) + 20 img = Image.new("RGBA", (base_width, text_height), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) - text_x = base_width // 2 text_y = text_height // 2 draw.text((text_x, text_y), text, text_color, anchor="mm", stroke_width=0, font=font, align="center") return img - def createOverlayImageSmallLog(self, message: str, your_language: str, translation: List[str] = [], target_language: List[str] = []) -> Image: + def renderRubyBlock(self, transliteration: List[dict], language: str, base_width: int, base_font_size: int, ruby_font_scale: float, ruby_line_spacing: int, text_color: Tuple[int, int, int]) -> Optional[Image.Image]: + # Build romaji and hiragana lines. + romaji_line = " ".join([t.get("hepburn", "") for t in transliteration if t.get("hepburn")]) + hira_line = " ".join([t.get("hira", "") for t in transliteration if t.get("hira")]) + if not romaji_line and not hira_line: + return None + font_family = self.LANGUAGES.get(language, self.LANGUAGES["Default"]) + ruby_size = max(1, int(base_font_size * ruby_font_scale)) + font_ruby = self._get_font(font_family, ruby_size) + # Symmetric outer padding so ruby block has breathing room top/bottom + outer_padding = 10 + # Measure widths to center lines independently. + img_tmp = Image.new("RGBA", (base_width, ruby_size * 2 + ruby_line_spacing + outer_padding * 2), (0, 0, 0, 0)) + draw_tmp = ImageDraw.Draw(img_tmp) + romaji_width = draw_tmp.textlength(romaji_line, font_ruby) if romaji_line else 0 + hira_width = draw_tmp.textlength(hira_line, font_ruby) if hira_line else 0 + romaji_x = (base_width - romaji_width) // 2 + hira_x = (base_width - hira_width) // 2 + # Construct final ruby image with symmetric padding + ruby_height = outer_padding + ruby_size * (2 if hira_line and romaji_line else 1) + (ruby_line_spacing if hira_line and romaji_line else 0) + outer_padding + ruby_img = Image.new("RGBA", (base_width, ruby_height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(ruby_img) + current_y = outer_padding + ruby_size // 2 + if romaji_line: + draw.text((romaji_x + romaji_width // 2, current_y), romaji_line, text_color, anchor="mm", font=font_ruby) + current_y += ruby_size + (ruby_line_spacing if hira_line else 0) + if hira_line: + draw.text((hira_x + hira_width // 2, current_y), hira_line, text_color, anchor="mm", font=font_ruby) + return ruby_img + + def createTextboxSmallLogWithRubyTokens(self, message: str, transliteration: List[dict], language: str, text_color: Tuple[int, int, int], base_width: int, font_size: int, ruby_font_scale: float, ruby_line_spacing: int, ruby_original_spacing: int) -> Image: + """Render a single textbox (original message) with per-token centered ruby (romaji above hiragana) over each original token. + + Fallback: if wrapping would occur (message too wide) or tokens mismatch, revert to block-level ruby (renderRubyBlock + createTextboxSmallLog). + """ + if not message or not transliteration: + return self.createTextboxSmallLog(message, language, text_color, base_width, self.getUiSizeSmallLog()["height"], font_size) + + # Obtain font instances + font_family = self.LANGUAGES.get(language, self.LANGUAGES["Default"]) + font_orig = self._get_font(font_family, font_size) + ruby_size = max(1, int(font_size * ruby_font_scale)) + font_ruby = self._get_font(font_family, ruby_size) + + # Token width measurement + draw_tmp_img = Image.new("RGBA", (1, 1), (0, 0, 0, 0)) + draw_tmp = ImageDraw.Draw(draw_tmp_img) + token_infos = [] + total_width = 0 + for tok in transliteration: + orig = tok.get("orig", "") + if not orig: + continue + hira = tok.get("hira", "") + romaji = tok.get("hepburn", "") + orig_w = max(1, int(draw_tmp.textlength(orig, font_orig))) + hira_w = max(0, int(draw_tmp.textlength(hira, font_ruby))) if hira else 0 + romaji_w = max(0, int(draw_tmp.textlength(romaji, font_ruby))) if romaji else 0 + layout_w = max(orig_w, hira_w, romaji_w) # allocate width so ruby lines never overflow neighboring token + token_infos.append((orig, hira, romaji, layout_w)) + total_width += layout_w + + if not token_infos: + # Fallback + ruby_block = self.renderRubyBlock(transliteration, language, base_width, font_size, ruby_font_scale, ruby_line_spacing, text_color) + base_img = self.createTextboxSmallLog(message, language, text_color, base_width, self.getUiSizeSmallLog()["height"], font_size) + if ruby_block: + return self.concatenateImagesVertically(ruby_block, base_img) + return base_img + + # Simple wrapping detection: if total width exceeds base_width * 0.9 → fallback + if total_width > base_width * 0.9: + ruby_block = self.renderRubyBlock(transliteration, language, base_width, font_size, ruby_font_scale, ruby_line_spacing, text_color) + base_img = self.createTextboxSmallLog(message, language, text_color, base_width, self.getUiSizeSmallLog()["height"], font_size) + if ruby_block: + return self.concatenateImagesVertically(ruby_block, base_img) + return base_img + + # Compute left start for centering complete line + start_x = (base_width - total_width) // 2 + # Vertical positioning + # Symmetric outer padding: make top padding equal to bottom padding (previously top was 4, bottom ~10) + outer_padding = 10 # uniform top & bottom padding for visual balance + ruby_lines_count = 0 + has_romaji_any = any(r for (_, _, r, _) in token_infos) + has_hira_any = any(h for (_, h, _, _) in token_infos) + if has_romaji_any: + ruby_lines_count += 1 + if has_hira_any: + ruby_lines_count += 1 + # Height calculation (replace asymmetric 4/10 with symmetric outer_padding) + ruby_block_height = ruby_lines_count * ruby_size + (ruby_line_spacing if ruby_lines_count == 2 else 0) + total_height = outer_padding + ruby_block_height + ruby_original_spacing + font_size + outer_padding + img = Image.new("RGBA", (base_width, total_height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + + # Y centers + current_y = outer_padding + ruby_size // 2 + romaji_y = current_y if has_romaji_any else None + hira_y = None + if has_romaji_any and has_hira_any: + hira_y = romaji_y + ruby_size + ruby_line_spacing + elif has_hira_any: + hira_y = current_y + + orig_y = outer_padding + ruby_block_height + ruby_original_spacing + font_size // 2 + + # Draw tokens sequentially + cursor_x = start_x + for orig, hira, romaji, w in token_infos: + token_center_x = cursor_x + w // 2 + if romaji_y is not None and romaji: + draw.text((token_center_x, romaji_y), romaji, text_color, anchor="mm", font=font_ruby) + if hira_y is not None and hira: + draw.text((token_center_x, hira_y), hira, text_color, anchor="mm", font=font_ruby) + draw.text((token_center_x, orig_y), orig, text_color, anchor="mm", font=font_orig) + cursor_x += w + return img + + def createOverlayImageSmallLog(self, message: str, your_language: str, translation: List[str] = [], target_language: List[str] = [], transliteration_message: List[dict] = [], transliteration_translation: List[List[dict]] = [], ruby_font_scale: float = 0.5, ruby_line_spacing: int = 4) -> Image: # UI設定を取得 ui_size = self.getUiSizeSmallLog() width, height, font_size = ui_size["width"], ui_size["height"], ui_size["font_size"] @@ -107,29 +239,66 @@ class OverlayImage: textbox_images = [] # 翻訳がある場合 + # Use improved per-token placement if possible; else fallback to previous block approach. + ruby_original_spacing = 2 # Narrow vertical gap between hiragana block and original text. if translation and target_language: - # 元のメッセージがある場合は追加 if message: - textbox_images.append( - self.createTextboxSmallLog(message, your_language, text_color, width, height, font_size) - ) - - # 翻訳をすべて追加 - for trans, lang in zip(translation, target_language): - textbox_images.append( - self.createTextboxSmallLog(trans, lang, text_color, width, height, font_size) - ) + base_msg_img = self.createTextboxSmallLog(message, your_language, text_color, width, height, font_size) + textbox_images.append(base_msg_img) + for trans, lang, translite in zip(translation, target_language, transliteration_translation): + try: + trans_img = self.createTextboxSmallLogWithRubyTokens( + trans, + translite, + lang, + text_color, + width, + font_size, + ruby_font_scale, + ruby_line_spacing, + ruby_original_spacing, + ) + except Exception: + errorLogging() + trans_img = self.createTextboxSmallLog(trans, lang, text_color, width, height, font_size) + textbox_images.append(trans_img) else: - # 翻訳がない場合は元のメッセージのみ - textbox_images.append( - self.createTextboxSmallLog(message, your_language, text_color, width, height, font_size) - ) + # 翻訳無しモード + if message and transliteration_message: + try: + base_msg_img = self.createTextboxSmallLogWithRubyTokens( + message, + transliteration_message, + your_language, + text_color, + width, + font_size, + ruby_font_scale, + ruby_line_spacing, + ruby_original_spacing, + ) + except Exception: + errorLogging() + base_msg_img = self.createTextboxSmallLog(message, your_language, text_color, width, height, font_size) + try: + ruby_img = self.renderRubyBlock(transliteration_message, your_language, width, font_size, ruby_font_scale, ruby_line_spacing, text_color) + if ruby_img is not None: + base_msg_img = self.concatenateImagesVertically(ruby_img, base_msg_img) + except Exception: + errorLogging() + else: + base_msg_img = self.createTextboxSmallLog(message, your_language, text_color, width, height, font_size) + textbox_images.append(base_msg_img) # すべてのテキストボックスを縦に結合 img = textbox_images[0] for textbox_img in textbox_images[1:]: img = self.concatenateImagesVertically(img, textbox_img) + # 画像周囲にUIパディングを追加して、文字が端に張り付かないようにする + ui_outer_padding = 50 + img = self.addImageMargin(img, ui_outer_padding, ui_outer_padding, ui_outer_padding, ui_outer_padding, (0, 0, 0, 0)) + # 角丸背景を作成 background = Image.new("RGBA", img.size, (0, 0, 0, 0)) draw = ImageDraw.Draw(background) @@ -191,6 +360,93 @@ class OverlayImage: draw.multiline_text((text_x, text_y), text, text_color, anchor=anchor, stroke_width=0, font=font, align=align) return img + def createTextboxLargeLogWithRubyTokens(self, message_type: str, size: str, message: str, transliteration: List[dict], language: str, ruby_font_scale: float, ruby_line_spacing: int) -> Image: + """Render a large-log textbox with per-token centered ruby above each original token. + + Falls back to block-level ruby if message wraps or tokens mismatch. + """ + ui_size = self.getUiSizeLargeLog() + font_size = ui_size["font_size_large"] if size == "large" else ui_size["font_size_small"] + text_color = self.getUiColorLargeLog()[f"text_color_{size}"] + font_family = self.LANGUAGES.get(language, self.LANGUAGES["Default"]) + font_orig = self._get_font(font_family, font_size) + ruby_size = max(1, int(font_size * ruby_font_scale)) + font_ruby = self._get_font(font_family, ruby_size) + + # Simple guard + if not message or not transliteration: + return self.createTextImageLargeLog(message_type, size, message, language) + + # Reject multiline for per-token layout; fallback to block ruby + if "\n" in message: + ruby_block = self.renderRubyBlock(transliteration, language, ui_size["width"], font_size, ruby_font_scale, ruby_line_spacing, text_color) + base_img = self.createTextImageLargeLog(message_type, size, message, language) + if ruby_block is not None: + return self.concatenateImagesVertically(ruby_block, base_img) + return base_img + + # Measure token widths + draw_tmp_img = Image.new("RGBA", (1, 1), (0, 0, 0, 0)) + draw_tmp = ImageDraw.Draw(draw_tmp_img) + token_infos = [] + total_width = 0 + for tok in transliteration: + orig = tok.get("orig", "") + if not orig: + continue + hira = tok.get("hira", "") + romaji = tok.get("hepburn", "") + orig_w = max(1, int(draw_tmp.textlength(orig, font_orig))) + hira_w = max(0, int(draw_tmp.textlength(hira, font_ruby))) if hira else 0 + romaji_w = max(0, int(draw_tmp.textlength(romaji, font_ruby))) if romaji else 0 + layout_w = max(orig_w, hira_w, romaji_w) + token_infos.append((orig, hira, romaji, layout_w)) + total_width += layout_w + + # Fallback if nothing to render or would overflow + base_width = ui_size["width"] + if not token_infos or total_width > base_width * 0.9: + ruby_block = self.renderRubyBlock(transliteration, language, base_width, font_size, ruby_font_scale, ruby_line_spacing, text_color) + base_img = self.createTextImageLargeLog(message_type, size, message, language) + if ruby_block is not None: + return self.concatenateImagesVertically(ruby_block, base_img) + return base_img + + # Determine start_x according to message type (left for receive, right-align for send) + start_x = 0 if message_type == "receive" else (base_width - total_width) + + # Vertical layout + outer_padding = 10 + has_romaji_any = any(r for (_, _, r, _) in token_infos) + has_hira_any = any(h for (_, h, _, _) in token_infos) + ruby_lines_count = (1 if has_romaji_any else 0) + (1 if has_hira_any else 0) + ruby_block_height = ruby_lines_count * ruby_size + (ruby_line_spacing if ruby_lines_count == 2 else 0) + total_height = outer_padding + ruby_block_height + font_size + outer_padding + img = Image.new("RGBA", (base_width, total_height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + + # y positions + current_y = outer_padding + ruby_size // 2 + romaji_y = current_y if has_romaji_any else None + hira_y = None + if has_romaji_any and has_hira_any: + hira_y = romaji_y + ruby_size + ruby_line_spacing + elif has_hira_any: + hira_y = current_y + orig_y = outer_padding + ruby_block_height + font_size // 2 + + # Draw tokens + cursor_x = start_x + for orig, hira, romaji, w in token_infos: + token_center_x = cursor_x + w // 2 + if romaji_y is not None and romaji: + draw.text((token_center_x, romaji_y), romaji, text_color, anchor="mm", font=font_ruby) + if hira_y is not None and hira: + draw.text((token_center_x, hira_y), hira, text_color, anchor="mm", font=font_ruby) + draw.text((token_center_x, orig_y), orig, text_color, anchor="mm", font=font_orig) + cursor_x += w + return img + def createTextImageMessageType(self, message_type: str, date_time: str) -> Image: ui_size = self.getUiSizeLargeLog() font_size = ui_size["font_size_small"] @@ -221,7 +477,7 @@ class OverlayImage: draw.text((text_x, text_y), text, text_color, anchor=anchor, stroke_width=0, font=font) return img - def createTextboxLargeLog(self, message_type: str, message: Optional[str] = None, your_language: Optional[str] = None, translation: List[str] = [], target_language: List[str] = [], date_time: Optional[str] = None) -> Image: + def createTextboxLargeLog(self, message_type: str, message: Optional[str] = None, your_language: Optional[str] = None, translation: List[str] = [], target_language: List[str] = [], date_time: Optional[str] = None, transliteration_message: Optional[List[dict]] = None, transliteration_translation: Optional[List[List[dict]]] = None, ruby_font_scale: float = 0.5, ruby_line_spacing: int = 4) -> Image: # テキスト画像のリストを作成 images = [self.createTextImageMessageType(message_type, date_time)] @@ -229,20 +485,25 @@ class OverlayImage: if translation and target_language: # 元のメッセージがある場合は小さいサイズで追加 if message is not None: - images.append( - self.createTextImageLargeLog(message_type, "small", message, your_language) - ) + small_img = self.createTextImageLargeLog(message_type, "small", message, your_language) + images.append(small_img) # 翻訳をすべて大きいサイズで追加 - for trans, lang in zip(translation, target_language): - images.append( - self.createTextImageLargeLog(message_type, "large", trans, lang) - ) + for trans, lang, translite in zip(translation, target_language, transliteration_translation): + try: + large_img = self.createTextboxLargeLogWithRubyTokens(message_type, "large", trans, translite, lang, ruby_font_scale, ruby_line_spacing) + except Exception: + errorLogging() + large_img = self.createTextImageLargeLog(message_type, "large", trans, lang) + images.append(large_img) else: # 翻訳がない場合は元のメッセージのみ - images.append( - self.createTextImageLargeLog(message_type, "large", message, your_language) - ) + try: + large_img = self.createTextboxLargeLogWithRubyTokens(message_type, "large", message, transliteration_message, your_language, ruby_font_scale, ruby_line_spacing) + except Exception: + errorLogging() + large_img = self.createTextImageLargeLog(message_type, "large", message, your_language) + images.append(large_img) # すべてのテキスト画像を縦に結合 combined_img = images[0] @@ -251,7 +512,7 @@ class OverlayImage: return combined_img - def createOverlayImageLargeLog(self, message_type: str, message: Optional[str] = None, your_language: Optional[str] = None, translation: List[str] = [], target_language: List[str] = []) -> Image: + def createOverlayImageLargeLog(self, message_type: str, message: Optional[str] = None, your_language: Optional[str] = None, translation: List[str] = [], target_language: List[str] = [], transliteration_message: List[dict] = [], transliteration_translation: List[List[dict]] = [], ruby_font_scale: float = 0.5, ruby_line_spacing: int = 4) -> Image: ui_color = self.getUiColorLargeLog() background_color = ui_color["background_color"] background_outline_color = ui_color["background_outline_color"] @@ -267,6 +528,8 @@ class OverlayImage: "your_language": your_language, "translation": translation, "target_language": target_language, + "transliteration_message": transliteration_message, + "transliteration_translation": transliteration_translation, "datetime": datetime.now().strftime("%H:%M") }) @@ -280,7 +543,12 @@ class OverlayImage: log["your_language"], log["translation"], log["target_language"], - log["datetime"]) for log in self.message_log + log["datetime"], + transliteration_message=log.get("transliteration_message", [{}]), + transliteration_translation=log.get("transliteration_translation", [{}]), + ruby_font_scale=ruby_font_scale, + ruby_line_spacing=ruby_line_spacing, + ) for log in self.message_log ] img = imgs[0] @@ -297,18 +565,103 @@ class OverlayImage: if __name__ == "__main__": overlay = OverlayImage() - img = overlay.createOverlayImageSmallLog("Hello, World!", "English", "こんにちは、世界!", "Japanese") + # Basic small log test (with translation list form) + img = overlay.createOverlayImageSmallLog("Hello, World!", "English", ["こんにちは、世界!"], ["Japanese"], [], [[]]) img.save("overlay_small.png") - img = overlay.createOverlayImageLargeLog("send", "Hello, World!", "English", "こんにちは、世界!", "Japanese") - img = overlay.createOverlayImageLargeLog("receive", "こんにちは、世界!", "Japanese", "Hello, World!", "English") - img = overlay.createOverlayImageLargeLog("send", "Hello, World!aaaaaaaaaaaaaaaaaあああああああああああああああaaaaaaaaaaaaaaaaaあああああああああああああああ", "English", "aaaaaaaaaaaaaaaaaあああああああああああああああaaaaaaaaaaaaaaaaaあああああああああああああああこんにちは、世界!", "Japanese") - img = overlay.createOverlayImageLargeLog("receive", "こんにちは、世界!aaaaaaaaaaaaaaaaaあああああああああああああああaaaaaaaaaaaaaaaaaあああああああああああああああ", "Japanese", "Hello, World!aaaaaaaaaaaaaaaaaあああああああああああああああaaaaaaaaaaaaaaaaaあああああああああああああああ", "English") - img = overlay.createOverlayImageLargeLog("send", "Hello, World!", "English", "こんにちは、世界!", "Japanese") - img = overlay.createOverlayImageLargeLog("receive", "こんにちは、世界!", "Japanese", "Hello, World!", "English") - img = overlay.createOverlayImageLargeLog("send", "Hello, World!aaaaaaaaaaaaaaaaaあああああああああああああああaaaaaaaaaaaaaaaaaあああああああああああああああ", "English", "aaaaaaaaaaaaaaaaaあああああああああああああああaaaaaaaaaaaaaaaaaあああああああああああああああこんにちは、世界!", "Japanese") - img = overlay.createOverlayImageLargeLog("receive", "こんにちは、世界!aaaaaaaaaaaaaaaaaあああああああああああああああaaaaaaaaaaaaaaaaaあああああああああああああああ", "Japanese", "Hello, World!aaaaaaaaaaaaaaaaaあああああああああああああああaaaaaaaaaaaaaaaaaあああああああああああああああ", "English") - img = overlay.createOverlayImageLargeLog("send", "Hello, World!", "English", "こんにちは、世界!", "Japanese") - img = overlay.createOverlayImageLargeLog("receive", "こんにちは、世界!", "Japanese", "Hello, World!", "English") - img = overlay.createOverlayImageLargeLog("send", "Hello, World!aaaaaaaaaaaaaaaaaあああああああああああああああaaaaaaaaaaaaaaaaaあああああああああああああああ", "English", "aaaaaaaaaaaaaaaaaあああああああああああああああaaaaaaaaaaaaaaaaaあああああああああああああああこんにちは、世界!", "Japanese") - img = overlay.createOverlayImageLargeLog("receive", "こんにちは、世界!aaaaaaaaaaaaaaaaaあああああああああああああああaaaaaaaaaaaaaaaaaあああああああああああああああ", "Japanese", "Hello, World!aaaaaaaaaaaaaaaaaあああああああああああああああaaaaaaaaaaaaaaaaaあああああああああああああああ", "English") + + # Ruby small log test (Japanese original with transliteration tokens) + ruby_tokens = [ + {"orig": "慮", "hira": "おもんぱか", "hepburn": "omonpaka"}, + {"orig": "る", "hira": "る", "hepburn": "ru"}, + ] + # Ruby on original + ruby on translation example + translation_tokens = [ + [ + {"orig": "慮", "hira": "おもんぱか", "hepburn": "omonpaka"}, + {"orig": "る", "hira": "る", "hepburn": "ru"}, + ], + [ + {"orig": "慮", "hira": "おもんぱか", "hepburn": "omonpaka"}, + {"orig": "る", "hira": "る", "hepburn": "ru"}, + ], + [ + {"orig": "慮", "hira": "おもんぱか", "hepburn": "omonpaka"}, + {"orig": "る", "hira": "る", "hepburn": "ru"}, + ] + ] + img_ruby = overlay.createOverlayImageSmallLog( + "慮る", + "Japanese", + ["慮る", "慮る", "慮る"], + ["Japanese", "Japanese", "Japanese"], + transliteration_message=ruby_tokens, + transliteration_translation=translation_tokens, + ruby_font_scale=0.5, + ruby_line_spacing=4, + ) + img_ruby.save("overlay_small_ruby.png") + + # Large log tests (adjusted to pass translation/target_language as lists) + img = overlay.createOverlayImageLargeLog("send", "Hello, World!", "English", ["こんにちは、世界!"], ["Japanese"], transliteration_message=[], transliteration_translation=[]) + img = overlay.createOverlayImageLargeLog("receive", "こんにちは、世界!", "Japanese", ["Hello, World!"], ["English"], transliteration_message=[ + {"orig": "慮", "hira": "おもんぱか", "hepburn": "omonpaka"}, + {"orig": "る", "hira": "る", "hepburn": "ru"}, + ], transliteration_translation=[ + [ + {"orig": "慮", "hira": "おもんぱか", "hepburn": "omonpaka"}, + {"orig": "る", "hira": "る", "hepburn": "ru"}, + ] + ]) + long_en = "Hello, World!" + long_jp = "こんにちは、世界!" + img = overlay.createOverlayImageLargeLog("send", long_en, "English", [long_jp], ["Japanese"], transliteration_message=[ + {"orig": "慮", "hira": "おもんぱか", "hepburn": "omonpaka"}, + {"orig": "る", "hira": "る", "hepburn": "ru"}, + ], transliteration_translation=[[]]) + img = overlay.createOverlayImageLargeLog("receive", long_jp, "Japanese", [long_en], ["English"], transliteration_message=[], transliteration_translation=[ + [ + {"orig": "慮", "hira": "おもんぱか", "hepburn": "omonpaka"}, + {"orig": "る", "hira": "る", "hepburn": "ru"}, + ] + ]) + img = overlay.createOverlayImageLargeLog("send", "Hello, World!", "English", ["こんにちは、世界!"], ["Japanese"], transliteration_message=[ + {"orig": "慮", "hira": "おもんぱか", "hepburn": "omonpaka"}, + {"orig": "る", "hira": "る", "hepburn": "ru"}, + ], transliteration_translation=[[]]) + + img = overlay.createOverlayImageLargeLog("receive", "こんにちは、世界!", "Japanese", ["Hello, World!"], ["English"], transliteration_message=[ + {"orig": "こんにちは", "hira": "こんにちは", "hepburn": "konnichiha"}, + {"orig": "世界", "hira": "sekai", "hepburn": "sekai"}, + ], transliteration_translation=[ + [ + {"orig": "慮", "hira": "おもんぱか", "hepburn": "omonpaka"}, + {"orig": "る", "hira": "る", "hepburn": "ru"}, + ] + ]) + img = overlay.createOverlayImageLargeLog("send", long_en, "English", [long_jp], ["Japanese"], transliteration_message=[ + {"orig": "慮", "hira": "おもんぱか", "hepburn": "omonpaka"}, + {"orig": "る", "hira": "る", "hepburn": "ru"}, + ], transliteration_translation=[ + [ + {"orig": "慮", "hira": "おもんぱか", "hepburn": "omonpaka"}, + {"orig": "る", "hira": "る", "hepburn": "ru"}, + ] + ]) + img = overlay.createOverlayImageLargeLog("receive", long_jp, "Japanese", [long_en, long_en, long_en], ["English", "English", "English"], transliteration_message=[ + {"orig": "慮", "hira": "おもんぱか", "hepburn": "omonpaka"}, + {"orig": "る", "hira": "る", "hepburn": "ru"}, + ], transliteration_translation=[ + [ + {"orig": "慮", "hira": "おもんぱか", "hepburn": "omonpaka"}, + {"orig": "る", "hira": "る", "hepburn": "ru"}, + ], + [ + {"orig": "慮", "hira": "おもんぱか", "hepburn": "omonpaka"}, + {"orig": "る", "hira": "る", "hepburn": "ru"}, + ], + [ + {"orig": "慮", "hira": "おもんぱか", "hepburn": "omonpaka"}, + {"orig": "る", "hira": "る", "hepburn": "ru"}, + ] + ]) img.save("overlay_large.png") \ No newline at end of file