fix wrap tab
This commit is contained in:
parent
429ad43918
commit
42cda41458
7 changed files with 1813 additions and 56 deletions
|
|
@ -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 3–4 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
127
tests/test_wolf_selective_wrap.py
Normal file
127
tests/test_wolf_selective_wrap.py
Normal 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()
|
||||
218
tests/test_wolf_wrap_search.py
Normal file
218
tests/test_wolf_wrap_search.py
Normal 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()
|
||||
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
218
util/wolfdawn/selective_wrap.py
Normal file
218
util/wolfdawn/selective_wrap.py
Normal 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)
|
||||
583
util/wolfdawn/wrap_search.py
Normal file
583
util/wolfdawn/wrap_search.py
Normal 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
|
||||
]
|
||||
Loading…
Reference in a new issue