WolfDawn namewrap

This commit is contained in:
DazedAnon 2026-07-07 14:56:47 -05:00
parent c781f66ae7
commit c56fae3141
6 changed files with 310 additions and 1 deletions

View file

@ -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=","

View file

@ -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()

View file

@ -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.

View file

@ -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()

View file

@ -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:

View file

@ -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