fix wrap tab

This commit is contained in:
DazedAnon 2026-07-08 17:14:06 -05:00
parent 429ad43918
commit 42cda41458
7 changed files with 1813 additions and 56 deletions

View file

@ -330,9 +330,10 @@ Open the **Workflow** tab and choose **Wolf RPG (WolfDawn)** from the engine sel
| **3 Names** | Translate `names.json` (item/skill/enemy/map value names). WolfDawn tags each name with a per-entry **safety** badge (`safe`, `refs`, or `verify`). Phase 0 translates only `safe` and `refs` entries; `verify` names stay Japanese so inject skips them. Review the category breakdown for this game, pick **Translation mode** (Normal or Batch), and run **Translate safe names (Phase 0)**. |
| **4 Database** | Review the **discovery summary** to see where this game's text lives (standard RPG sheets vs custom dialogue tables). Database sheets are classified as foundation (items, skills, descriptions — translate first) or narrative (custom event/profile sheets — translate after foundation). Use **Translate foundation DB** then **Translate narrative DB**, or tick specific sheets and run **Translate checked sheets only**. Optional: copy the **DB structure prompt** for an AI audit and import the returned JSON into `wolf_json/db_profile.json`. |
| **5 Maps/Events** | Translate map scripts (`.mps`), common events (`CommonEvent.dat`), `Game.dat`, and Evtext. Run after Steps 34 so vocab and database terms are consistent. Configure speaker handling for low-confidence nameplate guesses. Batch mode is recommended for large `CommonEvent.dat` files. |
| **6 Inject** | Write translations back into the game's `Data/` binaries and refresh git-tracked `wolf_json/`. Uses pristine originals from `wolf_json/originals/`. Quick inject or inject all; optional relayout reflows message boxes for English text length. |
| **7 Package** | Run from loose `Data/` (backs up `Data.wolf``.bak`) or repack `Data.wolf`. |
| **8 Saves** | *(Optional)* Update existing `.sav` files for the translated build. |
| **6 Inject** | Write translations back into the game's `Data/` binaries and refresh git-tracked `wolf_json/`. Uses pristine originals from `wolf_json/originals/`. Quick inject or inject all. |
| **7 Fix wrap** | Paste overflowing in-game text to **search** `translated/` JSON and jump to the database sheet and row (sheet names match Step 4 and `names.json` notes, e.g. `├■街の噂MOB`). Edit the line, set wrap width, **Wrap this row** or **Wrap all overflowing rows in this sheet**, save, then **Inject this file** (usually `DataBase.project.json`). Per-sheet widths are remembered in `wolf_json/wrap_profile.json`. **Advanced:** **wolf relayout** for event message boxes (maps/CommonEvent only — not bulletin-style DB UI) and **wolf desc-relayout** for standard 説明 description fields. |
| **8 Package** | Run from loose `Data/` (backs up `Data.wolf``.bak`) or repack `Data.wolf`. |
| **9 Saves** | *(Optional)* Update existing `.sav` files for the translated build. |
### Recommended order by game layout

View file

@ -18,10 +18,10 @@ Mirrors the RPGMaker WorkflowTab, driven by the vendored WolfDawn ``wolf`` CLI
and refresh the git-tracked wolf_json/ with the translated
JSON; quick-inject picks just a few translated files for
fast in-game / git iteration, or inject everything at once.
After a successful inject, optional WolfDawn relayout reflows
Message text (and optionally DB descriptions) to the message box.
Step 7 Package - run from a loose Data/ folder, or repack Data.wolf
Step 8 Saves - fix baked strings in existing .sav files (optional)
Step 7 Fix wrap - search translated JSON by in-game text; wrap DB sheet rows;
re-inject (Advanced: wolf relayout for event messages)
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+refs entries
@ -44,7 +44,7 @@ import tempfile
from pathlib import Path
from PyQt5.QtCore import Qt, QSettings, QThread, QTimer, pyqtSignal, QEvent
from PyQt5.QtGui import QColor, QFont
from PyQt5.QtGui import QColor, QFont, QTextCharFormat, QTextCursor
from PyQt5.QtWidgets import (
QApplication,
QAbstractItemView,
@ -87,6 +87,10 @@ from gui.workflow_tab import (
)
from util.wolfdawn import names as wolf_names
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
from util.paths import PROJECT_ROOT
from util.project_scanner import (
detect_wolf_layout,
@ -581,8 +585,9 @@ class WolfWorkflowTab(QWidget):
("4 Database", self._build_step4_database),
("5 Maps/Events", self._build_step5_maps_events),
("6 Inject", self._build_step4_inject),
("7 Package", self._build_step5_package),
("8 Saves", self._build_step6_saves),
("7 Fix wrap", self._build_step7_relayout),
("8 Package", self._build_step5_package),
("9 Saves", self._build_step6_saves),
]
for tab_label, builder in _tab_defs:
@ -2595,26 +2600,184 @@ class WolfWorkflowTab(QWidget):
layout.addWidget(self._subheading("Full inject"))
layout.addWidget(self._desc(
"Injects every extracted document and applies translated name values from names.json "
"across Data/. Use this once translation is complete before packaging in Step 7."
"across Data/. Use this once translation is complete before packaging in Step 8."
))
inject_btn = self._register(_make_btn("Inject all translations", "#00a86b"))
inject_btn.clicked.connect(lambda: self._inject())
layout.addWidget(inject_btn)
self._add_relayout_options(layout)
def _add_relayout_options(self, layout: QVBoxLayout):
"""Post-inject WolfDawn message-box / DB-description reflow."""
layout.addWidget(_make_hr())
layout.addWidget(self._subheading("Relayout (after inject)"))
layout.addWidget(self._desc(
"Runs WolfDawn on the live Data/ binaries after a successful inject: reflow Message "
"text to the message-box width (page-split overflow), and optionally refit DB "
"description fields. Layout is no longer done at translate time - leave dialogue "
"unwrapped in JSON and let this step fix the box."
"English text is often longer than Japanese. Step 7 (Fix wrapping): paste "
"overflowing in-game text to find the sheet, wrap, and re-inject."
))
self._relayout_after_cb = QCheckBox("Relayout after inject")
def _build_step7_relayout(self, layout: QVBoxLayout):
"""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."
))
search_row = QHBoxLayout()
self._wrap_search_edit = QLineEdit()
self._wrap_search_edit.setPlaceholderText(
'Paste in-game text, e.g. "there\'s a pure" or "gatekeeper just randomly"'
)
search_btn = _make_btn("Search", "#007acc")
search_btn.clicked.connect(self._run_wrap_search)
self._wrap_search_edit.returnPressed.connect(self._run_wrap_search)
search_row.addWidget(self._wrap_search_edit, 1)
search_row.addWidget(search_btn)
layout.addLayout(search_row)
self._wrap_results_list = QListWidget()
self._wrap_results_list.setMaximumHeight(160)
self._wrap_results_list.setStyleSheet(
"QListWidget{background-color:#252526;border:1px solid #3a3a3a;border-radius:4px;"
"color:#cccccc;font-size:12px;padding:2px;}"
"QListWidget::item{padding:4px 6px;}"
"QListWidget::item:selected{background:#264f78;color:#ffffff;}"
)
self._wrap_results_list.currentItemChanged.connect(self._on_wrap_hit_selected)
layout.addWidget(self._wrap_results_list)
self._wrap_detail_label = QLabel("Select a search result to edit.")
self._wrap_detail_label.setWordWrap(True)
self._wrap_detail_label.setStyleSheet(
"color:#9cdcfe;font-size:12px;background:transparent;"
)
layout.addWidget(self._wrap_detail_label)
self._wrap_text_editor = QTextEdit()
self._wrap_text_editor.setMaximumHeight(120)
self._wrap_text_editor.setStyleSheet(
"QTextEdit{background-color:#252526;color:#d4d4d4;border:1px solid #3c3c3c;"
"font-family:monospace;font-size:12px;padding:6px;}"
)
self._wrap_text_editor.setEnabled(False)
self._wrap_text_editor.textChanged.connect(self._update_wrap_preview)
layout.addWidget(self._wrap_text_editor)
width_row = QHBoxLayout()
w_lbl = QLabel("Wrap width (characters):")
w_lbl.setStyleSheet("color:#cccccc;font-size:12px;background:transparent;")
self._wrap_width_spin = QSpinBox()
self._wrap_width_spin.setRange(10, 120)
try:
self._wrap_width_spin.setValue(int(self._setting("wrap_fix_width", 36) or 36))
except (TypeError, ValueError):
self._wrap_width_spin.setValue(36)
self._wrap_width_spin.setFixedWidth(70)
self._wrap_width_spin.valueChanged.connect(self._on_wrap_width_changed)
width_row.addWidget(w_lbl)
width_row.addWidget(self._wrap_width_spin)
width_row.addStretch()
layout.addLayout(width_row)
self._wrap_preview_label = QLabel("Wrap preview")
self._wrap_preview_label.setStyleSheet(
"color:#858585;font-size:11px;background:transparent;"
)
layout.addWidget(self._wrap_preview_label)
self._wrap_preview = QTextEdit()
self._wrap_preview.setReadOnly(True)
self._wrap_preview.setMaximumHeight(110)
self._wrap_preview.setPlaceholderText(
"Wrapped lines appear here when you select a result and adjust width."
)
self._wrap_preview.setStyleSheet(
"QTextEdit{background-color:#1e1e1e;color:#b5cea8;border:1px solid #3c3c3c;"
"border-left:3px solid #007acc;font-family:monospace;font-size:12px;padding:6px;}"
)
layout.addWidget(self._wrap_preview)
btn_row = QHBoxLayout()
wrap_row_btn = _make_btn("Wrap this row", "#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)
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(save_row_btn)
btn_row.addWidget(inject_btn)
btn_row.addStretch()
layout.addLayout(btn_row)
self._wrap_status_label = QLabel("")
self._wrap_status_label.setWordWrap(True)
self._wrap_status_label.setStyleSheet(
"color:#8fbc8f;font-size:12px;background:transparent;"
)
layout.addWidget(self._wrap_status_label)
layout.addWidget(_make_hr())
sheets_toggle = QPushButton("▸ Browse database sheets (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(
"Sheets with lines longer than the wrap width above. Double-click to open in "
"the editor (first overflowing line)."
))
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"
)
@ -2626,20 +2789,8 @@ class WolfWorkflowTab(QWidget):
)
layout.addWidget(self._relayout_after_cb)
self._relayout_desc_cb = QCheckBox("Also refit DB descriptions (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)
msg_row = QHBoxLayout()
width_lbl = QLabel("Message width (cells):")
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)
@ -2651,7 +2802,7 @@ class WolfWorkflowTab(QWidget):
self._relayout_width_spin.valueChanged.connect(
lambda v: self._save_setting("relayout_width", v)
)
max_rows_lbl = QLabel("Max rows (0=default):")
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)
@ -2670,8 +2821,24 @@ class WolfWorkflowTab(QWidget):
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 (cells):")
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)
@ -2706,9 +2873,387 @@ class WolfWorkflowTab(QWidget):
desc_row.addStretch()
layout.addLayout(desc_row)
now_btn = self._register(_make_btn("Relayout Data/ now", "#007acc"))
now_btn.clicked.connect(lambda: self._run_relayout(manual=True))
layout.addWidget(now_btn)
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"
def _wrap_search_extra_dirs(self) -> list[Path]:
work = self._wolf_json_work_dir()
return [work] if work is not None else []
def _wrap_profile_width(self, sheet_name: str) -> int:
work = self._wolf_json_work_dir()
if work is not None:
profile = wolf_ws.load_wrap_profile(work)
return wolf_ws.get_sheet_width(
profile,
sheet_name,
default=int(self._setting("wrap_fix_width", 36) or 36),
)
try:
return int(self._setting("wrap_fix_width", 36) or 36)
except (TypeError, ValueError):
return 36
def _remember_sheet_width(self, sheet_name: str, width: int, json_file: str):
work = self._wolf_json_work_dir()
if work is not None:
wolf_ws.set_sheet_width(work, sheet_name, width, json_file=json_file)
def _on_wrap_width_changed(self, value: int):
self._save_setting("wrap_fix_width", value)
self._update_wrap_preview()
def _update_wrap_preview(self):
if not hasattr(self, "_wrap_preview"):
return
editor = getattr(self, "_wrap_text_editor", None)
if editor is None or not editor.isEnabled():
self._wrap_preview.clear()
if hasattr(self, "_wrap_preview_label"):
self._wrap_preview_label.setText("Wrap preview")
if editor is not None:
editor.setExtraSelections([])
return
text = editor.toPlainText()
width = self._wrap_width_spin.value()
summary = wolf_ws.wrap_preview_summary(text, width)
preview = wolf_ws.format_wrap_preview(text, width)
info = wolf_ws.wrap_preview_info(text, width)
if hasattr(self, "_wrap_preview_label"):
if summary:
self._wrap_preview_label.setText(summary)
color = "#ce9178" if info.get("needs_wrap") else "#8fbc8f"
self._wrap_preview_label.setStyleSheet(
f"color:{color};font-size:11px;background:transparent;"
)
else:
self._wrap_preview_label.setText("Wrap preview")
self._wrap_preview_label.setStyleSheet(
"color:#858585;font-size:11px;background:transparent;"
)
self._wrap_preview.setPlainText(preview)
border = "#ce9178" if info.get("needs_wrap") else "#007acc"
self._wrap_preview.setStyleSheet(
"QTextEdit{background-color:#1e1e1e;color:#b5cea8;border:1px solid #3c3c3c;"
f"border-left:3px solid {border};font-family:monospace;font-size:12px;padding:6px;}}"
)
self._apply_wrap_source_highlights(text, width, info)
def _apply_wrap_source_highlights(
self,
text: str,
width: int,
info: dict,
):
"""Mark in-width vs overflow segments on the source editor."""
editor = getattr(self, "_wrap_text_editor", None)
if editor is None or not editor.isEnabled() or not text.strip():
editor and editor.setExtraSelections([])
return
fmt_fit = QTextCharFormat()
fmt_fit.setBackground(QColor("#264f78"))
fmt_overflow = QTextCharFormat()
fmt_overflow.setBackground(QColor("#4a3728"))
selections = []
norm = text.replace("\r\n", "\n").replace("\r", "\n")
offset = 0
for line in norm.split("\n"):
line_len = len(line)
fit_end, _overflow = wolf_ws.split_line_at_visible_width(line, width)
if fit_end > 0:
sel = QTextEdit.ExtraSelection()
cursor = QTextCursor(editor.document())
cursor.setPosition(offset)
cursor.setPosition(offset + fit_end, QTextCursor.KeepAnchor)
sel.cursor = cursor
sel.format = fmt_fit
selections.append(sel)
if fit_end < line_len:
sel = QTextEdit.ExtraSelection()
cursor = QTextCursor(editor.document())
cursor.setPosition(offset + fit_end)
cursor.setPosition(offset + line_len, QTextCursor.KeepAnchor)
sel.cursor = cursor
sel.format = fmt_overflow
selections.append(sel)
offset += line_len + 1
editor.setExtraSelections(selections)
def _run_wrap_search(self):
query = self._wrap_search_edit.text().strip()
if not query:
return
tdir, fdir = self._translated_and_files_dirs()
extra = self._wrap_search_extra_dirs()
hits = wolf_ws.search_translated_text(
query, tdir, files_dir=fdir, extra_dirs=extra,
)
self._wrap_results_list.clear()
self._current_wrap_hit = None
if not hits:
where = "translated/, files/"
if extra:
where += f", {WORK_DIR_NAME}/"
item = QListWidgetItem(f"No matches in {where}.")
item.setFlags(Qt.ItemIsEnabled)
self._wrap_results_list.addItem(item)
self._wrap_detail_label.setText("No matches.")
self._wrap_text_editor.clear()
self._wrap_text_editor.setEnabled(False)
self._update_wrap_preview()
return
width = self._wrap_width_spin.value()
for hit in hits:
item = QListWidgetItem(hit.summary(width))
item.setData(Qt.UserRole, hit.hit_id)
item.setData(Qt.UserRole + 1, hit)
self._wrap_results_list.addItem(item)
self._wrap_results_list.setCurrentRow(0)
self._wrap_status_label.setText(f"Found {len(hits)} match(es).")
def _on_wrap_hit_selected(self, current: QListWidgetItem | None, _previous):
if current is None:
return
hit = current.data(Qt.UserRole + 1)
if not isinstance(hit, wolf_ws.WrapHit):
return
self._current_wrap_hit = hit
self._current_wrap_hit_id = hit.hit_id
tdir, fdir = self._translated_and_files_dirs()
extra = self._wrap_search_extra_dirs()
path, doc, line = wolf_ws.load_hit_from_id(
tdir, hit.hit_id, files_dir=fdir, extra_dirs=extra,
)
self._current_wrap_path = path
self._current_wrap_doc = doc
w = self._wrap_profile_width(hit.sheet_name)
self._wrap_width_spin.blockSignals(True)
self._wrap_width_spin.setValue(w)
self._wrap_width_spin.blockSignals(False)
if line is not None:
text = str(line.get("text") or hit.text)
else:
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._update_wrap_preview()
def _save_wrap_row_text(self):
if not self._current_wrap_hit_id or not self._current_wrap_path or not self._current_wrap_doc:
QMessageBox.information(self, "Fix wrapping", "Select a search result first.")
return
line = wolf_ws.locate_line(self._current_wrap_doc, self._current_wrap_hit_id)
if line is None:
QMessageBox.warning(self, "Fix wrapping", "Could not locate line in JSON.")
return
line["text"] = self._wrap_text_editor.toPlainText()
wolf_ws.save_document(self._current_wrap_path, self._current_wrap_doc)
self._wrap_status_label.setText(f"Saved {self._current_wrap_path.name}.")
self._log(f"✅ Saved wrap edit to {self._current_wrap_path.name}")
def _wrap_current_row(self):
if not self._current_wrap_hit_id or not self._current_wrap_path or not self._current_wrap_doc:
QMessageBox.information(self, "Fix wrapping", "Select a search result first.")
return
width = self._wrap_width_spin.value()
line = wolf_ws.locate_line(self._current_wrap_doc, self._current_wrap_hit_id)
if line is not None:
line["text"] = self._wrap_text_editor.toPlainText()
changed = wolf_ws.wrap_hit_in_file(
self._current_wrap_path,
self._current_wrap_doc,
self._current_wrap_hit_id,
width,
)
if self._current_wrap_hit:
self._remember_sheet_width(
self._current_wrap_hit.sheet_name,
width,
self._current_wrap_hit.json_file,
)
path, doc, line = wolf_ws.load_hit_from_id(
self._translated_and_files_dirs()[0],
self._current_wrap_hit_id,
files_dir=self._translated_and_files_dirs()[1],
extra_dirs=self._wrap_search_extra_dirs(),
)
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."
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):
hit = self._current_wrap_hit
if not hit or hit.kind != "db":
QMessageBox.information(
self, "Fix wrapping",
"Select a database sheet result first (not map dialogue).",
)
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)
self._remember_sheet_width(hit.sheet_name, width, hit.json_file)
self._refresh_wrap_sheets_list()
msg = f"Wrapped {count} overflowing line(s) in {hit.sheet_name} at width {width}."
self._wrap_status_label.setText(msg + f" Inject {hit.json_file}.")
self._log(f"{msg}")
def _inject_current_wrap_file(self):
hit = self._current_wrap_hit
if not hit:
QMessageBox.information(self, "Inject", "Select a search result first.")
return
if not self._require_manifest():
return
self._inject(only_json={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 JSON — translate DB sheets first.")
item.setFlags(Qt.ItemIsEnabled)
self._wrap_sheets_list.addItem(item)
return
for s in sorted(summaries, key=lambda x: (-x.overflow_count, x.sheet_name)):
flag = f"{s.overflow_count} overflow" if s.overflow_count else " ok"
item = QListWidgetItem(
f"{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)
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)
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"))
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._wrap_search_edit.setText(str(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)
return
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.)."""
@ -2876,6 +3421,10 @@ class WolfWorkflowTab(QWidget):
# Index 6 == Inject: keep the quick-inject picker 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"):
@ -3249,8 +3798,6 @@ class WolfWorkflowTab(QWidget):
if do_desc and not failures:
projects = self._find_db_projects(data_dir)
if not projects:
pass
for proj in projects:
with tempfile.TemporaryDirectory(prefix="wolfdawn-desc-") as tmp:
tmp_path = Path(tmp)
@ -3283,7 +3830,7 @@ class WolfWorkflowTab(QWidget):
if failures:
return False, "Relayout failed — " + "; ".join(failures)
return True, "Relayout complete." + (
" Continue to Step 7 to package." if not manual else ""
" Continue to Step 8 to package." if not manual else ""
)
self._run_task(task)
@ -3291,7 +3838,7 @@ class WolfWorkflowTab(QWidget):
# ── Step 5: Package ────────────────────────────────────────────────────────
def _build_step5_package(self, layout: QVBoxLayout):
layout.addWidget(_make_section_label("Step 7 · Package the Translated Game"))
layout.addWidget(_make_section_label("Step 8 · Package the Translated Game"))
layout.addWidget(self._desc(
"Choose how the translated build runs. A loose Data/ folder is simplest for "
"playtesting; repacking rebuilds a single Data.wolf archive for distribution."
@ -3369,7 +3916,7 @@ class WolfWorkflowTab(QWidget):
# ── Step 6: Saves ──────────────────────────────────────────────────────────
def _build_step6_saves(self, layout: QVBoxLayout):
layout.addWidget(_make_section_label("Step 8 · Update Existing Saves (optional)"))
layout.addWidget(_make_section_label("Step 9 · Update Existing Saves (optional)"))
layout.addWidget(self._desc(
"WOLF .sav files bake in the game title and some strings at save time. This rewrites "
"them so old Japanese saves load cleanly in the translated build. It is only needed "

View file

@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""Unit tests for selective DB wrap in util/wolfdawn/selective_wrap.py."""
from __future__ import annotations
import json
import os
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
os.chdir(ROOT)
sys.path.insert(0, str(ROOT))
from util.wolfdawn import selective_wrap as sw # noqa: E402
import util.dazedwrap as dazedwrap # noqa: E402
SAMPLE = {
"file": "DataBase.project",
"kind": "db",
"groups": [
{
"type": 10,
"typeName": "■イベント(テスト)",
"lines": [
{
"row": 0,
"field": 1,
"fieldName": "好きなもの_コメント",
"source": "短い",
"text": "Short text that fits",
},
{
"row": 1,
"field": 1,
"fieldName": "好きなもの_コメント",
"source": "長い",
"text": (
"This is a very long English comment that definitely "
"exceeds forty characters in a single line"
),
},
],
},
{
"type": 0,
"typeName": "Item · アイテム",
"lines": [
{
"row": 0,
"field": 1,
"fieldName": "Description · 説明",
"source": "x",
"text": "Also a long item description that should wrap when selected",
},
],
},
],
}
class TestLineNeedsWrap(unittest.TestCase):
def test_short_line_skipped(self):
self.assertFalse(sw.line_needs_wrap("Short", 40, min_visible=20))
def test_long_line_needs_wrap(self):
text = "A" * 50
self.assertTrue(sw.line_needs_wrap(text, 40))
def test_color_and_font_codes_do_not_count_toward_width(self):
text = r"Hello \c[21]\f[20]tavern\c[19] end"
self.assertFalse(sw.line_needs_wrap(text, 20))
self.assertTrue(sw.line_needs_wrap(text, 10))
def test_wrap_preserves_inline_codes(self):
text = r"Go to the \c[21]\f[20]tavern\c[19]\f[18] tonight for fun"
wrapped = sw.wrap_line_text(text, 18)
self.assertIn(r"\c[21]", wrapped)
self.assertIn(r"\f[20]", wrapped)
for line in wrapped.split("\n"):
self.assertLessEqual(dazedwrap._get_visible_length(line), 18)
class TestWrapDbDocument(unittest.TestCase):
def test_only_checked_sheet_and_field(self):
keys = frozenset({"DataBase.project.json|■イベント(テスト)"})
pattern = sw.FIELD_PRESET_COMMENTS
doc = json.loads(json.dumps(SAMPLE))
wrapped, skipped = sw.wrap_db_document(
doc,
group_keys=keys,
field_pattern=pattern,
width=30,
min_visible=0,
)
self.assertEqual(wrapped, 1)
long_line = doc["groups"][0]["lines"][1]["text"]
self.assertIn("\n", long_line)
short_line = doc["groups"][0]["lines"][0]["text"]
self.assertNotIn("\n", short_line)
def test_group_type_indices(self):
keys = frozenset({"DataBase.project.json|■イベント(テスト)"})
self.assertEqual(sw.group_type_indices(SAMPLE, keys), [10])
class TestWrapTranslatedDir(unittest.TestCase):
def test_writes_touched_file(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "DataBase.project.json"
path.write_text(json.dumps(SAMPLE), encoding="utf-8")
result = sw.wrap_db_translated_dir(
tmp,
group_keys=frozenset({"DataBase.project.json|■イベント(テスト)"}),
field_presets=frozenset({"comments"}),
width=30,
min_visible=0,
)
self.assertEqual(result.lines_wrapped, 1)
self.assertEqual(result.files_touched, 1)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,218 @@
#!/usr/bin/env python3
"""Unit tests for search-first wrap workflow (util/wolfdawn/wrap_search.py)."""
from __future__ import annotations
import json
import os
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
os.chdir(ROOT)
sys.path.insert(0, str(ROOT))
import util.dazedwrap as dazedwrap # noqa: E402
from util.wolfdawn import wrap_search as ws # noqa: E402
RUMOR_SHEET = "├■街の噂MOB"
GATEKEEPER_TEXT = (
"That gatekeeper just randomly hits on any pretty woman he sees. "
"They say he once tried to pick up a horned rabbit."
)
GATEKEEPER_ROW = 26
GATEKEEPER_FIELD = "町のウワサ0"
RUMOR_FIXTURE = {
"file": "DataBase.project",
"kind": "db",
"groups": [
{
"type": 29,
"typeName": RUMOR_SHEET,
"lines": [
{
"row": 25,
"field": 1,
"fieldName": "町のウワサ0",
"source": "別の噂",
"text": "Short rumor that fits fine.",
},
{
"row": GATEKEEPER_ROW,
"field": 1,
"fieldName": GATEKEEPER_FIELD,
"source": "門番",
"text": GATEKEEPER_TEXT,
},
],
}
],
}
class TestWolfVisibleLength(unittest.TestCase):
def test_font_codes_do_not_count_toward_width(self):
plain = "Hello world"
coded = r"Hello \f[2]world"
self.assertEqual(
dazedwrap.max_line_visible_length(plain),
dazedwrap.max_line_visible_length(coded),
)
def test_gatekeeper_line_is_overflow_at_bulletin_width(self):
length = dazedwrap.max_line_visible_length(GATEKEEPER_TEXT)
self.assertGreater(length, 40)
self.assertGreater(length, 34)
class TestWrapSearch(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.translated = Path(self.tmp.name) / "translated"
self.translated.mkdir()
self.work = Path(self.tmp.name) / "wolf_json"
self.work.mkdir()
path = self.translated / "DataBase.project.json"
path.write_text(json.dumps(RUMOR_FIXTURE, ensure_ascii=False, indent=4), encoding="utf-8")
def tearDown(self):
self.tmp.cleanup()
def test_search_finds_gatekeeper_in_rumor_sheet(self):
hits = ws.search_translated_text("gatekeeper just randomly", self.translated)
self.assertEqual(len(hits), 1)
hit = hits[0]
self.assertEqual(hit.kind, "db")
self.assertEqual(hit.sheet_name, RUMOR_SHEET)
self.assertEqual(hit.row, GATEKEEPER_ROW)
self.assertEqual(hit.field_name, GATEKEEPER_FIELD)
self.assertEqual(hit.json_file, "DataBase.project.json")
self.assertIn("gatekeeper", hit.text.lower())
def test_search_finds_horned_rabbits_same_row(self):
hits = ws.search_translated_text("horned rabbit", self.translated)
self.assertEqual(len(hits), 1)
self.assertEqual(hits[0].row, GATEKEEPER_ROW)
def test_wrap_overflow_in_sheet_changes_long_lines(self):
path = self.translated / "DataBase.project.json"
doc = json.loads(path.read_text(encoding="utf-8"))
changed = ws.wrap_overflow_in_sheet(path, doc, RUMOR_SHEET, width=34)
self.assertEqual(changed, 1)
doc2 = json.loads(path.read_text(encoding="utf-8"))
line = ws.locate_line(
doc2,
{
"kind": "db",
"sheet_name": RUMOR_SHEET,
"row": GATEKEEPER_ROW,
"field_name": GATEKEEPER_FIELD,
},
)
self.assertIsNotNone(line)
assert line is not None
self.assertIn("\n", line["text"])
self.assertLessEqual(dazedwrap.max_line_visible_length(line["text"]), 34)
def test_wrap_profile_remembers_sheet_width(self):
ws.set_sheet_width(self.work, RUMOR_SHEET, 34, json_file="DataBase.project.json")
profile = ws.load_wrap_profile(self.work)
self.assertEqual(ws.get_sheet_width(profile, RUMOR_SHEET), 34)
entry = profile["sheets"][RUMOR_SHEET]
self.assertEqual(entry["json_file"], "DataBase.project.json")
def test_sheet_overflow_summary_counts_gatekeeper_row(self):
summaries = ws.sheet_overflow_summaries(self.translated, width=34)
rumor = [s for s in summaries if s.sheet_name == RUMOR_SHEET]
self.assertEqual(len(rumor), 1)
self.assertEqual(rumor[0].overflow_count, 1)
self.assertEqual(rumor[0].line_count, 2)
class TestNamesSearch(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.translated = Path(self.tmp.name) / "translated"
self.translated.mkdir()
fixture = {
"kind": "names",
"count": 1,
"names": [
{
"source": "ブロthel scout line",
"text": (
"Maybe I'll go scout out some newcomers at the brothel.\n"
"\u3000Hope there's a pure-looking girl with big boobs..."
),
"note": "Profile · プロフィール",
"safety": "safe",
}
],
}
(self.translated / "names.json").write_text(
json.dumps(fixture, ensure_ascii=False, indent=4),
encoding="utf-8",
)
def tearDown(self):
self.tmp.cleanup()
def test_search_finds_names_json_profile_text(self):
hits = ws.search_translated_text("there's a pure", self.translated)
self.assertEqual(len(hits), 1)
hit = hits[0]
self.assertEqual(hit.kind, "names")
self.assertEqual(hit.json_file, "names.json")
self.assertIn("pure-looking", hit.text)
def test_load_hit_from_names_index(self):
hits = ws.search_translated_text("pure-looking", self.translated)
self.assertEqual(len(hits), 1)
path, doc, line = ws.load_hit_from_id(self.translated, hits[0].hit_id)
self.assertIsNotNone(path)
assert path is not None and line is not None
self.assertEqual(path.name, "names.json")
self.assertIn("pure-looking", line["text"])
class TestWrapPreview(unittest.TestCase):
SAMPLE = (
'"Apparently there\'s a super hot female adventurer who just '
'came to town!? Maybe I can meet her if I head to the '
r'\c[21]\f[20]tavern\c[19]\f[18]!"'
)
def test_preview_detects_wrap_needed(self):
info = ws.wrap_preview_info(self.SAMPLE, 40)
self.assertTrue(info["needs_wrap"])
self.assertGreater(info["longest"], 40)
self.assertGreater(info["output_line_count"], 1)
def test_format_preview_shows_line_counts(self):
preview = ws.format_wrap_preview(self.SAMPLE, 40)
self.assertIn("(40)", preview)
self.assertIn("Apparently", preview)
def test_split_line_at_visible_width(self):
line = "Apparently there's a super hot female adventurer who just"
fit_end, line_len = ws.split_line_at_visible_width(line, 40)
self.assertEqual(line_len, len(line))
self.assertGreater(fit_end, 0)
self.assertLess(fit_end, line_len)
self.assertLessEqual(
dazedwrap.max_line_visible_length(line[:fit_end]),
40,
)
def test_codes_do_not_inflate_visible_counts(self):
text = r'See the \c[21]\f[20]tavern\c[19]\f[18] on Main Street'
info = ws.wrap_preview_info(text, 30)
self.assertLess(info["longest"], len(text))
self.assertLessEqual(info["longest"], 30 + 10)
if __name__ == "__main__":
unittest.main()

View file

@ -1,20 +1,83 @@
import re
# Wolf / RPG Maker inline codes that do not occupy visible box width.
_WOLF_INLINE_VISIBLE_STRIP = re.compile(
r"\\(?:"
r"r\[[^\]]*\]|"
r"c(?:self)?\[[^\]]*\]|"
r"f\[[^\]]*\]|"
r"[A-Za-z]+\[[^\]]*\]|"
r"\^|"
r"[ @<>\-]|"
r"[A-Za-z]"
r")"
)
_WOLF_CODE_PLACEHOLDER = re.compile(r"__WOLF_CODE_\d+__")
def _get_visible_length(text: str) -> int:
"""
Calculate the visible length of text, ignoring RPG Maker color codes.
Args:
text (str): The text to measure
Returns:
int: The length of the text excluding color codes
Calculate the visible length of text, ignoring inline control codes.
Handles Wolf ``\\f[N]``, ``\\c[N]``, ruby ``\\r[...]``, and legacy RPG Maker
color codes so wrap-width checks match in-game bulletin / message boxes.
"""
# Remove all color codes like \c[5] or \C[35], and also ignore \\! and \\.
cleaned_text = re.sub(r'[\\]+[cC]\[\d+\]', '', text)
# Remove \\! and \\.
cleaned_text = re.sub(r'[\\]+.', '', cleaned_text)
return len(cleaned_text)
if not text:
return 0
cleaned = _WOLF_INLINE_VISIBLE_STRIP.sub("", text)
cleaned = _WOLF_CODE_PLACEHOLDER.sub("", cleaned)
cleaned = re.sub(r"[\\]+[cC]\[\d+\]", "", cleaned)
cleaned = re.sub(r"[\\]+[fF](?:\[\d+\])?", "", cleaned)
cleaned = re.sub(r"[\\]+.", "", cleaned)
cleaned = re.sub(r"\s+", " ", cleaned).strip()
return len(cleaned)
def visible_word_wrap_end_index(text: str, width: int) -> int:
"""Return index into *text* after the last word that fits *width* visible chars.
Uses the same word-wrapping rules as :func:`wrapText`. Wolf ``\\c[]`` /
``\\f[]`` codes do not count toward *width*.
"""
if not text or width <= 0:
return len(text)
if _get_visible_length(text) <= width:
return len(text)
words = text.split()
if not words:
return len(text)
current_len = 0
kept = 0
pos = 0
fit_end = 0
for word in words:
wl = _get_visible_length(word)
gap = 1 if kept > 0 else 0
if current_len + gap + wl <= width:
idx = text.find(word, pos)
if idx < 0:
break
pos = idx + len(word)
fit_end = pos
kept += 1
current_len += gap + wl
else:
break
if kept == 0 and words:
idx = text.find(words[0], 0)
fit_end = (idx + len(words[0])) if idx >= 0 else len(text)
return fit_end
def max_line_visible_length(text: str) -> int:
"""Longest visible line length in *text* (handles ``\\r`` / ``\\n``)."""
if not isinstance(text, str) or not text:
return 0
normalized = text.replace("\r\n", "\n").replace("\r", "\n")
lines = normalized.split("\n") if normalized else [""]
if not lines:
return _get_visible_length(normalized)
return max(_get_visible_length(line) for line in lines)
def wrapText(text: str, width: int) -> str:
"""

View file

@ -0,0 +1,218 @@
"""Selective word-wrap for WolfDawn translated JSON.
Bulk ``wolf relayout`` / ``desc-relayout`` cannot target individual DB rows or
custom field types (e.g. ``セリフ_メッセージ``, ``好きなもの_コメント``).
This module wraps ``text`` fields in ``translated/*.project.json`` for checked
database sheets and field patterns, then the user re-injects only those files.
``wolf relayout`` for maps already skips messages that fit; use it for dialogue
overflow. Use selective JSON wrap for custom DB columns and fine-grained control.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import util.dazedwrap as dazedwrap
from util.wolfdawn.db_classify import group_key, json_file_from_doc
# Field-name presets for the Relayout tab (match ``fieldName`` substrings).
FIELD_PRESET_DESCRIPTIONS = re.compile(r"説明|Description", re.IGNORECASE)
FIELD_PRESET_COMMENTS = re.compile(r"コメント")
FIELD_PRESET_DIALOGUE = re.compile(r"セリフ|台詞|会話")
FIELD_PRESET_MESSAGES = re.compile(r"メッセージ")
FIELD_PRESET_ALL = re.compile(r".", re.DOTALL)
FIELD_PRESETS: dict[str, re.Pattern[str]] = {
"descriptions": FIELD_PRESET_DESCRIPTIONS,
"comments": FIELD_PRESET_COMMENTS,
"dialogue": FIELD_PRESET_DIALOGUE,
"messages": FIELD_PRESET_MESSAGES,
}
@dataclass
class WrapResult:
files_touched: int = 0
lines_wrapped: int = 0
lines_skipped: int = 0
touched_json: list[str] | None = None
def __post_init__(self):
if self.touched_json is None:
self.touched_json = []
def combine_field_patterns(enabled: frozenset[str]) -> re.Pattern[str] | None:
"""Merge enabled preset keys into one regex, or None when nothing selected."""
if not enabled:
return None
parts = []
for key in enabled:
pat = FIELD_PRESETS.get(key)
if pat is not None and key != "all":
parts.append(f"(?:{pat.pattern})")
if "all" in enabled:
return FIELD_PRESET_ALL
if not parts:
return None
return re.compile("|".join(parts), re.IGNORECASE)
def _visible_length(text: str) -> int:
return dazedwrap._get_visible_length(text)
def line_needs_wrap(text: str, width: int, *, min_visible: int = 0) -> bool:
"""True when *text* is long enough and exceeds *width* on at least one line."""
if not isinstance(text, str) or not text.strip() or width <= 0:
return False
lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
if not lines:
visible = _visible_length(text)
if min_visible > 0 and visible < min_visible:
return False
return visible > width
total_visible = max((_visible_length(line) for line in lines), default=0)
if min_visible > 0 and total_visible < min_visible:
return False
return any(_visible_length(line) > width for line in lines)
def wrap_line_text(text: str, width: int) -> str:
"""Word-wrap one ``text`` value, preserving WOLF inline codes.
Width is measured with :func:`util.dazedwrap._get_visible_length`, so
``\\c[N]``, ``\\f[N]``, and similar codes do not consume box width.
"""
if not isinstance(text, str) or not text.strip() or width <= 0:
return text
return dazedwrap.wrapText(text, width)
def group_type_indices(doc: dict[str, Any], group_keys: frozenset[str]) -> list[int]:
"""Return sorted DB ``type`` indices for selected sheet keys."""
jf = json_file_from_doc(doc)
indices: set[int] = set()
for group in doc.get("groups") or []:
type_name = str(group.get("typeName") or "")
if group_key(jf, type_name) not in group_keys:
continue
try:
idx = int(group.get("type", -1))
except (TypeError, ValueError):
continue
if idx >= 0:
indices.add(idx)
return sorted(indices)
def wrap_db_document(
doc: dict[str, Any],
*,
group_keys: frozenset[str],
field_pattern: re.Pattern[str] | None,
width: int,
min_visible: int = 0,
only_if_overflow: bool = True,
) -> tuple[int, int]:
"""Wrap matching lines in one ``kind: db`` document. Returns (wrapped, skipped)."""
if doc.get("kind") != "db" or not group_keys:
return 0, 0
jf = json_file_from_doc(doc)
wrapped = skipped = 0
for group in doc.get("groups") or []:
type_name = str(group.get("typeName") or "")
if group_key(jf, type_name) not in group_keys:
continue
for line in group.get("lines") or []:
field_name = str(line.get("fieldName") or "")
if field_pattern is not None and not field_pattern.search(field_name):
skipped += 1
continue
text = line.get("text")
if not isinstance(text, str) or not text.strip():
skipped += 1
continue
if only_if_overflow and not line_needs_wrap(
text, width, min_visible=min_visible
):
skipped += 1
continue
new_text = wrap_line_text(text, width)
if new_text != text:
line["text"] = new_text
wrapped += 1
else:
skipped += 1
return wrapped, skipped
def wrap_db_translated_dir(
translated_dir: str | Path,
*,
group_keys: frozenset[str],
field_presets: frozenset[str],
width: int,
min_visible: int = 0,
only_if_overflow: bool = True,
) -> WrapResult:
"""Apply selective wrap to all ``*.project.json`` under ``translated/``."""
base = Path(translated_dir)
result = WrapResult()
if not base.is_dir() or not group_keys:
return result
field_pattern = combine_field_patterns(field_presets)
if field_pattern is None:
return result
for path in sorted(base.glob("*.project.json")):
try:
doc = json.loads(path.read_text(encoding="utf-8-sig"))
except Exception:
continue
if doc.get("kind") != "db":
continue
w, s = wrap_db_document(
doc,
group_keys=group_keys,
field_pattern=field_pattern,
width=width,
min_visible=min_visible,
only_if_overflow=only_if_overflow,
)
if w:
path.write_text(
json.dumps(doc, ensure_ascii=False, indent=4) + "\n",
encoding="utf-8",
)
result.files_touched += 1
result.lines_wrapped += w
result.touched_json.append(path.name)
result.lines_skipped += s
return result
def collect_field_names(translated_dir: str | Path) -> list[str]:
"""Sorted unique ``fieldName`` values from staged/translated DB JSON."""
base = Path(translated_dir)
names: set[str] = set()
if not base.is_dir():
return []
for path in base.glob("*.project.json"):
try:
doc = json.loads(path.read_text(encoding="utf-8-sig"))
except Exception:
continue
if doc.get("kind") != "db":
continue
for group in doc.get("groups") or []:
for line in group.get("lines") or []:
fn = str(line.get("fieldName") or "").strip()
if fn:
names.add(fn)
return sorted(names)

View file

@ -0,0 +1,583 @@
"""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``).
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Sequence
import util.dazedwrap as dazedwrap
from util.wolfdawn.db_classify import group_key, json_file_from_doc
from util.wolfdawn.selective_wrap import line_needs_wrap, wrap_line_text
WRAP_PROFILE_NAME = "wrap_profile.json"
DEFAULT_WRAP_WIDTH = 36
_APOSTROPHE_NORMALIZE = str.maketrans(
{
"\u2019": "'",
"\u2018": "'",
"\u02bc": "'",
"`": "'",
}
)
@dataclass
class WrapHit:
"""One searchable line in translated JSON."""
json_file: str
kind: str
sheet_name: str
row: int | None
field_name: str
text: str
max_line_len: int
scene_index: int | None = None
line_index: int | None = None
map_file: str | None = None
@property
def hit_id(self) -> dict[str, Any]:
"""Stable locator stored in the UI."""
loc: dict[str, Any] = {
"json_file": self.json_file,
"kind": self.kind,
"sheet_name": self.sheet_name,
"field_name": self.field_name,
}
if self.row is not None:
loc["row"] = self.row
if self.kind == "names" and self.row is not None:
loc["name_index"] = self.row
if self.scene_index is not None:
loc["scene_index"] = self.scene_index
if self.line_index is not None:
loc["line_index"] = self.line_index
return loc
def summary(self, width: int = 0) -> str:
overflow = f" longest line: {self.max_line_len} chars"
if width > 0 and self.max_line_len > width:
overflow += " (overflow)"
preview = self.text.replace("\n", " ")[:90]
if len(self.text) > 90:
preview += ""
if self.kind == "db":
loc = f"row {self.row} · {self.field_name}"
elif self.kind == "names":
loc = f"entry #{self.row} · {self.field_name[:60]}"
elif self.kind == "gamedat":
loc = self.field_name or f"line {self.line_index}"
else:
loc = self.field_name or self.map_file or self.json_file
return (
f"Sheet: {self.sheet_name} · {self.json_file} · {loc}\n"
f" {preview}{overflow}"
)
@dataclass
class SheetOverflowSummary:
json_file: str
sheet_name: str
line_count: int
overflow_count: int
tier: str = ""
@property
def key(self) -> str:
return group_key(self.json_file, self.sheet_name)
def wrap_profile_path(work_dir: str | Path) -> Path:
return Path(work_dir) / WRAP_PROFILE_NAME
def load_wrap_profile(work_dir: str | Path) -> dict[str, Any]:
path = wrap_profile_path(work_dir)
if not path.is_file():
return {"default_width": DEFAULT_WRAP_WIDTH, "sheets": {}}
try:
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
return {"default_width": DEFAULT_WRAP_WIDTH, "sheets": {}}
data.setdefault("default_width", DEFAULT_WRAP_WIDTH)
data.setdefault("sheets", {})
return data
except Exception:
return {"default_width": DEFAULT_WRAP_WIDTH, "sheets": {}}
def save_wrap_profile(work_dir: str | Path, profile: dict[str, Any]) -> None:
path = wrap_profile_path(work_dir)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(profile, ensure_ascii=False, indent=4) + "\n", encoding="utf-8")
def get_sheet_width(
profile: dict[str, Any],
sheet_name: str,
*,
default: int | None = None,
) -> int:
sheets = profile.get("sheets") or {}
entry = sheets.get(sheet_name)
if isinstance(entry, dict) and entry.get("width"):
try:
return int(entry["width"])
except (TypeError, ValueError):
pass
if default is not None:
return default
try:
return int(profile.get("default_width") or DEFAULT_WRAP_WIDTH)
except (TypeError, ValueError):
return DEFAULT_WRAP_WIDTH
def set_sheet_width(
work_dir: str | Path,
sheet_name: str,
width: int,
*,
json_file: str,
) -> None:
profile = load_wrap_profile(work_dir)
sheets = profile.setdefault("sheets", {})
sheets[sheet_name] = {"width": int(width), "json_file": json_file}
profile["default_width"] = profile.get("default_width", DEFAULT_WRAP_WIDTH)
save_wrap_profile(work_dir, profile)
def _load_json(path: Path) -> dict[str, Any] | None:
try:
return json.loads(path.read_text(encoding="utf-8-sig"))
except Exception:
return None
def _normalize_query(query: str) -> str:
return " ".join(query.lower().translate(_APOSTROPHE_NORMALIZE).split())
def _normalize_match_text(text: str) -> str:
return text.lower().translate(_APOSTROPHE_NORMALIZE)
def _text_matches(text: str, query_norm: str) -> bool:
if not query_norm or not isinstance(text, str):
return False
hay = _normalize_match_text(text)
if query_norm in hay:
return True
# In-game UI often hides line breaks; allow matching across newlines.
collapsed = " ".join(hay.split())
return query_norm in collapsed
def _iter_search_dirs(
translated_dir: Path,
files_dir: Path | None,
extra_dirs: Sequence[str | Path] | None = None,
) -> list[Path]:
dirs: list[Path] = []
for candidate in (translated_dir, files_dir, *(extra_dirs or ())):
if candidate is None:
continue
p = Path(candidate)
if p.is_dir() and p not in dirs:
dirs.append(p)
return dirs
def search_translated_text(
query: str,
translated_dir: str | Path,
*,
files_dir: str | Path | None = None,
extra_dirs: Sequence[str | Path] | None = None,
limit: int = 50,
) -> list[WrapHit]:
"""Find lines containing *query* in translated, files/, and optional extra dirs."""
if not query.strip():
return []
q = _normalize_query(query)
hits: list[WrapHit] = []
tdir = Path(translated_dir)
fdir = Path(files_dir) if files_dir else tdir.parent / "files"
seen: set[tuple] = set()
for base in _iter_search_dirs(tdir, fdir, extra_dirs):
for path in sorted(base.glob("*.json")):
doc = _load_json(path)
if not doc:
continue
kind = doc.get("kind")
jf = path.name
if kind == "db":
for group in doc.get("groups") or []:
sheet = str(group.get("typeName") or "")
for line in group.get("lines") or []:
text = line.get("text")
if not _text_matches(text, q):
continue
row = line.get("row")
field = str(line.get("fieldName") or "")
key = (jf, sheet, row, field)
if key in seen:
continue
seen.add(key)
hits.append(
WrapHit(
json_file=jf,
kind="db",
sheet_name=sheet,
row=int(row) if row is not None else None,
field_name=field,
text=str(text),
max_line_len=dazedwrap.max_line_visible_length(str(text)),
)
)
elif kind == "names":
for idx, entry in enumerate(doc.get("names") or []):
if not isinstance(entry, dict):
continue
text = entry.get("text")
if not _text_matches(text, q):
continue
note = str(entry.get("note") or "names.json")
source = str(entry.get("source") or "")[:80]
key = (jf, "names", idx)
if key in seen:
continue
seen.add(key)
hits.append(
WrapHit(
json_file=jf,
kind="names",
sheet_name=note,
row=idx,
field_name=source or f"entry {idx}",
text=str(text),
max_line_len=dazedwrap.max_line_visible_length(str(text)),
)
)
elif kind == "gamedat":
for li, line in enumerate(doc.get("lines") or []):
if not isinstance(line, dict):
continue
text = line.get("text")
if not _text_matches(text, q):
continue
key = (jf, "gamedat", li)
if key in seen:
continue
seen.add(key)
field = str(line.get("key") or line.get("fieldName") or f"line {li}")
hits.append(
WrapHit(
json_file=jf,
kind="gamedat",
sheet_name="Game.dat",
row=li,
field_name=field,
text=str(text),
max_line_len=dazedwrap.max_line_visible_length(str(text)),
line_index=li,
)
)
elif kind in ("map", "common"):
label = doc.get("file") or jf
sheet = "CommonEvent" if kind == "common" else str(label)
for si, scene in enumerate(doc.get("scenes") or []):
for li, line in enumerate(scene.get("lines") or []):
text = line.get("text")
if not _text_matches(text, q):
continue
key = (jf, si, li)
if key in seen:
continue
seen.add(key)
speaker = str(line.get("speaker") or "")
hits.append(
WrapHit(
json_file=jf,
kind=kind,
sheet_name=sheet,
row=scene.get("event"),
field_name=speaker or f"scene {si} line {li}",
text=str(text),
max_line_len=dazedwrap.max_line_visible_length(str(text)),
scene_index=si,
line_index=li,
map_file=str(label) if kind == "map" else None,
)
)
if len(hits) >= limit:
return hits
return hits
def wrap_preview_info(text: str, width: int) -> dict[str, Any]:
"""Return wrapped text and metrics for the Step 7 live preview."""
if not isinstance(text, str) or not text.strip() or width <= 0:
return {
"wrapped": text if isinstance(text, str) else "",
"needs_wrap": False,
"longest": 0,
"input_line_count": 0,
"output_line_count": 0,
"line_stats": [],
}
wrapped = wrap_line_text(text, width)
norm_in = text.replace("\r\n", "\n").replace("\r", "\n")
norm_out = wrapped.replace("\r\n", "\n").replace("\r", "\n")
in_lines = norm_in.split("\n") if norm_in else [""]
out_lines = norm_out.split("\n") if norm_out else [""]
line_stats: list[dict[str, Any]] = []
for i, line in enumerate(in_lines):
vis = dazedwrap.max_line_visible_length(line)
line_stats.append(
{
"line": i + 1,
"visible": vis,
"overflow": max(0, vis - width),
"needs_wrap": vis > width,
}
)
longest = max((s["visible"] for s in line_stats), default=0)
needs_wrap = wrapped != text or any(s["needs_wrap"] for s in line_stats)
return {
"wrapped": wrapped,
"needs_wrap": needs_wrap,
"longest": longest,
"input_line_count": len(in_lines),
"output_line_count": len(out_lines),
"line_stats": line_stats,
}
def format_wrap_preview(text: str, width: int) -> str:
"""Multiline preview with per-line visible character counts."""
info = wrap_preview_info(text, width)
if not info["wrapped"]:
return ""
lines = info["wrapped"].replace("\r\n", "\n").replace("\r", "\n").split("\n")
parts: list[str] = []
for i, line in enumerate(lines):
vis = dazedwrap.max_line_visible_length(line)
marker = "" if vis > width else " "
parts.append(f"{marker} {i + 1:2d} ({vis:2d}) {line}")
return "\n".join(parts)
def wrap_preview_summary(text: str, width: int) -> str:
"""One-line status for the preview header."""
info = wrap_preview_info(text, width)
if not isinstance(text, str) or not text.strip():
return ""
longest = int(info["longest"])
if not info["needs_wrap"]:
return f"Fits at width {width} (longest line {longest} visible chars)."
return (
f"Will wrap to {info['output_line_count']} line(s) at width {width} "
f"(longest input line {longest} visible chars)."
)
def split_line_at_visible_width(line: str, width: int) -> tuple[int, int]:
"""Return ``(fit_end, line_len)`` char indices in *line* for UI highlighting."""
line_len = len(line)
if not line or width <= 0:
return (line_len, line_len)
fit_end = dazedwrap.visible_word_wrap_end_index(line, width)
return (min(fit_end, line_len), line_len)
def locate_line(doc: dict[str, Any], hit_id: dict[str, Any]) -> dict[str, Any] | None:
"""Return the live line dict for *hit_id* inside *doc*."""
kind = hit_id.get("kind")
if kind == "db":
sheet = hit_id.get("sheet_name")
row = hit_id.get("row")
field = hit_id.get("field_name")
for group in doc.get("groups") or []:
if str(group.get("typeName") or "") != sheet:
continue
for line in group.get("lines") or []:
if line.get("row") == row and str(line.get("fieldName") or "") == field:
return line
return None
if kind in ("map", "common"):
si = hit_id.get("scene_index")
li = hit_id.get("line_index")
scenes = doc.get("scenes") or []
if si is None or li is None or si >= len(scenes):
return None
lines = scenes[si].get("lines") or []
if li >= len(lines):
return None
return lines[li]
if kind == "names":
idx = hit_id.get("name_index", hit_id.get("row"))
names = doc.get("names") or []
if idx is None or not isinstance(idx, int) or idx < 0 or idx >= len(names):
return None
entry = names[idx]
return entry if isinstance(entry, dict) else None
if kind == "gamedat":
li = hit_id.get("line_index", hit_id.get("row"))
lines = doc.get("lines") or []
if li is None or not isinstance(li, int) or li < 0 or li >= len(lines):
return None
line = lines[li]
return line if isinstance(line, dict) else None
return None
def load_hit_from_id(
translated_dir: str | Path,
hit_id: dict[str, Any],
*,
files_dir: str | Path | None = None,
extra_dirs: Sequence[str | Path] | None = None,
) -> tuple[Path | None, dict[str, Any] | None, dict[str, Any] | None]:
"""Load JSON path, document, and line dict for *hit_id*."""
jf = hit_id.get("json_file")
if not jf:
return None, None, None
tdir = Path(translated_dir)
fdir = Path(files_dir) if files_dir else None
for base in _iter_search_dirs(tdir, fdir, extra_dirs):
path = base / str(jf)
if not path.is_file():
continue
doc = _load_json(path)
if not doc:
continue
line = locate_line(doc, hit_id)
if line is not None:
return path, doc, line
return None, None, None
def save_document(path: Path, doc: dict[str, Any]) -> None:
path.write_text(
json.dumps(doc, ensure_ascii=False, indent=4) + "\n",
encoding="utf-8",
)
def wrap_line_in_doc(line: dict[str, Any], width: int) -> bool:
"""Wrap one line's ``text``; return True if changed."""
text = line.get("text")
if not isinstance(text, str) or not text.strip() or width <= 0:
return False
new_text = wrap_line_text(text, width)
if new_text == text:
return False
line["text"] = new_text
return True
def wrap_hit_in_file(
path: Path,
doc: dict[str, Any],
hit_id: dict[str, Any],
width: int,
) -> bool:
line = locate_line(doc, hit_id)
if line is None:
return False
if not wrap_line_in_doc(line, width):
return False
save_document(path, doc)
return True
def wrap_overflow_in_sheet(
path: Path,
doc: dict[str, Any],
sheet_name: str,
width: int,
) -> int:
"""Wrap every overflowing line in one DB sheet; return count changed."""
if doc.get("kind") != "db":
return 0
changed = 0
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
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 sheet_overflow_summaries(
translated_dir: str | Path,
width: int,
*,
files_dir: str | Path | None = None,
) -> list[SheetOverflowSummary]:
"""Per DB sheet overflow counts at *width*."""
from util.wolfdawn.db_classify import analyze_content_distribution, classify_db_document
base = Path(translated_dir)
if not base.is_dir():
base = Path(files_dir) if files_dir else base
dist = analyze_content_distribution(base)
summaries: list[SheetOverflowSummary] = []
for path in sorted(base.glob("*.project.json")):
doc = _load_json(path)
if not doc or doc.get("kind") != "db":
continue
jf = path.name
for group in classify_db_document(doc, json_file=jf):
overflow = 0
for g in doc.get("groups") or []:
if str(g.get("typeName") or "") != group.type_name:
continue
for line in g.get("lines") or []:
text = line.get("text")
if isinstance(text, str) and line_needs_wrap(text, width):
overflow += 1
summaries.append(
SheetOverflowSummary(
json_file=jf,
sheet_name=group.type_name,
line_count=group.line_count,
overflow_count=overflow,
tier=group.tier,
)
)
if summaries:
return summaries
return [
SheetOverflowSummary(
json_file=g.json_file,
sheet_name=g.type_name,
line_count=g.line_count,
overflow_count=0,
tier=g.tier,
)
for g in dist.groups
]