From c56fae31414e74568f1c19798a3491581824ae3b Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Tue, 7 Jul 2026 14:56:47 -0500 Subject: [PATCH] WolfDawn namewrap --- .env.example | 9 +++ gui/wolf_workflow_tab.py | 151 +++++++++++++++++++++++++++++++++++++++ modules/wolfdawn.py | 31 +++++++- tests/test_wolf_names.py | 27 +++++++ tests/test_wolfdawn.py | 56 +++++++++++++++ util/wolf_names.py | 37 ++++++++++ 6 files changed, 310 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index f3c2e71..afe41b5 100644 --- a/.env.example +++ b/.env.example @@ -65,6 +65,15 @@ listWidth="100" #The wordwrap of items and help text noteWidth="75" +# WolfDawn names.json (Step 3): re-wrap selected note categories with dazedwrap +#wolfNameWrap="false" +#wolfNameWrapWidth="60" +#wolfNameWrapNotes='["├■プロフィール","説明"]' + +# WolfDawn dialogue (Step 4 Phase 2): re-wrap translated event text +#wolfWrap="true" +#wolfWidth="55" + #CSV delimiter character (comma, semicolon, or tab) csvDelimiter="," diff --git a/gui/wolf_workflow_tab.py b/gui/wolf_workflow_tab.py index 34639c9..f5219b2 100644 --- a/gui/wolf_workflow_tab.py +++ b/gui/wolf_workflow_tab.py @@ -2006,6 +2006,8 @@ class WolfWorkflowTab(QWidget): refresh_btn.clicked.connect(self._refresh_names_summary) layout.addWidget(refresh_btn) + self._add_name_wrap_options(layout) + layout.addWidget(_make_hr()) layout.addWidget(self._subheading("Translate names (Phase 0)")) layout.addWidget(self._desc( @@ -2044,6 +2046,154 @@ class WolfWorkflowTab(QWidget): summary = wolf_names.format_name_safety_summary(data) where = src.parent.name if src else WORK_DIR_NAME self.names_summary_label.setText(f"{summary}\nSource: {where}/{NAMES_JSON}") + self._refresh_name_wrap_categories() + + def _add_name_wrap_options(self, layout: QVBoxLayout): + """Per-category dazedwrap for names.json (Step 3).""" + layout.addWidget(_make_hr()) + layout.addWidget(self._subheading("Category word wrap (dazedwrap)")) + layout.addWidget(self._desc( + "Re-wrap translated name values in selected categories (profile blurbs, " + "descriptions, and other multi-line fields) to a character width so English " + "fits in-game UI. Short row labels are usually left alone. Applies on the " + "next Phase 0 run." + )) + + wrap_env = self._read_env_values(["wolfNameWrap", "wolfNameWrapWidth", "wolfNameWrapNotes"]) + self._name_wrap_enable_cb = QCheckBox("Apply dazedwrap to selected categories") + enabled = str(wrap_env.get("wolfNameWrap", "false")).strip().lower() == "true" + self._name_wrap_enable_cb.setChecked(enabled) + self._name_wrap_enable_cb.stateChanged.connect(self._apply_name_wrap_config) + layout.addWidget(self._name_wrap_enable_cb) + + width_row = QHBoxLayout() + width_lbl = QLabel("Wrap width (characters):") + width_lbl.setStyleSheet("color:#cccccc;font-size:12px;background:transparent;") + self._name_wrap_width_spin = QSpinBox() + self._name_wrap_width_spin.setRange(20, 300) + try: + self._name_wrap_width_spin.setValue(int(wrap_env.get("wolfNameWrapWidth") or 60)) + except ValueError: + self._name_wrap_width_spin.setValue(60) + self._name_wrap_width_spin.setFixedWidth(70) + self._name_wrap_width_spin.valueChanged.connect(self._apply_name_wrap_config) + width_row.addWidget(width_lbl) + width_row.addWidget(self._name_wrap_width_spin) + width_row.addStretch() + layout.addLayout(width_row) + + self._name_wrap_notes_list = QListWidget() + self._name_wrap_notes_list.setMaximumHeight(180) + self._name_wrap_notes_list.setStyleSheet( + "QListWidget{background-color:#252526;border:1px solid #3a3a3a;border-radius:4px;" + "color:#cccccc;font-size:12px;padding:2px;}" + "QListWidget::item{padding:3px 4px;}" + "QListWidget::item:selected{background:#264f78;color:#ffffff;}" + ) + self._name_wrap_notes_list.itemChanged.connect(self._apply_name_wrap_config) + layout.addWidget(self._name_wrap_notes_list) + + list_row = QHBoxLayout() + refresh_cats_btn = _make_btn("↻ Refresh categories", "#3a3a3a") + refresh_cats_btn.clicked.connect(self._refresh_name_wrap_categories) + sel_all_btn = _make_btn("Select all", "#3a3a3a") + sel_all_btn.clicked.connect(lambda: self._set_name_wrap_checks(True)) + sel_none_btn = _make_btn("Select none", "#3a3a3a") + sel_none_btn.clicked.connect(lambda: self._set_name_wrap_checks(False)) + list_row.addWidget(refresh_cats_btn) + list_row.addWidget(sel_all_btn) + list_row.addWidget(sel_none_btn) + list_row.addStretch() + layout.addLayout(list_row) + + self._saved_name_wrap_notes = wolf_names.parse_name_wrap_notes( + wrap_env.get("wolfNameWrapNotes", "") + ) + + def _set_name_wrap_checks(self, checked: bool): + if not hasattr(self, "_name_wrap_notes_list"): + return + state = Qt.Checked if checked else Qt.Unchecked + self._name_wrap_notes_list.blockSignals(True) + for i in range(self._name_wrap_notes_list.count()): + item = self._name_wrap_notes_list.item(i) + if item.flags() & Qt.ItemIsUserCheckable: + item.setCheckState(state) + self._name_wrap_notes_list.blockSignals(False) + self._apply_name_wrap_config() + + def _refresh_name_wrap_categories(self): + if not hasattr(self, "_name_wrap_notes_list"): + return + saved = getattr(self, "_saved_name_wrap_notes", frozenset()) + wrap_env = self._read_env_values(["wolfNameWrapNotes"]) + if wrap_env.get("wolfNameWrapNotes"): + saved = wolf_names.parse_name_wrap_notes(wrap_env["wolfNameWrapNotes"]) + self._saved_name_wrap_notes = saved + + self._name_wrap_notes_list.blockSignals(True) + self._name_wrap_notes_list.clear() + data, _src = self._load_names_document() + if not isinstance(data, dict): + item = QListWidgetItem("Run Step 0 (Extract text) first to list categories.") + item.setFlags(Qt.ItemIsEnabled) + self._name_wrap_notes_list.addItem(item) + self._name_wrap_notes_list.blockSignals(False) + return + + notes = wolf_names.collect_name_notes(data) + if not notes: + item = QListWidgetItem("No categories found in names.json.") + item.setFlags(Qt.ItemIsEnabled) + self._name_wrap_notes_list.addItem(item) + self._name_wrap_notes_list.blockSignals(False) + return + + db_labels = wolf_names.derive_db_labels("files") + for note in notes: + label = wolf_names.note_header(note, db_labels) + item = QListWidgetItem(label) + item.setData(Qt.UserRole, note) + item.setFlags(item.flags() | Qt.ItemIsUserCheckable) + item.setCheckState(Qt.Checked if note in saved else Qt.Unchecked) + self._name_wrap_notes_list.addItem(item) + self._name_wrap_notes_list.blockSignals(False) + + def _selected_name_wrap_notes(self) -> list[str]: + if not hasattr(self, "_name_wrap_notes_list"): + return [] + notes: list[str] = [] + for i in range(self._name_wrap_notes_list.count()): + item = self._name_wrap_notes_list.item(i) + if not (item.flags() & Qt.ItemIsUserCheckable): + continue + if item.checkState() == Qt.Checked: + note = item.data(Qt.UserRole) + if note: + notes.append(str(note)) + return notes + + def _apply_name_wrap_config(self, *args): + """Persist names.json category wrap settings to .env.""" + if not hasattr(self, "_name_wrap_enable_cb"): + return + notes = self._selected_name_wrap_notes() + enabled = self._name_wrap_enable_cb.isChecked() and bool(notes) + updates = { + "wolfNameWrap": "true" if enabled else "false", + "wolfNameWrapWidth": str(self._name_wrap_width_spin.value()), + "wolfNameWrapNotes": json.dumps(notes, ensure_ascii=False), + } + self._saved_name_wrap_notes = frozenset(notes) + if self._write_env_values(updates): + if enabled: + self._log( + "Name category wrap updated: " + f"width={updates['wolfNameWrapWidth']}, " + f"categories={len(notes)}" + ) + else: + self._log("Name category wrap disabled.") def _names_json_path(self) -> Path | None: """Locate names.json for reading its note categories (files/ then wolf_json/).""" @@ -2149,6 +2299,7 @@ class WolfWorkflowTab(QWidget): # Index 3 == Names: refresh the per-name safety summary. if idx == 3: self._refresh_names_summary() + self._refresh_name_wrap_categories() # Index 5 == Inject: keep the quick-inject picker in sync with translated/. elif idx == 5: self._refresh_inject_list() diff --git a/modules/wolfdawn.py b/modules/wolfdawn.py index 5b64509..596209b 100644 --- a/modules/wolfdawn.py +++ b/modules/wolfdawn.py @@ -32,6 +32,11 @@ Wrapping is applied to the dialogue *body* only - a speaker's nameplate line (th Configurable from the workflow (``.env``: ``wolfWrap`` / ``wolfWidth``); a width of 0 (or ``wolfWrap=false``) writes the model output back verbatim. +names.json category wrap (Step 3): selected ``note`` categories can be re-wrapped +with dazedwrap at a dedicated width (``.env``: ``wolfNameWrap``, +``wolfNameWrapWidth``, ``wolfNameWrapNotes``). Other name categories keep the +model output verbatim. + Speakers: WolfDawn tags each line with ``speaker`` / ``speaker_src``. For the first-line formats (``literal_line1`` / ``literal_line1_lowconf``) the speaker name is baked into line 1 of ``source``. Those lines are reshaped into the shared @@ -100,6 +105,15 @@ try: except ValueError: WRAPWIDTH = 0 +# names.json: optional per-category dazedwrap (Step 3 workflow UI). +NAME_WRAP_ENABLED, NAME_WRAP_WIDTH, NAME_WRAP_NOTES = wolf_names.load_name_wrap_config() + + +def reload_name_wrap_config() -> None: + """Re-read names.json category wrap settings from ``.env``.""" + global NAME_WRAP_ENABLED, NAME_WRAP_WIDTH, NAME_WRAP_NOTES + NAME_WRAP_ENABLED, NAME_WRAP_WIDTH, NAME_WRAP_NOTES = wolf_names.load_name_wrap_config() + # Pricing / batching from the configured model PRICING_CONFIG = getPricingConfig(MODEL) INPUTAPICOST = PRICING_CONFIG["inputAPICost"] @@ -133,6 +147,7 @@ def handleWolfDawn(filename, estimate): # Re-read workflow-configured settings so edits made this session take effect # even when translation runs in-process (the module import is cached). SPEAKER_CONFIG = wolf_speakers.load_config() + reload_name_wrap_config() # Reload the glossary so a later phase (DB text / dialogue) picks up names # that an earlier Phase 0 (names) harvested into vocab.txt. VOCAB = VOCAB_PATH.read_text(encoding="utf-8") @@ -213,6 +228,18 @@ def _wrap_body(body): return dazedwrap.wrapText(body, WRAPWIDTH) +def _wrap_names_entry(text, entry): + """Apply dazedwrap to a names.json entry when its ``note`` is selected in Step 3.""" + if not NAME_WRAP_ENABLED or NAME_WRAP_WIDTH <= 0: + return text + if not isinstance(text, str) or not text: + return text + note = str(entry.get("note", "")) + if note not in NAME_WRAP_NOTES: + return text + return dazedwrap.wrapText(text, NAME_WRAP_WIDTH) + + def _wrap_plain(text, is_firstline): """Wrap a non-reshaped entry, protecting a nameplate first line if present. @@ -289,7 +316,9 @@ def parseDocument(data, filename): for (entry, prefix, has_speaker, is_firstline), text in zip(plans, translated): if not isinstance(text, str): continue - if has_speaker: + if is_names: + entry["text"] = _wrap_names_entry(text, entry) + elif has_speaker: speaker_en, body_en = wolf_speakers.parse_prefixed(text) if speaker_en is not None: # Wrap only the body; the name stays on its own line. diff --git a/tests/test_wolf_names.py b/tests/test_wolf_names.py index 9e60096..b20fe9c 100644 --- a/tests/test_wolf_names.py +++ b/tests/test_wolf_names.py @@ -87,6 +87,33 @@ class TestNoteHeader(unittest.TestCase): self.assertEqual(wn.note_header("■MOBセリフ"), "■MOBセリフ") +class TestCollectNameNotes(unittest.TestCase): + DOC = { + "kind": "names", + "names": [ + {"source": "a", "note": "武器"}, + {"source": "b", "note": "技能"}, + {"source": "c", "note": "武器"}, + {"source": "d"}, + ], + } + + def test_collects_unique_sorted_notes(self): + self.assertEqual(wn.collect_name_notes(self.DOC), ["技能", "武器"]) + + def test_parse_json_notes(self): + self.assertEqual( + wn.parse_name_wrap_notes('["武器","├■プロフィール"]'), + frozenset({"武器", "├■プロフィール"}), + ) + + def test_parse_comma_separated_notes(self): + self.assertEqual( + wn.parse_name_wrap_notes("武器,技能"), + frozenset({"武器", "技能"}), + ) + + class TestDeriveDbLabels(unittest.TestCase): def setUp(self): self._tmp = tempfile.TemporaryDirectory() diff --git a/tests/test_wolfdawn.py b/tests/test_wolfdawn.py index 62fd666..5509cad 100644 --- a/tests/test_wolfdawn.py +++ b/tests/test_wolfdawn.py @@ -388,6 +388,62 @@ class TestWrapping(unittest.TestCase): self.assertEqual(line0.split("\n", 1)[0], "EN_市民") +class TestNameCategoryWrap(unittest.TestCase): + """dazedwrap for selected names.json note categories (Step 3).""" + + def setUp(self): + self._orig = (wd.NAME_WRAP_ENABLED, wd.NAME_WRAP_WIDTH, wd.NAME_WRAP_NOTES) + + def tearDown(self): + wd.NAME_WRAP_ENABLED, wd.NAME_WRAP_WIDTH, wd.NAME_WRAP_NOTES = self._orig + + def _set(self, enabled, width, notes): + wd.NAME_WRAP_ENABLED = enabled + wd.NAME_WRAP_WIDTH = width + wd.NAME_WRAP_NOTES = frozenset(notes) + + def test_wrap_names_entry_only_selected_notes(self): + self._set(True, 10, {"├■プロフィール"}) + long_text = "alpha beta gamma delta epsilon" + entry = {"note": "├■プロフィール"} + wrapped = wd._wrap_names_entry(long_text, entry) + self.assertGreater(wrapped.count("\n"), 0) + entry_other = {"note": "武器"} + self.assertEqual(wd._wrap_names_entry(long_text, entry_other), long_text) + + def test_wrap_names_entry_disabled_is_noop(self): + self._set(False, 10, {"├■プロフィール"}) + long_text = "alpha beta gamma delta epsilon" + entry = {"note": "├■プロフィール"} + self.assertEqual(wd._wrap_names_entry(long_text, entry), long_text) + + def test_names_translation_wraps_selected_category(self): + doc = { + "kind": "names", + "names": [ + {"source": "剣", "text": "剣", "note": "武器", "safety": "safe"}, + { + "source": "alpha beta gamma delta epsilon zeta", + "text": "alpha beta gamma delta epsilon zeta", + "note": "├■プロフィール", + "safety": "safe", + }, + ], + } + self._set(True, 8, {"├■プロフィール"}) + orig_wrap = wd.WRAP + wd.WRAP = True + wd.WRAPWIDTH = 4 + try: + (data, _t, err), _c = _WolfTranslateHarness().run(doc, "names.json") + finally: + wd.WRAP = orig_wrap + self.assertIsNone(err) + names = {n["note"]: n["text"] for n in data["names"]} + self.assertEqual(names["武器"], "EN_剣") + self.assertGreater(names["├■プロフィール"].count("\n"), 0) + + class TestOpenFiles(unittest.TestCase): def test_rejects_unknown_kind(self): with tempfile.TemporaryDirectory() as td: diff --git a/util/wolf_names.py b/util/wolf_names.py index 09e1152..73da4ee 100644 --- a/util/wolf_names.py +++ b/util/wolf_names.py @@ -189,3 +189,40 @@ def note_header(note: str, db_labels: dict[str, str] | None = None) -> str: if english and english != note: return f"{english}{_LABEL_SEP}{note}" return note + + +def collect_name_notes(data: dict[str, Any]) -> list[str]: + """Return sorted unique ``note`` values from a names.json document.""" + notes: set[str] = set() + for entry in parse_names_entries(data): + note = str(entry.get("note", "")).strip() + if note: + notes.add(note) + return sorted(notes) + + +def parse_name_wrap_notes(raw: str) -> frozenset[str]: + """Parse ``wolfNameWrapNotes`` from ``.env`` (JSON array or comma-separated).""" + text = (raw or "").strip() + if not text: + return frozenset() + try: + parsed = json.loads(text) + if isinstance(parsed, list): + return frozenset(str(item).strip() for item in parsed if str(item).strip()) + except json.JSONDecodeError: + pass + return frozenset(part.strip() for part in text.split(",") if part.strip()) + + +def load_name_wrap_config() -> tuple[bool, int, frozenset[str]]: + """Return ``(enabled, width, notes)`` from workflow ``.env`` settings.""" + import os + + enabled = os.getenv("wolfNameWrap", "false").strip().lower() == "true" + try: + width = int(os.getenv("wolfNameWrapWidth") or 0) + except ValueError: + width = 0 + notes = parse_name_wrap_notes(os.getenv("wolfNameWrapNotes", "")) + return enabled, width, notes