diff --git a/.env.example b/.env.example index b944254..5655b6c 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,9 @@ threads="1" #The wordwrap of dialogue text width="60" +#The narrower dialogue wrap used when code 101 displays a face graphic +faceWidth="50" + #The wordwrap of items and help text listWidth="100" diff --git a/data/skills/project_setup.md b/data/skills/project_setup.md index 9c376a1..03c3950 100644 --- a/data/skills/project_setup.md +++ b/data/skills/project_setup.md @@ -166,7 +166,11 @@ geometry rather than generic RPG Maker defaults whenever project code is availab 6. For dialogue, verify the recommendation against the actual message-row limit, commonly four. If a long message cannot fit without unreadable font reduction or horizontal overflow, flag it for pagination/manual reflow instead of forcing an unsafe four-line wrap. -7. Recommend one conservative shared width and one readable font size for each category. If the +7. Detect standard message faces from non-empty code-101 parameter 0 values. Calculate both the + full dialogue width and the reduced `faceWidth`; DazedTL selects `faceWidth` automatically for + those message groups. For plugin portraits outside code 101, document the detection gap and use + a conservative global width or recommend custom handling. +8. Recommend one conservative shared width and one readable font size for each category. If the current font should remain unchanged, say `keep current` and report its measured size. Cite the files/functions/plugin parameters supporting every recommendation and give a confidence level. @@ -179,7 +183,7 @@ Label the fence language as `rpgmaker_config`. Inside: ```text CODE408 : ENABLE|SKIP - -Dialogue : width= ; font= ; rows= +Dialogue : width= ; faceWidth= ; font= ; rows= List/Help: listWidth= ; font= ; rows= Notes : noteWidth= ; font= ; rows= diff --git a/data/skills/wrap_config.md b/data/skills/wrap_config.md index c8af06f..a17f81c 100644 --- a/data/skills/wrap_config.md +++ b/data/skills/wrap_config.md @@ -1,32 +1,48 @@ You are an expert RPGMaker MV/MZ configuration analyst. -Calculate the correct text-wrap width settings for a Japanese-to-English RPGMaker MV/MZ translation tool. The tool wraps translated English using character-count limits (not pixels). I need three values: width, listWidth, and noteWidth. +Calculate the correct text-wrap and font recommendations for a Japanese-to-English RPGMaker +MV/MZ translation tool. The tool wraps translated English using character-count limits, not pixels. +I need `width`, `faceWidth`, `listWidth`, and `noteWidth`, plus a font recommendation for dialogue, +list/help text, and notes. ---- attach System.json and js/plugins.js here before continuing --- +--- open the game repository and inspect its data/ and js/ folders before continuing --- -1. Read screenWidth and fontSize from System.json. - Check js/plugins.js for any MessageCore or Window plugin that overrides these values. -2. For each window type, estimate its pixel width, subtract ~48px padding, then calculate: - chars = floor(content_px / (font_size × 0.58)) - - width: main dialogue/message box (Show Text) — typically full screen width - - listWidth: item/skill/help description windows — typically full or half screen width - - noteWidth: database note fields — typically the narrowest pane (~40–50% screen width) -3. If font size is above 26px and reducing it would meaningfully increase characters per line, note where to change it (System.json or the relevant plugin parameter). +1. Read the resolution and base fonts from `System.json` and engine code. Inspect enabled entries in + `js/plugins.js` and their plugin sources for message, help, list, note, portrait, font, padding, + line-height, column, and window-size overrides. +2. For dialogue, inspect code-101 commands. A non-empty parameter 0 is a standard face graphic and + reserves horizontal space. Calculate both the full `width` and reduced `faceWidth`; DazedTL + selects `faceWidth` automatically for those message groups. Identify plugin portraits that do + not use code 101 as exceptions requiring a conservative width or custom handling. +3. For Dialogue, List/Help, and Notes, calculate usable pixels from the actual window geometry: + subtract padding, text inset, faces/portraits, icons, columns, and plugin margins. Calculate row + capacity from usable height and line height. +4. Account for the real font face and base size plus `\\{`, `\\}`, `\\FS[n]`, custom font codes, + inline icons/images, and plugin scaling. Measure representative English glyphs in the real font + when possible; otherwise state the conservative average used. Never copy pixels directly into a + character-count setting. +5. Test representative short, long, icon-heavy, control-code-heavy, and font-changed values. Check + dialogue against its real row limit, commonly four. Recommend pagination/manual reflow when a + message cannot fit without unreadable font reduction or horizontal overflow. +6. Recommend a readable font for each category. Use `keep current (px)` when no game-side font + change is needed; otherwise cite the exact plugin parameter or function that would change it. -Output only the final values — do not show calculations: +Output the recommendations followed by compact evidence and exceptions: ``` -width= -listWidth= -noteWidth= -fontSize= # or: no change needed -``` +Dialogue : width= ; faceWidth= ; font= ; rows= +List/Help: listWidth= ; font= ; rows= +Notes : noteWidth= ; font= ; rows= -Followed by one sentence of assumptions if anything was estimated. +Evidence: +- + +Exceptions / playtests: +- +``` - diff --git a/gui/config_tab.py b/gui/config_tab.py index e7f2324..de97510 100644 --- a/gui/config_tab.py +++ b/gui/config_tab.py @@ -806,6 +806,18 @@ class ConfigTab(QWidget): self.width_spin.setSuffix(" chars") add_row(trans_section, "Dialogue Width:", size_field(self.width_spin)) + self.face_width_spin = QSpinBox() + self.face_width_spin.setButtonSymbols(QSpinBox.NoButtons) + self.face_width_spin.setRange(10, 200) + self.face_width_spin.setValue(50) + self.face_width_spin.setSuffix(" chars") + self.face_width_spin.setToolTip( + "Dialogue wrap width when a standard code-101 face graphic reserves window space." + ) + self.face_width_spin.setMaximum(self.width_spin.value()) + self.width_spin.valueChanged.connect(self.face_width_spin.setMaximum) + add_row(trans_section, "Dialogue + Face:", size_field(self.face_width_spin)) + self.list_width_spin = QSpinBox() self.list_width_spin.setButtonSymbols(QSpinBox.NoButtons) self.list_width_spin.setRange(20, 200) @@ -1210,6 +1222,7 @@ class ConfigTab(QWidget): # Formatting settings self.width_spin.setValue(int(_get("width", "60"))) + self.face_width_spin.setValue(int(_get("faceWidth", "50"))) self.list_width_spin.setValue(int(_get("listWidth", "100"))) self.note_width_spin.setValue(int(_get("noteWidth", "75"))) self.convert_quotes_cb.setChecked( @@ -1274,6 +1287,7 @@ class ConfigTab(QWidget): self.batch_size_spin.editingFinished.connect(self.auto_save) self.frequency_penalty_spin.editingFinished.connect(self.auto_save) self.width_spin.editingFinished.connect(self.auto_save) + self.face_width_spin.editingFinished.connect(self.auto_save) self.list_width_spin.editingFinished.connect(self.auto_save) self.note_width_spin.editingFinished.connect(self.auto_save) self.convert_quotes_cb.stateChanged.connect(self._on_convert_quotes_changed) @@ -1299,6 +1313,7 @@ class ConfigTab(QWidget): self.batch_size_spin.editingFinished.disconnect(self.auto_save) self.frequency_penalty_spin.editingFinished.disconnect(self.auto_save) self.width_spin.editingFinished.disconnect(self.auto_save) + self.face_width_spin.editingFinished.disconnect(self.auto_save) self.list_width_spin.editingFinished.disconnect(self.auto_save) self.note_width_spin.editingFinished.disconnect(self.auto_save) self.convert_quotes_cb.stateChanged.disconnect(self._on_convert_quotes_changed) @@ -1358,6 +1373,7 @@ class ConfigTab(QWidget): "batchsize": str(self.batch_size_spin.value()), "frequency_penalty": str(self.frequency_penalty_spin.value()), "width": str(self.width_spin.value()), + "faceWidth": str(min(self.width_spin.value(), self.face_width_spin.value())), "listWidth": str(self.list_width_spin.value()), "noteWidth": str(self.note_width_spin.value()), "convertQuotes": "true" if self.convert_quotes_cb.isChecked() else "false", @@ -1422,6 +1438,7 @@ class ConfigTab(QWidget): # Formatting settings self.width_spin.setValue(60) + self.face_width_spin.setValue(50) self.list_width_spin.setValue(100) self.note_width_spin.setValue(75) self.convert_quotes_cb.setChecked(True) @@ -1468,6 +1485,7 @@ class ConfigTab(QWidget): "batchsize": self.batch_size_spin.value(), "frequency_penalty": self.frequency_penalty_spin.value(), "width": self.width_spin.value(), + "faceWidth": min(self.width_spin.value(), self.face_width_spin.value()), "listWidth": self.list_width_spin.value(), "noteWidth": self.note_width_spin.value(), "convertQuotes": self.convert_quotes_cb.isChecked(), diff --git a/gui/workflow_tab.py b/gui/workflow_tab.py index 4f16e50..9ff6781 100644 --- a/gui/workflow_tab.py +++ b/gui/workflow_tab.py @@ -1744,13 +1744,14 @@ class WorkflowTab(QWidget): wrap_hint = QLabel( "Use Project Setup's measured rpgmaker_config recommendations, then apply the three " - "wrap widths to .env. Font recommendations describe the game UI and are not applied here." + "category widths plus the dialogue-face variant to .env. Font recommendations " + "describe the game UI and are not applied here." ) wrap_hint.setWordWrap(True) wrap_hint.setStyleSheet("color:#9d9d9d;font-size:13px;") wrap_inner.addWidget(wrap_hint) - # All three spinboxes on one row + # Wrap settings on one row, including the narrower code-101 face variant. spins_row = QHBoxLayout() spins_row.setSpacing(16) @@ -1764,10 +1765,14 @@ class WorkflowTab(QWidget): return lbl, sp lbl_w, self.wrap_width_spin = _spin_pair("Dialogue", 60) + lbl_fw, self.wrap_face_spin = _spin_pair("Dialogue+Face", 50) + self.wrap_face_spin.setMaximum(self.wrap_width_spin.value()) + self.wrap_width_spin.valueChanged.connect(self.wrap_face_spin.setMaximum) lbl_lw, self.wrap_list_spin = _spin_pair("List/Help", 70) lbl_nw, self.wrap_note_spin = _spin_pair("Notes", 60) for lbl, sp in [(lbl_w, self.wrap_width_spin), + (lbl_fw, self.wrap_face_spin), (lbl_lw, self.wrap_list_spin), (lbl_nw, self.wrap_note_spin)]: spins_row.addWidget(lbl) @@ -1776,7 +1781,9 @@ class WorkflowTab(QWidget): apply_wrap_btn = _make_btn("✔ Apply to .env", "#3a7a3a") apply_wrap_btn.setFixedWidth(140) - apply_wrap_btn.setToolTip("Write width / listWidth / noteWidth into .env") + apply_wrap_btn.setToolTip( + "Write width / faceWidth / listWidth / noteWidth into .env" + ) apply_wrap_btn.clicked.connect(self._apply_wrap_config) spins_row.addWidget(apply_wrap_btn) @@ -3840,10 +3847,11 @@ class WorkflowTab(QWidget): self._log(f"❌ Could not copy {filename}: {exc}") def _apply_wrap_config(self): - """Write width / listWidth / noteWidth back into .env.""" + """Write dialogue, face-dialogue, list, and note widths back into .env.""" import re as _re updates = { "width": str(self.wrap_width_spin.value()), + "faceWidth": str(min(self.wrap_width_spin.value(), self.wrap_face_spin.value())), "listWidth": str(self.wrap_list_spin.value()), "noteWidth": str(self.wrap_note_spin.value()), } diff --git a/modules/rpgmakermvmz.py b/modules/rpgmakermvmz.py index 1c5bc70..721fa8d 100644 --- a/modules/rpgmakermvmz.py +++ b/modules/rpgmakermvmz.py @@ -28,6 +28,10 @@ VOCAB = VOCAB_PATH.read_text(encoding="utf-8") LOCK = threading.Lock() THREAD_CTX = threading.local() WIDTH = int(os.getenv("width")) +FACEWIDTH = max( + 1, + min(WIDTH, int(os.getenv("faceWidth", str(max(1, WIDTH - 10))))), +) LISTWIDTH = int(os.getenv("listWidth")) NOTEWIDTH = int(os.getenv("noteWidth")) MAXHISTORY = 10 @@ -728,6 +732,16 @@ def _facename101_speaker(cmd) -> str | None: return None +def _101_has_face_graphic(cmd) -> bool: + """Return whether a standard code-101 command reserves face-image space.""" + params = cmd.get("parameters") or [] + return ( + len(params) >= 4 + and isinstance(params[0], str) + and bool(params[0].strip()) + ) + + def _entry_orig(entry) -> dict: """Return _original dict on a database entry, or empty dict if absent.""" orig = entry.get("_original") if isinstance(entry, dict) else None @@ -2332,7 +2346,7 @@ def searchCodes(page, pbar, jobList, filename): syncIndex = 0 maxHistory = MAXHISTORY VNameValue = None - reduceWidthFlag = False # Track if 101 code has non-empty first parameter + reduceWidthFlag = False # Track whether the active 101 reserves face space global LOCK global NAMESLIST global MISMATCH @@ -2366,6 +2380,17 @@ def searchCodes(page, pbar, jobList, filename): currentSourceGroup = [] nametag = "" + # Face layout affects the following message even when code-101 name + # translation is disabled or face-to-speaker resolution exits early. + if "code" in codeList[i] and codeList[i]["code"] == 101: + reduceWidthFlag = _101_has_face_graphic(codeList[i]) + elif ( + reduceWidthFlag + and "code" in codeList[i] + and codeList[i]["code"] not in (401, -1) + ): + reduceWidthFlag = False + ## Event Code: 401 Show Text if "code" in codeList[i] and codeList[i]["code"] in [401, 405, -1] and ((codeList[i]["code"] in [401, -1] and CODE401) or (codeList[i]["code"] == 405 and CODE405)): # Save Code and starting index (j) @@ -2756,7 +2781,7 @@ def searchCodes(page, pbar, jobList, filename): finalJAString = finalJAString.replace("
", " ") # Determine width based on reduceWidthFlag - currentWidth = WIDTH - 15 if reduceWidthFlag else WIDTH + currentWidth = FACEWIDTH if reduceWidthFlag else WIDTH if FIXTEXTWRAP is True and "_ABL" in nametag: translatedText = dazedwrap.wrapText(translatedText, width=100) @@ -3205,9 +3230,6 @@ def searchCodes(page, pbar, jobList, filename): # Grab String jaString = "" if len(codeList[i]["parameters"]) > 4: - # Set flag if first parameter has a non-empty string - if isinstance(codeList[i]["parameters"][0], str) and codeList[i]["parameters"][0].strip(): - reduceWidthFlag = True jaString = codeList[i]["parameters"][4] # Check for Var (only when parameters[0] is not a face file, # i.e. fewer than 4 params — standard code 101 always has 4: diff --git a/tests/test_config_tab.py b/tests/test_config_tab.py index 611e755..5a3d73d 100644 --- a/tests/test_config_tab.py +++ b/tests/test_config_tab.py @@ -38,6 +38,7 @@ class ConfigTabRegressionTests(unittest.TestCase): "batchsize", "frequency_penalty", "width", + "faceWidth", "listWidth", "noteWidth", "convertQuotes", @@ -89,6 +90,7 @@ class ConfigTabRegressionTests(unittest.TestCase): "batchsize=42", "frequency_penalty=0.75", "width=88", + "faceWidth=77", "listWidth=99", "noteWidth=111", "convertQuotes=false", @@ -142,6 +144,7 @@ class ConfigTabRegressionTests(unittest.TestCase): self.assertEqual(tab.batch_size_spin.value(), 42) self.assertAlmostEqual(tab.frequency_penalty_spin.value(), 0.75) self.assertEqual(tab.width_spin.value(), 88) + self.assertEqual(tab.face_width_spin.value(), 77) self.assertEqual(tab.list_width_spin.value(), 99) self.assertEqual(tab.note_width_spin.value(), 111) self.assertFalse(tab.convert_quotes_cb.isChecked()) @@ -206,6 +209,7 @@ class ConfigTabRegressionTests(unittest.TestCase): tab.language_combo, tab.timeout_spin, tab.width_spin, + tab.face_width_spin, tab.list_width_spin, tab.note_width_spin, ), @@ -319,6 +323,7 @@ class ConfigTabRegressionTests(unittest.TestCase): tab.batch_size_spin.setValue(64) tab.frequency_penalty_spin.setValue(1.25) tab.width_spin.setValue(72) + tab.face_width_spin.setValue(62) tab.list_width_spin.setValue(84) tab.note_width_spin.setValue(96) tab.convert_quotes_cb.setChecked(True) @@ -350,6 +355,7 @@ class ConfigTabRegressionTests(unittest.TestCase): "batchsize": "64", "frequency_penalty": "1.25", "width": "72", + "faceWidth": "62", "listWidth": "84", "noteWidth": "96", "convertQuotes": "true", @@ -374,6 +380,7 @@ class ConfigTabRegressionTests(unittest.TestCase): self.assertEqual(reloaded.batch_size_spin.value(), 64) self.assertAlmostEqual(reloaded.frequency_penalty_spin.value(), 1.25) self.assertEqual(reloaded.width_spin.value(), 72) + self.assertEqual(reloaded.face_width_spin.value(), 62) self.assertEqual(reloaded.list_width_spin.value(), 84) self.assertEqual(reloaded.note_width_spin.value(), 96) self.assertTrue(reloaded.convert_quotes_cb.isChecked()) @@ -409,6 +416,7 @@ class ConfigTabRegressionTests(unittest.TestCase): self.assertEqual(tab.batch_size_spin.value(), 30) self.assertAlmostEqual(tab.frequency_penalty_spin.value(), 0.05) self.assertEqual(tab.width_spin.value(), 60) + self.assertEqual(tab.face_width_spin.value(), 50) self.assertEqual(tab.list_width_spin.value(), 100) self.assertEqual(tab.note_width_spin.value(), 75) self.assertTrue(tab.convert_quotes_cb.isChecked()) @@ -431,6 +439,7 @@ class ConfigTabRegressionTests(unittest.TestCase): "batchsize": "30", "frequency_penalty": "0.05", "width": "60", + "faceWidth": "50", "listWidth": "100", "noteWidth": "75", "convertQuotes": "true", diff --git a/tests/test_mvmz_source_original.py b/tests/test_mvmz_source_original.py index 1ab6640..d29c076 100644 --- a/tests/test_mvmz_source_original.py +++ b/tests/test_mvmz_source_original.py @@ -10,6 +10,7 @@ import re import sys import unittest from pathlib import Path +from unittest.mock import patch ROOT = Path(__file__).resolve().parents[1] os.chdir(ROOT) @@ -192,6 +193,80 @@ def _has_japanese(s: str) -> bool: class TestMVMZSourceOriginal(unittest.TestCase): + def test_code101_face_detection_supports_mv_and_mz_shapes(self): + self.assertTrue( + mvmz._101_has_face_graphic( + {"parameters": ["Actor1", 0, 0, 2]} + ) + ) + self.assertTrue( + mvmz._101_has_face_graphic( + {"parameters": ["Actor1", 0, 0, 2, "Alice"]} + ) + ) + self.assertFalse( + mvmz._101_has_face_graphic( + {"parameters": ["", 0, 0, 2, "Alice"]} + ) + ) + + def test_code101_face_uses_configured_face_width_after_speaker_resolution(self): + page = { + "list": [ + { + "code": 101, + "indent": 0, + "parameters": ["___princess1", 0, 0, 2, ""], + }, + { + "code": 401, + "indent": 0, + "parameters": ["これは顔付きの長い台詞です。"], + }, + ] + } + widths = [] + + def capture_wrap(text, width): + widths.append(width) + return text + + with ( + patch.object(mvmz, "WIDTH", 60), + patch.object(mvmz, "FACEWIDTH", 50), + patch.object(mvmz, "FACENAME101", True), + patch.object(mvmz.dazedwrap, "wrapText", side_effect=capture_wrap), + ): + _run_search_codes(page) + + self.assertEqual(widths, [50]) + + def test_code101_without_face_uses_full_dialogue_width(self): + page = { + "list": [ + {"code": 101, "indent": 0, "parameters": ["", 0, 0, 2, ""]}, + { + "code": 401, + "indent": 0, + "parameters": ["これは通常幅の台詞です。"], + }, + ] + } + widths = [] + + def capture_wrap(text, width): + widths.append(width) + return text + + with ( + patch.object(mvmz, "WIDTH", 60), + patch.object(mvmz, "FACEWIDTH", 50), + patch.object(mvmz.dazedwrap, "wrapText", side_effect=capture_wrap), + ): + _run_search_codes(page) + + self.assertEqual(widths, [60]) + def test_choice_condition_prefix_parser_handles_nested_calls(self): prefix, label = mvmz._split_choice_condition_prefix( "if($gameSwitches.value(1) && v[31]>=4)迷宮四階" diff --git a/tests/test_translation_engine_dropdown.py b/tests/test_translation_engine_dropdown.py index bf9f887..80a2f47 100644 --- a/tests/test_translation_engine_dropdown.py +++ b/tests/test_translation_engine_dropdown.py @@ -35,6 +35,7 @@ class TranslationEngineDropdownTests(unittest.TestCase): "language", "timeout", "width", + "faceWidth", "listWidth", "noteWidth", ) diff --git a/tests/test_workflow_prompts.py b/tests/test_workflow_prompts.py index 765044f..b92e1fe 100644 --- a/tests/test_workflow_prompts.py +++ b/tests/test_workflow_prompts.py @@ -59,6 +59,7 @@ class WorkflowTranslationPromptTests(unittest.TestCase): self.assertIn("code408 : enable|skip", lowered) self.assertIn("translate code 408 plugin/comment text", lowered) self.assertIn("dialogue : width=", lowered) + self.assertIn("facewidth=", lowered) self.assertIn("list/help: listwidth=", lowered) self.assertIn("notes : notewidth=", lowered) self.assertIn("face/portrait reservation", lowered) @@ -103,6 +104,13 @@ class WorkflowTranslationPromptTests(unittest.TestCase): self.assertIn("QA Game Data Skill", workflow_source) self.assertIn('load_clipboard_skill("rpgmaker_translation_qa.md")', workflow_source) + def test_wrap_prompt_accounts_for_code101_faces_and_font_changes(self): + prompt = load_clipboard_skill("wrap_config.md").casefold() + self.assertIn("facewidth", prompt) + self.assertIn("non-empty parameter 0", prompt) + self.assertIn("plugin portraits", prompt) + self.assertIn("pagination/manual reflow", prompt) + def test_image_translation_prompt_ends_skips_with_recovery_options(self): prompt = load_clipboard_skill("image_translation.md")