Simplify Fix Wrap

This commit is contained in:
DazedAnon 2026-07-08 18:42:46 -05:00
parent 2b4a0e257f
commit 7079efc639
10 changed files with 553 additions and 824 deletions

View file

@ -65,20 +65,10 @@ 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 database (Step 4): limit which DB sheets are translated (JSON array of group keys)
#wolfDbIncludeGroups='["DataBase.project.json|Skill · 技能"]'
#wolfDbIncludeTiers='["foundation","system"]'
# WolfDawn dialogue wrap at translate time is optional / advanced; normal workflow
# leaves line breaks alone; optional `wolf relayout` after inject (Step 6, off by default).
#wolfWrap="false"
#wolfWidth="55"
#CSV delimiter character (comma, semicolon, or tab)
csvDelimiter=","

View file

@ -19,14 +19,14 @@ Mirrors the RPGMaker WorkflowTab, driven by the vendored WolfDawn ``wolf`` CLI
JSON; quick-inject picks just a few translated files for
fast in-game / git iteration, or inject everything at once.
Step 7 Fix wrap - search translated JSON by in-game text; wrap DB sheet rows;
re-inject (Advanced: wolf relayout for event messages)
wolf names-wrap, relayout, and desc-relayout
Step 8 Package - run from a loose Data/ folder, or repack Data.wolf
Step 9 Saves - fix baked strings in existing .sav files (optional)
names.json is staged into files/ but is NOT translated in the bulk phases - WolfDawn
tags each name safe / refs / verify and Phase 0 translates only safe entries
(per-name, not per-category), harvests them into vocab.txt, and Step 6 injects the
result.
result. All text wrapping (names-wrap, relayout, manual sheet wrap) lives in Step 7.
The extracted JSON is staged in ``<game_root>/wolf_json/`` (a manifest maps each
file back to its base binary), then imported into ``files/`` for the shared
@ -88,7 +88,6 @@ from gui.workflow_tab import (
from util.wolfdawn import names as wolf_names
from util.wolfdawn import codes as wolf_codes
from util.wolfdawn import db_classify as wolf_db
from util.wolfdawn import selective_wrap as wolf_selwrap
from util.wolfdawn import wrap_search as wolf_ws
import util.dazedwrap as dazedwrap
@ -2337,8 +2336,6 @@ class WolfWorkflowTab(QWidget):
)
)
layout.addWidget(tl_btn)
self._add_name_wrap_options(layout)
self._refresh_names_summary()
def _load_names_document(self) -> tuple[dict | None, Path | None]:
@ -2369,157 +2366,9 @@ class WolfWorkflowTab(QWidget):
self.names_summary_label.setText(
f"{summary}\n\n{categories}\n\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/)."""
"""Locate names.json for reading (files/ then wolf_json/)."""
for base in (Path("files"), self._work_dir()):
p = base / NAMES_JSON
if p.is_file():
@ -2603,9 +2452,9 @@ class WolfWorkflowTab(QWidget):
"""Search-first fix wrapping: find sheet by in-game text, wrap, re-inject."""
layout.addWidget(_make_section_label("Step 7 · Fix wrapping"))
layout.addWidget(self._desc(
"Paste text you see overflowing in the game. Search covers database sheets "
"(Step 4), names.json profile blurbs (Step 3), maps, and common events. "
"Wrap at the right width, then re-inject that file from Step 6 or below."
"Paste overflowing in-game text to find it in translated JSON. Adjust width "
"for one line or wrap every overflowing line in the same group (database "
"sheet, names category, map, CommonEvent, or Game.dat), then inject."
))
search_row = QHBoxLayout()
@ -2702,21 +2551,40 @@ class WolfWorkflowTab(QWidget):
layout.addWidget(self._wrap_preview)
btn_row = QHBoxLayout()
wrap_row_btn = _make_btn("Wrap this row", "#00a86b")
wrap_row_btn = _make_btn("Wrap this line", "#00a86b")
wrap_row_btn.clicked.connect(self._wrap_current_row)
wrap_sheet_btn = _make_btn("Wrap all overflowing in sheet", "#00a86b")
wrap_sheet_btn.clicked.connect(self._wrap_current_sheet)
wrap_group_btn = _make_btn("Wrap all in group", "#00a86b")
wrap_group_btn.clicked.connect(self._wrap_current_group)
save_row_btn = _make_btn("Save text", "#3a3a3a")
save_row_btn.clicked.connect(self._save_wrap_row_text)
inject_btn = _make_btn("Inject this file", "#007acc")
inject_btn.clicked.connect(self._inject_current_wrap_file)
btn_row.addWidget(wrap_row_btn)
btn_row.addWidget(wrap_sheet_btn)
btn_row.addWidget(wrap_group_btn)
btn_row.addWidget(save_row_btn)
btn_row.addWidget(inject_btn)
btn_row.addStretch()
layout.addLayout(btn_row)
context_row = QHBoxLayout()
self._wrap_relayout_btn = _make_btn("Relayout messages on Data/ (wolf)", "#3a3a3a")
self._wrap_relayout_btn.clicked.connect(lambda: self._run_relayout(manual=True))
self._wrap_relayout_btn.setVisible(False)
self._wrap_relayout_btn.setToolTip(
"Reflow message boxes in live Data/ using JP-measured geometry. "
"Run after inject for map and CommonEvent dialogue."
)
self._wrap_names_smart_btn = _make_btn("Smart wrap names.json (wolf)", "#3a3a3a")
self._wrap_names_smart_btn.clicked.connect(lambda: self._run_names_wrap(manual=True))
self._wrap_names_smart_btn.setVisible(False)
self._wrap_names_smart_btn.setToolTip(
"Re-wrap multi-line name fields to each category's JP-measured width."
)
context_row.addWidget(self._wrap_relayout_btn)
context_row.addWidget(self._wrap_names_smart_btn)
context_row.addStretch()
layout.addLayout(context_row)
self._wrap_status_label = QLabel("")
self._wrap_status_label.setWordWrap(True)
self._wrap_status_label.setStyleSheet(
@ -2724,208 +2592,11 @@ class WolfWorkflowTab(QWidget):
)
layout.addWidget(self._wrap_status_label)
layout.addWidget(_make_hr())
sheets_toggle = QPushButton("▸ Browse sheets & name categories (overflow counts)")
sheets_toggle.setCheckable(True)
sheets_toggle.setChecked(False)
sheets_toggle.setStyleSheet(
"QPushButton{text-align:left;color:#cccccc;background:transparent;border:none;"
"font-size:12px;padding:4px 0;}"
"QPushButton:hover{color:#ffffff;}"
)
self._wrap_sheets_toggle = sheets_toggle
sheets_toggle.toggled.connect(self._toggle_wrap_sheets_panel)
layout.addWidget(sheets_toggle)
self._wrap_sheets_panel = QWidget()
sheets_panel_layout = QVBoxLayout(self._wrap_sheets_panel)
sheets_panel_layout.setContentsMargins(0, 0, 0, 0)
sheets_panel_layout.addWidget(self._desc(
"Database sheets and names.json categories (``note``) with lines longer than "
"the wrap width above. Double-click to open the first overflowing entry."
))
self._wrap_sheets_list = QListWidget()
self._wrap_sheets_list.setMaximumHeight(140)
self._wrap_sheets_list.setStyleSheet(
"QListWidget{background-color:#252526;border:1px solid #3a3a3a;border-radius:4px;"
"color:#cccccc;font-size:12px;}"
)
self._wrap_sheets_list.itemDoubleClicked.connect(self._on_wrap_sheet_double_clicked)
sheets_panel_layout.addWidget(self._wrap_sheets_list)
self._wrap_sheets_panel.setVisible(False)
layout.addWidget(self._wrap_sheets_panel)
layout.addWidget(_make_hr())
adv_toggle = QPushButton("▸ Advanced (event message windows & item descriptions)")
adv_toggle.setCheckable(True)
adv_toggle.setChecked(False)
adv_toggle.setStyleSheet(sheets_toggle.styleSheet())
adv_toggle.toggled.connect(self._toggle_wrap_advanced_panel)
layout.addWidget(adv_toggle)
self._wrap_advanced_toggle = adv_toggle
self._wrap_advanced_panel = QWidget()
adv_layout = QVBoxLayout(self._wrap_advanced_panel)
adv_layout.setContentsMargins(0, 0, 0, 0)
self._build_wrap_advanced_panel(adv_layout)
self._wrap_advanced_panel.setVisible(False)
layout.addWidget(self._wrap_advanced_panel)
self._current_wrap_hit: wolf_ws.WrapHit | None = None
self._current_wrap_hit_id: dict | None = None
self._current_wrap_path: Path | None = None
self._current_wrap_doc: dict | None = None
def _build_wrap_advanced_panel(self, layout: QVBoxLayout):
"""wolf relayout / desc-relayout for non-DB-UI text."""
layout.addWidget(self._desc(
"Use these only for standard event message windows (maps / CommonEvent) and "
"item description boxes (説明 fields). Bulletin boards and custom DB UI "
"use the search workflow above."
))
self._relayout_after_cb = QCheckBox("Automatically relayout after Step 6 inject")
self._relayout_after_cb.setChecked(
self._setting("relayout_after_inject", "false") == "true"
)
self._relayout_after_cb.stateChanged.connect(
lambda: self._save_setting(
"relayout_after_inject",
"true" if self._relayout_after_cb.isChecked() else "false",
)
)
layout.addWidget(self._relayout_after_cb)
msg_row = QHBoxLayout()
width_lbl = QLabel("Message width:")
width_lbl.setStyleSheet("color:#cccccc;font-size:12px;background:transparent;")
self._relayout_width_spin = QSpinBox()
self._relayout_width_spin.setRange(20, 300)
try:
self._relayout_width_spin.setValue(int(self._setting("relayout_width", 55) or 55))
except (TypeError, ValueError):
self._relayout_width_spin.setValue(55)
self._relayout_width_spin.setFixedWidth(70)
self._relayout_width_spin.valueChanged.connect(
lambda v: self._save_setting("relayout_width", v)
)
max_rows_lbl = QLabel("Max rows (0=auto):")
max_rows_lbl.setStyleSheet("color:#cccccc;font-size:12px;background:transparent;")
self._relayout_max_rows_spin = QSpinBox()
self._relayout_max_rows_spin.setRange(0, 20)
try:
self._relayout_max_rows_spin.setValue(int(self._setting("relayout_max_rows", 0) or 0))
except (TypeError, ValueError):
self._relayout_max_rows_spin.setValue(0)
self._relayout_max_rows_spin.setFixedWidth(70)
self._relayout_max_rows_spin.valueChanged.connect(
lambda v: self._save_setting("relayout_max_rows", v)
)
msg_row.addWidget(width_lbl)
msg_row.addWidget(self._relayout_width_spin)
msg_row.addWidget(max_rows_lbl)
msg_row.addWidget(self._relayout_max_rows_spin)
msg_row.addStretch()
layout.addLayout(msg_row)
relayout_btn = _make_btn("Relayout event messages (wolf relayout)", "#3a3a3a")
relayout_btn.clicked.connect(lambda: self._run_relayout(manual=True))
layout.addWidget(relayout_btn)
self._relayout_desc_cb = QCheckBox("Also refit item description boxes (desc-relayout)")
self._relayout_desc_cb.setChecked(
str(self._setting("relayout_desc", "true")).lower() != "false"
)
self._relayout_desc_cb.stateChanged.connect(
lambda: self._save_setting(
"relayout_desc",
"true" if self._relayout_desc_cb.isChecked() else "false",
)
)
layout.addWidget(self._relayout_desc_cb)
desc_row = QHBoxLayout()
desc_width_lbl = QLabel("Desc width:")
desc_width_lbl.setStyleSheet("color:#cccccc;font-size:12px;background:transparent;")
self._relayout_desc_width_spin = QSpinBox()
self._relayout_desc_width_spin.setRange(20, 300)
try:
self._relayout_desc_width_spin.setValue(
int(self._setting("relayout_desc_width", 75) or 75)
)
except (TypeError, ValueError):
self._relayout_desc_width_spin.setValue(75)
self._relayout_desc_width_spin.setFixedWidth(70)
self._relayout_desc_width_spin.valueChanged.connect(
lambda v: self._save_setting("relayout_desc_width", v)
)
desc_lines_lbl = QLabel("Desc max lines (0=auto):")
desc_lines_lbl.setStyleSheet("color:#cccccc;font-size:12px;background:transparent;")
self._relayout_desc_lines_spin = QSpinBox()
self._relayout_desc_lines_spin.setRange(0, 20)
try:
self._relayout_desc_lines_spin.setValue(
int(self._setting("relayout_desc_max_lines", 0) or 0)
)
except (TypeError, ValueError):
self._relayout_desc_lines_spin.setValue(0)
self._relayout_desc_lines_spin.setFixedWidth(70)
self._relayout_desc_lines_spin.valueChanged.connect(
lambda v: self._save_setting("relayout_desc_max_lines", v)
)
desc_row.addWidget(desc_width_lbl)
desc_row.addWidget(self._relayout_desc_width_spin)
desc_row.addWidget(desc_lines_lbl)
desc_row.addWidget(self._relayout_desc_lines_spin)
desc_row.addStretch()
layout.addLayout(desc_row)
wrap_env = self._read_env_values(["wolfWrap", "wolfWidth"])
self._wolf_wrap_cb = QCheckBox("Wrap dialogue at translate time (wolfWrap, rare)")
self._wolf_wrap_cb.setChecked(
str(wrap_env.get("wolfWrap", "false")).strip().lower() == "true"
)
self._wolf_wrap_cb.stateChanged.connect(self._apply_wolf_wrap_config)
layout.addWidget(self._wolf_wrap_cb)
tw_row = QHBoxLayout()
tw_lbl = QLabel("wolfWrap width:")
tw_lbl.setStyleSheet("color:#cccccc;font-size:12px;background:transparent;")
self._wolf_wrap_width_spin = QSpinBox()
self._wolf_wrap_width_spin.setRange(20, 300)
try:
self._wolf_wrap_width_spin.setValue(int(wrap_env.get("wolfWidth") or 55))
except ValueError:
self._wolf_wrap_width_spin.setValue(55)
self._wolf_wrap_width_spin.setFixedWidth(70)
self._wolf_wrap_width_spin.valueChanged.connect(self._apply_wolf_wrap_config)
tw_row.addWidget(tw_lbl)
tw_row.addWidget(self._wolf_wrap_width_spin)
tw_row.addStretch()
layout.addLayout(tw_row)
def _toggle_wrap_sheets_panel(self, expanded: bool):
if hasattr(self, "_wrap_sheets_panel"):
self._wrap_sheets_panel.setVisible(expanded)
btn = getattr(self, "_wrap_sheets_toggle", None)
if btn is not None:
label = "▾ Browse database sheets (overflow counts)" if expanded else (
"▸ Browse database sheets (overflow counts)"
)
btn.setText(label)
if expanded:
self._refresh_wrap_sheets_list()
def _toggle_wrap_advanced_panel(self, expanded: bool):
if hasattr(self, "_wrap_advanced_panel"):
self._wrap_advanced_panel.setVisible(expanded)
btn = getattr(self, "_wrap_advanced_toggle", None)
if btn is not None:
label = (
"▾ Advanced (event message windows & item descriptions)"
if expanded else
"▸ Advanced (event message windows & item descriptions)"
)
btn.setText(label)
def _translated_and_files_dirs(self) -> tuple[Path, Path]:
root = self._tool_root()
return root / "translated", root / "files"
@ -2956,6 +2627,51 @@ class WolfWorkflowTab(QWidget):
def _on_wrap_width_changed(self, value: int):
self._save_setting("wrap_fix_width", value)
self._update_wrap_preview()
hit = self._current_wrap_hit
doc = self._current_wrap_doc
if hit is not None and doc is not None and hasattr(self, "_wrap_detail_label"):
text = self._wrap_text_editor.toPlainText() if self._wrap_text_editor.isEnabled() else hit.text
self._wrap_detail_label.setText(self._format_wrap_detail(hit, doc, text, value))
def _format_wrap_detail(
self,
hit: wolf_ws.WrapHit,
doc: dict | None,
text: str,
width: int,
) -> str:
kind_labels = {
"db": "Database sheet",
"names": "Names category",
"gamedat": "Game.dat",
"map": "Map dialogue",
"common": "CommonEvent dialogue",
}
kind_lbl = kind_labels.get(hit.kind, hit.kind)
scope = (
wolf_ws.scope_stats(doc, hit, width)
if doc is not None
else wolf_ws.ScopeStats(label=wolf_ws.scope_label(hit), total=0, overflow=0)
)
overflow_note = (
f" · {scope.overflow} of {scope.total} lines overflow at width {width}"
if scope.total
else ""
)
if hit.kind == "names":
return (
f"{kind_lbl}: {hit.sheet_name} · {hit.json_file} · "
f"entry #{hit.row}{overflow_note}"
)
if hit.kind == "db":
return (
f"{kind_lbl}: {hit.sheet_name} · {hit.json_file} · "
f"row {hit.row} · {hit.field_name}{overflow_note}"
)
return (
f"{kind_lbl}: {hit.sheet_name} · {hit.json_file} · "
f"{hit.field_name}{overflow_note}"
)
def _update_wrap_preview(self):
if not hasattr(self, "_wrap_preview"):
@ -3090,30 +2806,14 @@ class WolfWorkflowTab(QWidget):
text = hit.text
self._wrap_text_editor.setEnabled(True)
self._wrap_text_editor.setPlainText(text)
kind_labels = {
"db": "Database sheet",
"names": "Name / profile text",
"gamedat": "Game.dat",
"map": "Event dialogue",
"common": "Event dialogue",
}
kind_lbl = kind_labels.get(hit.kind, hit.kind)
if hit.kind == "names":
detail = (
f"{kind_lbl}: {hit.sheet_name} · {hit.json_file} · "
f"entry #{hit.row} · longest line {hit.max_line_len} chars"
)
elif hit.kind == "db":
detail = (
f"{kind_lbl}: {hit.sheet_name} · {hit.json_file} · "
f"row {hit.row} · {hit.field_name} · longest line {hit.max_line_len} chars"
)
else:
detail = (
f"{kind_lbl}: {hit.sheet_name} · {hit.json_file} · "
f"{hit.field_name} · longest line {hit.max_line_len} chars"
)
self._wrap_detail_label.setText(detail)
self._wrap_detail_label.setText(
self._format_wrap_detail(hit, doc, text, self._wrap_width_spin.value())
)
if hasattr(self, "_wrap_relayout_btn"):
show_relayout = hit.kind in ("map", "common")
self._wrap_relayout_btn.setVisible(show_relayout)
if hasattr(self, "_wrap_names_smart_btn"):
self._wrap_names_smart_btn.setVisible(hit.kind == "names")
self._sync_wrap_font_controls(text)
self._update_wrap_preview()
@ -3196,37 +2896,43 @@ class WolfWorkflowTab(QWidget):
)
if line is not None:
self._wrap_text_editor.setPlainText(str(line.get("text") or ""))
msg = f"Wrapped row at width {width}." if changed else "Row already fits at this width."
msg = f"Wrapped line at width {width}." if changed else "Line already fits at this width."
self._wrap_status_label.setText(msg + f" Inject {self._current_wrap_path.name} from Step 6.")
self._log(f"{'' if changed else ''} {msg}")
self._update_wrap_preview()
def _wrap_current_sheet(self):
def _wrap_current_group(self):
hit = self._current_wrap_hit
if not hit or hit.kind not in ("db", "names"):
QMessageBox.information(
self, "Fix wrapping",
"Select a database sheet or names.json category result first.",
)
if not hit or not self._current_wrap_path or not self._current_wrap_doc:
QMessageBox.information(self, "Fix wrapping", "Select a search result first.")
return
tdir, fdir = self._translated_and_files_dirs()
path = tdir / hit.json_file
if not path.is_file():
path = fdir / hit.json_file
if not path.is_file():
QMessageBox.warning(self, "Fix wrapping", f"{hit.json_file} not found.")
return
doc = json.loads(path.read_text(encoding="utf-8-sig"))
width = self._wrap_width_spin.value()
count = wolf_ws.wrap_overflow_in_sheet(
path, doc, hit.sheet_name, width, kind=hit.kind,
count = wolf_ws.wrap_overflow_in_scope(
self._current_wrap_path,
self._current_wrap_doc,
hit,
width,
)
self._remember_sheet_width(hit.sheet_name, width, hit.json_file)
self._refresh_wrap_sheets_list()
label = "category" if hit.kind == "names" else "sheet"
msg = f"Wrapped {count} overflowing line(s) in {label} {hit.sheet_name} at width {width}."
self._wrap_status_label.setText(msg + f" Inject {hit.json_file}.")
scope = wolf_ws.scope_label(hit)
msg = f"Wrapped {count} line(s) in {scope} at width {width}."
self._wrap_status_label.setText(msg + f" Inject {self._current_wrap_path.name}.")
self._log(f"{msg}")
path, doc, line = wolf_ws.load_hit_from_id(
self._translated_and_files_dirs()[0],
hit.hit_id,
files_dir=self._translated_and_files_dirs()[1],
extra_dirs=self._wrap_search_extra_dirs(),
)
self._current_wrap_path = path
self._current_wrap_doc = doc
if line is not None:
self._wrap_text_editor.setPlainText(str(line.get("text") or ""))
if doc is not None:
self._wrap_detail_label.setText(
self._format_wrap_detail(hit, doc, self._wrap_text_editor.toPlainText(), width)
)
self._update_wrap_preview()
def _inject_current_wrap_file(self):
hit = self._current_wrap_hit
@ -3237,101 +2943,6 @@ class WolfWorkflowTab(QWidget):
return
self._inject({hit.json_file})
def _refresh_wrap_sheets_list(self):
if not hasattr(self, "_wrap_sheets_list"):
return
tdir, fdir = self._translated_and_files_dirs()
width = self._wrap_width_spin.value() if hasattr(self, "_wrap_width_spin") else 36
summaries = wolf_ws.sheet_overflow_summaries(tdir, width, files_dir=fdir)
self._wrap_sheets_list.clear()
if not summaries:
item = QListWidgetItem("No database or names.json data found.")
item.setFlags(Qt.ItemIsEnabled)
self._wrap_sheets_list.addItem(item)
return
for s in sorted(
summaries,
key=lambda x: (x.kind != "names", -x.overflow_count, x.sheet_name),
):
kind_tag = "names" if s.kind == "names" else "DB"
flag = f"{s.overflow_count} overflow" if s.overflow_count else " ok"
item = QListWidgetItem(
f"[{kind_tag}] {s.sheet_name} ({s.line_count} lines){flag}{s.json_file}"
)
item.setData(Qt.UserRole, s.sheet_name)
item.setData(Qt.UserRole + 1, s.json_file)
item.setData(Qt.UserRole + 2, s.kind)
self._wrap_sheets_list.addItem(item)
def _on_wrap_sheet_double_clicked(self, item: QListWidgetItem):
sheet = item.data(Qt.UserRole)
jf = item.data(Qt.UserRole + 1)
kind = item.data(Qt.UserRole + 2) or "db"
if not sheet or not jf:
return
tdir, fdir = self._translated_and_files_dirs()
width = self._wrap_width_spin.value()
path = tdir / str(jf)
if not path.is_file():
path = fdir / str(jf)
if not path.is_file():
return
doc = json.loads(path.read_text(encoding="utf-8-sig"))
if kind == "names":
for idx, entry in enumerate(doc.get("names") or []):
if not isinstance(entry, dict):
continue
if str(entry.get("note") or "") != sheet:
continue
text = entry.get("text")
if isinstance(text, str) and wolf_selwrap.line_needs_wrap(text, width):
hit = wolf_ws.WrapHit(
json_file=str(jf),
kind="names",
sheet_name=str(sheet),
row=idx,
field_name=str(entry.get("source") or "")[:80] or f"entry {idx}",
text=str(text),
max_line_len=dazedwrap.max_line_visible_length(str(text)),
)
self._show_wrap_hit_in_editor(hit, width)
return
return
for group in doc.get("groups") or []:
if str(group.get("typeName") or "") != sheet:
continue
for line in group.get("lines") or []:
text = line.get("text")
if isinstance(text, str) and wolf_selwrap.line_needs_wrap(text, width):
hit = wolf_ws.WrapHit(
json_file=str(jf),
kind="db",
sheet_name=str(sheet),
row=line.get("row"),
field_name=str(line.get("fieldName") or ""),
text=str(text),
max_line_len=dazedwrap.max_line_visible_length(str(text)),
)
self._show_wrap_hit_in_editor(hit, width)
return
def _show_wrap_hit_in_editor(self, hit: wolf_ws.WrapHit, width: int):
self._wrap_search_edit.setText(str(hit.text)[:40])
self._wrap_results_list.clear()
item_r = QListWidgetItem(hit.summary(width))
item_r.setData(Qt.UserRole, hit.hit_id)
item_r.setData(Qt.UserRole + 1, hit)
self._wrap_results_list.addItem(item_r)
self._wrap_results_list.setCurrentRow(0)
def _apply_wolf_wrap_config(self):
enabled = self._wolf_wrap_cb.isChecked() if hasattr(self, "_wolf_wrap_cb") else False
width = self._wolf_wrap_width_spin.value() if hasattr(self, "_wolf_wrap_width_spin") else 55
self._write_env_values({
"wolfWrap": "true" if enabled else "false",
"wolfWidth": str(width),
})
def _tool_root(self) -> Path:
"""DazedMTLTool project root (``files/``, ``translated/``, etc.)."""
return Path(__file__).resolve().parent.parent
@ -3437,29 +3048,9 @@ class WolfWorkflowTab(QWidget):
elif body:
QMessageBox.information(self, title, body)
selection = inject_state.get("selected") or set()
names_only = selection and selection.issubset({NAMES_JSON})
if names_only or not self._relayout_after_enabled():
self._log("Relayout: not run")
return
if not report or not any(
r.success and r.json_name != NAMES_JSON for r in report.files
):
self._log("Relayout: not run")
return
QTimer.singleShot(
0, lambda: self._run_relayout(manual=False, quiet=True)
)
inject_state: dict = {}
self._run_task(task, on_done=_after_inject)
def _relayout_after_enabled(self) -> bool:
cb = getattr(self, "_relayout_after_cb", None)
if cb is not None:
return cb.isChecked()
return self._setting("relayout_after_inject", "false") == "true"
def _on_step_changed(self, idx: int):
previous = self._current_step_index
self._current_step_index = idx
@ -3468,17 +3059,12 @@ 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 4 == Database: refresh discovery report and sheet list.
elif idx == 4:
self._refresh_db_discovery()
# Index 6 == Inject: keep the file list in sync with translated/.
elif idx == 6:
self._refresh_inject_list()
# Index 7 == Fix wrap: refresh sheet overflow list.
elif idx == 7:
if getattr(self, "_wrap_sheets_panel", None) and self._wrap_sheets_panel.isVisible():
self._refresh_wrap_sheets_list()
def _refresh_inject_list(self):
if not hasattr(self, "inject_list"):
@ -3568,28 +3154,25 @@ class WolfWorkflowTab(QWidget):
self._run_task(task)
def _relayout_desc_enabled(self) -> bool:
cb = getattr(self, "_relayout_desc_cb", None)
if cb is not None:
return cb.isChecked()
return str(self._setting("relayout_desc", "true")).lower() != "false"
def _relayout_settings(self) -> dict:
def _spin(attr: str, key: str, default: int) -> int:
w = getattr(self, attr, None)
if w is not None:
return int(w.value())
def _int_setting(key: str, default: int) -> int:
try:
return int(self._setting(key, default) or default)
except (TypeError, ValueError):
return default
def _width(key: str) -> int | str:
if str(self._setting(f"{key}_auto", "true")).lower() != "false":
return "auto"
return _int_setting(key, 55 if key == "relayout_width" else 75)
return {
"width": _spin("_relayout_width_spin", "relayout_width", 55),
"max_rows": _spin("_relayout_max_rows_spin", "relayout_max_rows", 0),
"desc_width": _spin("_relayout_desc_width_spin", "relayout_desc_width", 75),
"desc_max_lines": _spin(
"_relayout_desc_lines_spin", "relayout_desc_max_lines", 0
),
"width": _width("relayout_width"),
"max_rows": _int_setting("relayout_max_rows", 0),
"desc_width": _width("relayout_desc_width"),
"desc_max_lines": _int_setting("relayout_desc_max_lines", 0),
}
def _find_db_projects(self, data_dir: Path) -> list[Path]:
@ -3643,7 +3226,7 @@ class WolfWorkflowTab(QWidget):
data_dir,
out_dir=out_dir,
width=opts["width"],
max_rows=opts["max_rows"] or None,
max_rows=opts["max_rows"],
log_fn=None,
)
if not res.ok and res.returncode not in (0, 2):
@ -3690,6 +3273,35 @@ class WolfWorkflowTab(QWidget):
self._run_task(task)
def _run_names_wrap(self, *, manual: bool = False, dry_run: bool = False):
"""Run ``wolf names-wrap`` on translated/names.json."""
names_path = self._tool_root() / "translated" / NAMES_JSON
if not names_path.is_file():
names_path = self._work_dir() / NAMES_JSON
if not names_path.is_file():
QMessageBox.warning(
self,
"names-wrap",
"No names.json found in translated/ or wolf_json/. Run Step 0 first.",
)
return
def task(log, progress=None):
ok, summary = wolf_names.apply_names_wrap(
names_path,
dry_run=dry_run,
log_fn=log,
)
if not ok:
return False, f"names-wrap failed — {summary}"
if manual:
self._sync_translated_json_to_wolf_json(NAMES_JSON, self._work_dir())
return True, summary + (
"" if dry_run else " Re-inject names.json from Step 6 if needed."
)
self._run_task(task)
# ── Step 5: Package ────────────────────────────────────────────────────────
def _build_step5_package(self, layout: QVBoxLayout):

View file

@ -28,16 +28,6 @@ names.json, translatable entries are harvested into ``vocab.txt`` (grouped by
``note``, with bilingual headers) so later phases keep item / skill / term names
consistent.
Text wrapping: optional advanced ``wolfWrap`` / ``wolfWidth`` (via :mod:`util.dazedwrap`)
can still re-wrap dialogue bodies at translate time, but the normal Wolf workflow
leaves JSON line breaks alone and runs WolfDawn ``relayout`` after inject (Step 6)
to fit the message box. Defaults to off (``wolfWrap=false``).
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.
Database sheet filter (Step 4): optional ``wolfDbIncludeGroups`` or
``wolfDbIncludeTiers`` in ``.env`` limit which ``kind: db`` lines are collected.
When unset, all database lines are translated.
@ -61,8 +51,6 @@ import traceback
from colorama import Fore
from tqdm import tqdm
import util.dazedwrap as dazedwrap
from util.paths import PROMPT_PATH, VOCAB_PATH
from util.translation import (
TranslationConfig,
@ -110,25 +98,6 @@ _WOLF_CODE_RE = re.compile(
# configurable from the workflow (data/wolf_speakers.json).
SPEAKER_CONFIG = wolf_speakers.load_config()
# names.json: translate per-entry safe badges only (see util.wolfdawn.names).
# Text wrapping: optional advanced rewrap via dazedwrap (wolfWrap/wolfWidth). Defaults
# off - the workflow uses WolfDawn relayout after inject instead.
WRAP = (os.getenv("wolfWrap", "false").strip().lower() == "true")
try:
WRAPWIDTH = int(os.getenv("wolfWidth") or 0)
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"]
@ -167,7 +136,6 @@ 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")
@ -177,8 +145,7 @@ def handleWolfDawn(filename, estimate):
translatedData = openFiles(filename)
# Batch collect only queues API requests; text is still Japanese. Writing
# translated/ here would overwrite prior work with rewrapped source (and with
# wolfWrap on, the git diff looks like "translations" that never changed).
# translated/ here would overwrite prior work with source echoed back.
# Real English is written on the consume pass (or a live non-batch run).
if not estimate and _batch_phase() != "collect":
try:
@ -252,42 +219,6 @@ def collectEntries(data):
return entries
def _wrap_body(body):
"""Word-wrap a dialogue body to WRAPWIDTH (no-op when wrapping is disabled)."""
if not WRAP or WRAPWIDTH <= 0 or not isinstance(body, str) or not body:
return 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.
``is_firstline`` is True for first-line-speaker formats that are turned off in
the config: their model output is still ``Speaker\\nbody``, so line 1 (the
name) is kept intact and only the body is wrapped. Any leading ``@<option>``
window prefix is also preserved.
"""
if not WRAP or WRAPWIDTH <= 0 or not isinstance(text, str) or not text:
return text
prefix, rest = wolf_speakers.split_window_prefix(text)
if is_firstline and "\n" in rest:
name, body = rest.split("\n", 1)
return prefix + name + "\n" + _wrap_body(body)
return prefix + _wrap_body(rest)
def _text_check_body(text: str, speaker_src: str = "") -> str:
"""Body text used for skip-translated checks (nameplates / codes ignored).
@ -404,20 +335,16 @@ def parseDocument(data, filename):
if text == src:
continue
text = wolf_codes.restore_wolf_code_placeholders(text, code_map)
if is_names:
entry["text"] = _wrap_names_entry(text, entry)
elif has_speaker:
if 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.
entry["text"] = wolf_speakers.restore_source(
prefix, speaker_en, _wrap_body(body_en)
prefix, speaker_en, body_en
)
else:
# Model dropped the [Speaker]: prefix; keep its output as-is.
entry["text"] = prefix + _wrap_body(text)
entry["text"] = prefix + text
else:
entry["text"] = _wrap_plain(text, is_firstline)
entry["text"] = text
wolf_codes.repair_entry(entry)
# Phase 0 feeds the glossary: harvest safe name values into vocab.txt

View file

@ -101,19 +101,6 @@ class TestCollectNameNotes(unittest.TestCase):
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

@ -263,5 +263,64 @@ class TestNamesCategoryWrap(unittest.TestCase):
self.assertIn("\n", long_entry)
class TestEventScopeWrap(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.translated = Path(self.tmp.name) / "translated"
self.translated.mkdir()
self.path = self.translated / "Map001.mps.json"
self.path.write_text(
json.dumps(
{
"kind": "map",
"file": "Map001.mps",
"scenes": [
{
"event": 1,
"lines": [
{
"text": (
"This is a very long line of dialogue that "
"should wrap at thirty four visible chars."
),
},
{"text": "Short line."},
],
}
],
},
ensure_ascii=False,
indent=4,
),
encoding="utf-8",
)
self.hit = ws.WrapHit(
json_file="Map001.mps.json",
kind="map",
sheet_name="Map001.mps",
row=1,
field_name="scene 0 line 0",
text="very long line",
max_line_len=80,
scene_index=0,
line_index=0,
map_file="Map001.mps",
)
def tearDown(self):
self.tmp.cleanup()
def test_scope_stats_for_map_file(self):
doc = json.loads(self.path.read_text(encoding="utf-8"))
stats = ws.scope_stats(doc, self.hit, 34)
self.assertEqual(stats.total, 2)
self.assertEqual(stats.overflow, 1)
def test_wrap_all_in_map_file(self):
doc = json.loads(self.path.read_text(encoding="utf-8"))
changed = ws.wrap_overflow_in_scope(self.path, doc, self.hit, 34)
self.assertEqual(changed, 1)
if __name__ == "__main__":
unittest.main()

View file

@ -493,10 +493,8 @@ SPEAKER_MAP_DOC = {
class _SpeakerHarness:
"""Run parseDocument with the speaker-aware mock and a chosen speaker config."""
def __init__(self, config, wrap=False, width=0):
def __init__(self, config):
self.config = config
self.wrap = wrap
self.width = width
self.captured = []
def run(self, data, filename="OP.mps.json"):
@ -504,17 +502,15 @@ class _SpeakerHarness:
self.captured.append(copy.deepcopy(text))
return _mock_translate_speaker(text, history, history_ctx)
orig = (wd.translateAI, wd.ESTIMATE, wd.SPEAKER_CONFIG, wd.WRAP, wd.WRAPWIDTH)
orig = (wd.translateAI, wd.ESTIMATE, wd.SPEAKER_CONFIG)
wd.translateAI = translate
wd.ESTIMATE = False
wd.SPEAKER_CONFIG = self.config
wd.WRAP = self.wrap
wd.WRAPWIDTH = self.width
try:
result = wd.parseDocument(copy.deepcopy(data), filename)
return result, self.captured
finally:
(wd.translateAI, wd.ESTIMATE, wd.SPEAKER_CONFIG, wd.WRAP, wd.WRAPWIDTH) = orig
(wd.translateAI, wd.ESTIMATE, wd.SPEAKER_CONFIG) = orig
class TestSpeakerReshaping(unittest.TestCase):
@ -549,132 +545,6 @@ class TestSpeakerReshaping(unittest.TestCase):
self.assertEqual(lines[0]["text"], "EN_市民\nおはよう\n元気?")
class TestWrapping(unittest.TestCase):
"""dazedwrap-style re-wrapping, with the speaker name kept on its own line."""
def setUp(self):
self._orig = (wd.WRAP, wd.WRAPWIDTH)
def tearDown(self):
wd.WRAP, wd.WRAPWIDTH = self._orig
def _set(self, enabled, width):
wd.WRAP, wd.WRAPWIDTH = enabled, width
def test_wrap_body_respects_width_and_keeps_words(self):
self._set(True, 10)
out = wd._wrap_body("alpha beta gamma delta")
self.assertGreater(out.count("\n"), 0) # actually wrapped
for line in out.split("\n"):
self.assertLessEqual(len(line), 10)
self.assertEqual(out.replace("\n", " "), "alpha beta gamma delta")
def test_wrap_disabled_is_noop(self):
self._set(False, 10)
self.assertEqual(wd._wrap_body("alpha beta gamma delta"), "alpha beta gamma delta")
def test_zero_width_is_noop(self):
self._set(True, 0)
self.assertEqual(wd._wrap_body("alpha beta gamma delta"), "alpha beta gamma delta")
def test_wrap_plain_preserves_nameplate_line(self):
self._set(True, 12)
out = wd._wrap_plain("Name\nalpha beta gamma delta epsilon", is_firstline=True)
self.assertEqual(out.split("\n", 1)[0], "Name") # name never merged into body
for line in out.split("\n")[1:]:
self.assertLessEqual(len(line), 12)
def test_wrap_plain_preserves_window_prefix(self):
self._set(True, 12)
out = wd._wrap_plain("@1\nName\nalpha beta gamma delta", is_firstline=True)
self.assertTrue(out.startswith("@1\nName\n"))
def test_speaker_body_wrapped_but_name_kept(self):
# Integration: even at a tiny width the (translated) name stays on line 1.
cfg = {"literal_line1": True, "literal_line1_lowconf": True}
(data, _t, err), _c = _SpeakerHarness(cfg, wrap=True, width=6).run(SPEAKER_MAP_DOC)
self.assertIsNone(err)
line0 = data["scenes"][0]["lines"][0]["text"]
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):
# Narrow JP source (8 cells of CJK) so LANGREGEX matches; mock returns long EN.
profile_src = "あいうえ" # 4 CJK → matches LANGREGEX
doc = {
"kind": "names",
"names": [
{"source": "", "text": "", "note": "武器", "safety": "safe"},
{
"source": profile_src,
"text": profile_src,
"note": "├■プロフィール",
"safety": "safe",
},
],
}
self._set(True, 8, {"├■プロフィール"})
long_en = "alpha beta gamma delta epsilon zeta eta theta"
orig_t = wd.translateAI
orig_estimate = wd.ESTIMATE
orig_wrap = wd.WRAP
orig_update = wd.wolf_vocab.update_vocab_section
orig_labels = wd.wolf_names.derive_db_labels
def translate(text, history, history_ctx=None):
out = []
for t in text:
out.append(long_en if t == profile_src else f"EN_{t}")
return [out, [1, 1]]
wd.translateAI = translate
wd.ESTIMATE = False
wd.WRAP = False
wd.wolf_vocab.update_vocab_section = lambda *_a, **_k: None
wd.wolf_names.derive_db_labels = lambda _p: {}
try:
data, _t, err = wd.parseDocument(copy.deepcopy(doc), "names.json")
finally:
wd.translateAI = orig_t
wd.ESTIMATE = orig_estimate
wd.WRAP = orig_wrap
wd.wolf_vocab.update_vocab_section = orig_update
wd.wolf_names.derive_db_labels = orig_labels
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

@ -30,7 +30,8 @@ class TestRelayoutWrappers(unittest.TestCase):
self.assertIn("--width", captured["args"])
self.assertIn("55", captured["args"])
self.assertNotIn("-o", captured["args"])
self.assertNotIn("--max-rows", captured["args"]) # 0 omitted
self.assertIn("--max-rows", captured["args"])
self.assertIn("auto", captured["args"])
def test_relayout_write_args(self):
captured = {}
@ -79,9 +80,56 @@ class TestRelayoutWrappers(unittest.TestCase):
self.assertIn("--width", args)
self.assertIn("75", args)
self.assertIn("--max-lines", args)
self.assertIn("0", args)
self.assertIn("auto", args)
self.assertIn("--keep-breaks", args)
def test_relayout_auto_width(self):
captured = {}
def fake_run(args, log_fn=None):
captured["args"] = list(args)
return wolfdawn.WolfResult(0, "", "", [str(a) for a in args])
with patch.object(wolfdawn, "_run", side_effect=fake_run):
wolfdawn.relayout("/game/Data", width="auto", max_rows="auto")
args = captured["args"]
self.assertIn("--width", args)
self.assertIn("auto", args)
self.assertIn("--max-rows", args)
def test_names_wrap_args(self):
captured = {}
def fake_run(args, log_fn=None):
captured["args"] = list(args)
return wolfdawn.WolfResult(
0,
"names-wrap: 3 entry(ies) re-wrapped",
"",
[str(a) for a in args],
)
with patch.object(wolfdawn, "_run", side_effect=fake_run):
res = wolfdawn.names_wrap("/tmp/names.json")
self.assertEqual(captured["args"], ["names-wrap", "/tmp/names.json"])
self.assertEqual(wolfdawn.parse_names_wrap_counts(res.stdout), 3)
def test_names_wrap_dry_run(self):
captured = {}
def fake_run(args, log_fn=None):
captured["args"] = list(args)
return wolfdawn.WolfResult(
0,
"names-wrap: 2 entry(ies) WOULD be re-wrapped",
"",
[str(a) for a in args],
)
with patch.object(wolfdawn, "_run", side_effect=fake_run):
wolfdawn.names_wrap("/tmp/names.json", dry_run=True)
self.assertEqual(captured["args"], ["names-wrap", "/tmp/names.json", "--dry-run"])
if __name__ == "__main__":
unittest.main(verbosity=2)

View file

@ -49,6 +49,8 @@ __all__ = [
"pack",
"save_update",
"names_check",
"names_wrap",
"parse_names_wrap_counts",
"relayout",
"desc_relayout",
]
@ -655,6 +657,58 @@ def names_check(json_files: Iterable[PathLike], log_fn=None) -> WolfResult:
return _run(args, log_fn=log_fn)
_NAMES_WRAP_WOULD_RE = re.compile(
r"(\d+)\s+entry\(ies\)\s+WOULD\s+be\s+re-wrapped", re.IGNORECASE
)
_NAMES_WRAP_APPLIED_RE = re.compile(
r"(\d+)\s+entry\(ies\)\s+re-wrapped", re.IGNORECASE
)
def parse_names_wrap_counts(stdout: str, stderr: str = "") -> int | None:
"""Return how many entries names-wrap would change or did change, or None."""
blob = f"{stdout}\n{stderr}"
for pattern in (_NAMES_WRAP_APPLIED_RE, _NAMES_WRAP_WOULD_RE):
match = pattern.search(blob)
if match:
return int(match.group(1))
return None
def names_wrap(
names_json: PathLike,
*,
dry_run: bool = False,
log_fn=None,
) -> WolfResult:
"""``wolf names-wrap <names.json> [--dry-run]``.
Re-wraps translated multi-line glossary entries to each category's
JP-measured width and line count. One-line names are never wrapped.
"""
args = ["names-wrap", _str(names_json)]
if dry_run:
args.append("--dry-run")
return _run(args, log_fn=log_fn)
def _relayout_metric_arg(value: int | str | None, *, zero_is_auto: bool = False) -> str | None:
"""Format one relayout geometry flag (``55``, ``auto``, or omit)."""
if value is None:
return None
if isinstance(value, str):
token = value.strip().lower()
if not token or token == "auto":
return "auto"
try:
value = int(token)
except ValueError:
return token
if zero_is_auto and int(value) == 0:
return "auto"
return str(int(value))
def pack(
directory: PathLike,
output: PathLike,
@ -700,12 +754,12 @@ def relayout(
data_dir: PathLike,
out_dir: Optional[PathLike] = None,
*,
width: Optional[int] = None,
width_face: Optional[int] = None,
max_rows: Optional[int] = None,
width: int | str | None = None,
width_face: int | str | None = None,
max_rows: int | str | None = None,
sub_width: Optional[int] = None,
base_font: Optional[int] = None,
no_formup: bool = False,
base_font: int | str | None = None,
no_fixup: bool = False,
no_resolve: bool = False,
log_fn=None,
) -> WolfResult:
@ -713,22 +767,26 @@ def relayout(
Without ``-o`` this is a dry run. With ``-o``, only changed map / CommonEvent
files are written under ``out_dir`` (paths relative to ``data_dir``).
Pass ``width="auto"`` or ``max_rows=0`` to measure geometry from JP text.
"""
args: list[str] = ["relayout", _str(data_dir)]
if out_dir is not None:
args += ["-o", _str(out_dir)]
if width is not None:
args += ["--width", str(int(width))]
if width_face is not None:
args += ["--width-face", str(int(width_face))]
if max_rows is not None and int(max_rows) > 0:
args += ["--max-rows", str(int(max_rows))]
width_arg = _relayout_metric_arg(width)
if width_arg is not None:
args += ["--width", width_arg]
width_face_arg = _relayout_metric_arg(width_face)
if width_face_arg is not None:
args += ["--width-face", width_face_arg]
if max_rows is not None:
args += ["--max-rows", _relayout_metric_arg(max_rows, zero_is_auto=True) or "auto"]
if sub_width is not None:
args += ["--sub-width", str(int(sub_width))]
if base_font is not None:
args += ["--base-font", str(int(base_font))]
if no_formup:
args.append("--no-formup")
base_arg = _relayout_metric_arg(base_font, zero_is_auto=True)
args += ["--base-font", base_arg if base_arg is not None else "0"]
if no_fixup:
args.append("--no-fixup")
if no_resolve:
args.append("--no-resolve")
return _run(args, log_fn=log_fn)
@ -738,33 +796,36 @@ def desc_relayout(
project: PathLike,
output: PathLike,
*,
width: Optional[int] = None,
max_lines: Optional[int] = None,
font: Optional[int] = None,
width: int | str | None = None,
max_lines: int | str | None = None,
font: int | str | None = None,
min_font: Optional[int] = None,
types: Optional[str] = None,
keep_breaks: bool = False,
no_formup: bool = False,
no_fixup: bool = False,
log_fn=None,
) -> WolfResult:
"""``wolf desc-relayout <X.project> -o <out.project> [flags]``.
``width`` may be omitted when ``wolfdawn-roles.json`` defines ``descBoxes``.
``width`` may be omitted or set to ``auto`` to measure from JP text.
``max_lines=0`` means auto (read each field's ``[N行]`` hint).
"""
args: list[str] = ["desc-relayout", _str(project), "-o", _str(output)]
if width is not None:
args += ["--width", str(int(width))]
width_arg = _relayout_metric_arg(width)
if width_arg is not None:
args += ["--width", width_arg]
if max_lines is not None:
args += ["--max-lines", str(int(max_lines))]
max_arg = _relayout_metric_arg(max_lines, zero_is_auto=True)
args += ["--max-lines", max_arg if max_arg is not None else "0"]
if font is not None:
args += ["--font", str(int(font))]
font_arg = _relayout_metric_arg(font, zero_is_auto=True)
args += ["--font", font_arg if font_arg is not None else "0"]
if min_font is not None:
args += ["--min-font", str(int(min_font))]
if types:
args += ["--types", str(types)]
if keep_breaks:
args.append("--keep-breaks")
if no_formup:
args.append("--no-formup")
if no_fixup:
args.append("--no-fixup")
return _run(args, log_fn=log_fn)

View file

@ -22,7 +22,10 @@ from __future__ import annotations
import json
import re
from typing import Any
from pathlib import Path
from typing import Any, Union
PathLike = Union[str, Path]
SAFETY_SAFE = "safe"
SAFETY_REFS = "refs"
@ -227,28 +230,46 @@ def collect_name_notes(data: dict[str, Any]) -> list[str]:
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 apply_names_wrap(
names_path: PathLike,
*,
dry_run: bool = False,
log_fn=None,
) -> tuple[bool, str]:
"""Run ``wolf names-wrap`` on one names.json file.
Returns ``(ok, summary)``. *ok* is False only when the CLI exits with a hard
error; overflow warnings still count as success.
"""
from util import wolfdawn
def load_name_wrap_config() -> tuple[bool, int, frozenset[str]]:
"""Return ``(enabled, width, notes)`` from workflow ``.env`` settings."""
import os
emit = log_fn or (lambda _msg: None)
path = Path(names_path)
if not path.is_file():
return False, f"names.json not found: {path}"
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
res = wolfdawn.names_wrap(path, dry_run=dry_run, log_fn=None)
for line in (res.stdout or "").splitlines():
line = line.strip()
if line:
emit(line)
for line in (res.stderr or "").splitlines():
line = line.strip()
if line:
emit(line)
if not res.ok and res.returncode not in (0, 2):
return False, f"names-wrap exited {res.returncode}"
count = wolfdawn.parse_names_wrap_counts(res.stdout, res.stderr)
if dry_run:
summary = (
f"dry run: {count} entr{'y' if count == 1 else 'ies'} would be re-wrapped"
if count is not None
else "dry run complete"
)
elif count is not None:
summary = f"re-wrapped {count} entr{'y' if count == 1 else 'ies'}"
else:
summary = "names-wrap complete"
return True, summary

View file

@ -1,8 +1,8 @@
"""Search translated WolfDawn JSON for text to fix wrapping.
Primary workflow: paste in-game text, find the database sheet and row, wrap at
the right width, re-inject. Sheet names match ``typeName`` in DB JSON and
``note`` in names.json (e.g. ``街の噂MOB``).
Paste in-game text, open the matching line, then wrap that line or every
overflowing line in the same group (database sheet, names category, map /
CommonEvent file, or Game.dat).
"""
from __future__ import annotations
@ -493,6 +493,160 @@ def wrap_line_in_doc(line: dict[str, Any], width: int) -> bool:
return True
@dataclass
class ScopeStats:
"""Line counts for the wrap group containing one search hit."""
label: str
total: int
overflow: int
def scope_label(hit: WrapHit | dict[str, Any]) -> str:
"""Human-readable group name for status messages."""
if isinstance(hit, WrapHit):
kind, sheet, jf = hit.kind, hit.sheet_name, hit.json_file
else:
kind = str(hit.get("kind") or "")
sheet = str(hit.get("sheet_name") or "")
jf = str(hit.get("json_file") or "")
if kind == "db":
return f"sheet {sheet}"
if kind == "names":
return f"category {sheet}"
if kind == "gamedat":
return "Game.dat"
if kind == "common":
return "CommonEvent"
if kind == "map":
return sheet or jf
return sheet or jf
def scope_stats(
doc: dict[str, Any],
hit: WrapHit | dict[str, Any],
width: int,
) -> ScopeStats:
"""Count lines and overflows in the same group as *hit*."""
if isinstance(hit, WrapHit):
kind, sheet_name = hit.kind, hit.sheet_name
else:
kind = str(hit.get("kind") or doc.get("kind") or "")
sheet_name = str(hit.get("sheet_name") or "")
total = 0
overflow = 0
if kind == "db":
for group in doc.get("groups") or []:
if str(group.get("typeName") or "") != sheet_name:
continue
for line in group.get("lines") or []:
text = line.get("text")
if not isinstance(text, str) or not text.strip():
continue
total += 1
if line_needs_wrap(text, width):
overflow += 1
break
elif kind == "names":
for entry in doc.get("names") or []:
if not isinstance(entry, dict):
continue
if str(entry.get("note") or "") != sheet_name:
continue
text = entry.get("text")
if not isinstance(text, str) or not text.strip():
continue
total += 1
if line_needs_wrap(text, width):
overflow += 1
elif kind in ("map", "common"):
for scene in doc.get("scenes") or []:
for line in scene.get("lines") or []:
text = line.get("text")
if not isinstance(text, str) or not text.strip():
continue
total += 1
if line_needs_wrap(text, width):
overflow += 1
elif kind == "gamedat":
for line in doc.get("lines") or []:
if not isinstance(line, dict):
continue
text = line.get("text")
if not isinstance(text, str) or not text.strip():
continue
total += 1
if line_needs_wrap(text, width):
overflow += 1
return ScopeStats(label=scope_label(hit), total=total, overflow=overflow)
def _wrap_overflow_in_event_file(path: Path, doc: dict[str, Any], width: int) -> int:
"""Wrap overflowing dialogue lines in one map or CommonEvent JSON file."""
if doc.get("kind") not in ("map", "common"):
return 0
changed = 0
for scene in doc.get("scenes") or []:
for line in scene.get("lines") or []:
text = line.get("text")
if not isinstance(text, str) or not text.strip():
continue
if not line_needs_wrap(text, width):
continue
if wrap_line_in_doc(line, width):
changed += 1
if changed:
save_document(path, doc)
return changed
def _wrap_overflow_in_gamedat(path: Path, doc: dict[str, Any], width: int) -> int:
if doc.get("kind") != "gamedat":
return 0
changed = 0
for line in doc.get("lines") or []:
if not isinstance(line, dict):
continue
text = line.get("text")
if not isinstance(text, str) or not text.strip():
continue
if not line_needs_wrap(text, width):
continue
if wrap_line_in_doc(line, width):
changed += 1
if changed:
save_document(path, doc)
return changed
def wrap_overflow_in_scope(
path: Path,
doc: dict[str, Any],
hit: WrapHit | dict[str, Any],
width: int,
) -> int:
"""Wrap every overflowing line in the same group as *hit*."""
if isinstance(hit, WrapHit):
kind, sheet_name = hit.kind, hit.sheet_name
else:
kind = str(hit.get("kind") or doc.get("kind") or "")
sheet_name = str(hit.get("sheet_name") or "")
if kind == "names":
return _wrap_overflow_in_names_category(path, doc, sheet_name, width)
if kind == "db":
return wrap_overflow_in_sheet(path, doc, sheet_name, width, kind="db")
if kind in ("map", "common"):
return _wrap_overflow_in_event_file(path, doc, width)
if kind == "gamedat":
return _wrap_overflow_in_gamedat(path, doc, width)
return 0
def wrap_hit_in_file(
path: Path,
doc: dict[str, Any],