Working Wrap

This commit is contained in:
DazedAnon 2026-07-09 07:09:23 -05:00
parent dd048f48f1
commit a2411b3e31
12 changed files with 1291 additions and 199 deletions

View file

@ -117,3 +117,5 @@ def cmd_usage(client, bid, model):
- **Retranslate workflow:** when the user re-imports fresh JSON and runs Translate again, cache hits should fill `translated/` automatically for unchanged source lines; only new or edited source text should incur API cost.
- **UI (later):** cache stats (hits / misses / estimated savings), export/import, clear cache for one game, optional “prefer cache” toggle.
- **Invalidation:** optional metadata (model id, prompt version) in the key or entry if we need to avoid reusing stale translations after prompt or model changes.
## According to someone from f95, the patcher breaks if a folder's name has an apostrophe

View file

@ -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, QTextCharFormat, QTextCursor
from PyQt5.QtGui import QColor, QFont
from PyQt5.QtWidgets import (
QApplication,
QAbstractItemView,
@ -2392,7 +2392,9 @@ class WolfWorkflowTab(QWidget):
)
layout.addWidget(self.en_punct_cb)
self.drift_cb = QCheckBox("Allow inline-code drift (--allow-code-drift, riskier)")
self.drift_cb = QCheckBox(
"Allow inline-code drift (--allow-code-drift; auto for names-wrap \\f shrink)"
)
self.drift_cb.setChecked(self._setting("allow_code_drift", "false") == "true")
self.drift_cb.stateChanged.connect(
lambda: self._save_setting(
@ -2452,9 +2454,11 @@ class WolfWorkflowTab(QWidget):
"""Search-first fix wrapping: find sheet by in-game text, wrap, re-inject."""
layout.addWidget(_make_section_label("Step 7 · Fix wrapping"))
layout.addWidget(self._desc(
"Paste overflowing in-game text to find it in translated JSON. Adjust width "
"for one line or wrap every overflowing line in the same group (database "
"sheet, names category, map, CommonEvent, or Game.dat), then inject."
"Paste overflowing in-game text to find it in translated JSON. Choose "
"Relayout (cell width + max lines) or Manual (wrap width + body font), "
"then wrap one line or the whole group and inject. Manual mode scales "
"emphasis ``\\f[N]`` with the body font (e.g. ``\\f[20]``/``\\f[18]`` → "
"``\\f[16]``/``\\f[14]``). names.json Relayout uses WolfDawn ``names-wrap``."
))
search_row = QHBoxLayout()
@ -2487,50 +2491,111 @@ class WolfWorkflowTab(QWidget):
)
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;}"
mode_row = QHBoxLayout()
mode_lbl = QLabel("Mode:")
mode_lbl.setStyleSheet("color:#cccccc;font-size:12px;background:transparent;")
self._wrap_mode_combo = QComboBox()
self._wrap_mode_combo.addItem("Relayout (cell width + max lines)", "relayout")
self._wrap_mode_combo.addItem("Manual (wrap width + font)", "manual")
saved_mode = str(self._setting("wrap_fix_mode", "relayout") or "relayout")
idx = self._wrap_mode_combo.findData(saved_mode)
self._wrap_mode_combo.setCurrentIndex(idx if idx >= 0 else 0)
self._wrap_mode_combo.setToolTip(
"Relayout: wolf names-wrap / JP slot geometry (cell width + max lines).\n"
"Manual: force wrap width and body font; emphasis \\f[N] scales with the body."
)
self._wrap_text_editor.setEnabled(False)
self._wrap_text_editor.textChanged.connect(self._update_wrap_preview)
layout.addWidget(self._wrap_text_editor)
self._wrap_mode_combo.currentIndexChanged.connect(self._on_wrap_mode_changed)
mode_row.addWidget(mode_lbl)
mode_row.addWidget(self._wrap_mode_combo)
mode_row.addStretch()
layout.addLayout(mode_row)
width_row = QHBoxLayout()
w_lbl = QLabel("Wrap width (characters):")
# Relayout controls: cell width + max lines
self._wrap_relayout_row = QWidget()
relayout_row = QHBoxLayout(self._wrap_relayout_row)
relayout_row.setContentsMargins(0, 0, 0, 0)
w_lbl = QLabel("Cell width (0=auto):")
w_lbl.setStyleSheet("color:#cccccc;font-size:12px;background:transparent;")
self._wrap_width_spin = QSpinBox()
self._wrap_width_spin.setRange(10, 120)
self._wrap_width_spin.setRange(0, 200)
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.setToolTip(
"Halfwidth cells (ASCII=1, fullwidth=2). For names.json Relayout: "
"0 = measure from JP; N > 0 forces wolf names-wrap --width."
)
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)
lines_lbl = QLabel("Max lines (0=auto):")
lines_lbl.setStyleSheet("color:#cccccc;font-size:12px;background:transparent;")
self._wrap_max_lines_spin = QSpinBox()
self._wrap_max_lines_spin.setRange(0, 20)
try:
self._wrap_max_lines_spin.setValue(
int(self._setting("wrap_fix_max_lines", 0) or 0)
)
except (TypeError, ValueError):
self._wrap_max_lines_spin.setValue(0)
self._wrap_max_lines_spin.setFixedWidth(70)
self._wrap_max_lines_spin.setToolTip(
"wolf names-wrap --lines (0 = each entry's JP line count). "
"Saved as maxLines in wolfdawn-roles.json."
)
self._wrap_max_lines_spin.valueChanged.connect(self._on_wrap_max_lines_changed)
relayout_row.addWidget(w_lbl)
relayout_row.addWidget(self._wrap_width_spin)
relayout_row.addWidget(lines_lbl)
relayout_row.addWidget(self._wrap_max_lines_spin)
relayout_row.addStretch()
layout.addWidget(self._wrap_relayout_row)
font_row = QHBoxLayout()
font_lbl = QLabel("Font size \\f[N]:")
# Manual controls: wrap width + font size
self._wrap_manual_row = QWidget()
manual_row = QHBoxLayout(self._wrap_manual_row)
manual_row.setContentsMargins(0, 0, 0, 0)
mw_lbl = QLabel("Wrap width:")
mw_lbl.setStyleSheet("color:#cccccc;font-size:12px;background:transparent;")
self._wrap_manual_width_spin = QSpinBox()
self._wrap_manual_width_spin.setRange(10, 200)
try:
self._wrap_manual_width_spin.setValue(
int(self._setting("wrap_fix_manual_width", 36) or 36)
)
except (TypeError, ValueError):
self._wrap_manual_width_spin.setValue(36)
self._wrap_manual_width_spin.setFixedWidth(70)
self._wrap_manual_width_spin.setToolTip(
"Hard wrap width in halfwidth cells for Manual mode (dazedwrap)."
)
self._wrap_manual_width_spin.valueChanged.connect(self._on_wrap_manual_width_changed)
font_lbl = QLabel("Body font \\f[N]:")
font_lbl.setStyleSheet("color:#cccccc;font-size:12px;background:transparent;")
self._wrap_font_spin = QSpinBox()
self._wrap_font_spin.setRange(8, 32)
self._wrap_font_spin.setValue(18)
try:
self._wrap_font_spin.setValue(int(self._setting("wrap_fix_font", 18) or 18))
except (TypeError, ValueError):
self._wrap_font_spin.setValue(18)
self._wrap_font_spin.setFixedWidth(70)
self._wrap_font_spin.setToolTip(
"Target body font applied on Wrap. Emphasis overrides (e.g. \\f[20]…\\f[18]) "
"scale proportionally; a leading \\f[body] is always set."
)
self._wrap_font_spin.valueChanged.connect(self._on_wrap_font_changed)
self._wrap_font_detect_label = QLabel("")
self._wrap_font_detect_label.setStyleSheet(
"color:#858585;font-size:11px;background:transparent;"
)
apply_font_btn = _make_btn("Apply \\f to text", "#3a3a3a")
apply_font_btn.clicked.connect(self._apply_wrap_font_size)
font_row.addWidget(font_lbl)
font_row.addWidget(self._wrap_font_spin)
font_row.addWidget(apply_font_btn)
font_row.addWidget(self._wrap_font_detect_label, 1)
layout.addLayout(font_row)
manual_row.addWidget(mw_lbl)
manual_row.addWidget(self._wrap_manual_width_spin)
manual_row.addWidget(font_lbl)
manual_row.addWidget(self._wrap_font_spin)
manual_row.addWidget(self._wrap_font_detect_label, 1)
layout.addWidget(self._wrap_manual_row)
self._sync_wrap_mode_controls()
self._wrap_preview_label = QLabel("Wrap preview")
self._wrap_preview_label.setStyleSheet(
@ -2540,9 +2605,9 @@ class WolfWorkflowTab(QWidget):
self._wrap_preview = QTextEdit()
self._wrap_preview.setReadOnly(True)
self._wrap_preview.setMaximumHeight(110)
self._wrap_preview.setMaximumHeight(160)
self._wrap_preview.setPlaceholderText(
"Wrapped lines appear here when you select a result and adjust width."
"Select a search result to preview wrap at the current width."
)
self._wrap_preview.setStyleSheet(
"QTextEdit{background-color:#1e1e1e;color:#b5cea8;border:1px solid #3c3c3c;"
@ -2555,36 +2620,14 @@ class WolfWorkflowTab(QWidget):
wrap_row_btn.clicked.connect(self._wrap_current_row)
wrap_group_btn = _make_btn("Wrap all in group", "#00a86b")
wrap_group_btn.clicked.connect(self._wrap_current_group)
save_row_btn = _make_btn("Save text", "#3a3a3a")
save_row_btn.clicked.connect(self._save_wrap_row_text)
inject_btn = _make_btn("Inject this file", "#007acc")
inject_btn.clicked.connect(self._inject_current_wrap_file)
btn_row.addWidget(wrap_row_btn)
btn_row.addWidget(wrap_group_btn)
btn_row.addWidget(save_row_btn)
btn_row.addWidget(inject_btn)
btn_row.addStretch()
layout.addLayout(btn_row)
context_row = QHBoxLayout()
self._wrap_relayout_btn = _make_btn("Relayout messages on Data/ (wolf)", "#3a3a3a")
self._wrap_relayout_btn.clicked.connect(lambda: self._run_relayout(manual=True))
self._wrap_relayout_btn.setVisible(False)
self._wrap_relayout_btn.setToolTip(
"Reflow message boxes in live Data/ using JP-measured geometry. "
"Run after inject for map and CommonEvent dialogue."
)
self._wrap_names_smart_btn = _make_btn("Smart wrap names.json (wolf)", "#3a3a3a")
self._wrap_names_smart_btn.clicked.connect(lambda: self._run_names_wrap(manual=True))
self._wrap_names_smart_btn.setVisible(False)
self._wrap_names_smart_btn.setToolTip(
"Re-wrap multi-line name fields to each category's JP-measured width."
)
context_row.addWidget(self._wrap_relayout_btn)
context_row.addWidget(self._wrap_names_smart_btn)
context_row.addStretch()
layout.addLayout(context_row)
self._wrap_status_label = QLabel("")
self._wrap_status_label.setWordWrap(True)
self._wrap_status_label.setStyleSheet(
@ -2596,6 +2639,7 @@ class WolfWorkflowTab(QWidget):
self._current_wrap_hit_id: dict | None = None
self._current_wrap_path: Path | None = None
self._current_wrap_doc: dict | None = None
self._current_wrap_text: str = ""
def _translated_and_files_dirs(self) -> tuple[Path, Path]:
root = self._tool_root()
@ -2619,10 +2663,102 @@ class WolfWorkflowTab(QWidget):
except (TypeError, ValueError):
return 36
def _remember_sheet_width(self, sheet_name: str, width: int, json_file: str):
def _remember_sheet_width(
self,
sheet_name: str,
width: int,
json_file: str,
*,
max_lines: int | None = None,
):
work = self._wolf_json_work_dir()
if work is not None:
wolf_ws.set_sheet_width(work, sheet_name, width, json_file=json_file)
wolf_ws.set_sheet_width(
work,
sheet_name,
width,
json_file=json_file,
max_lines=max_lines,
)
def _wrap_mode(self) -> str:
combo = getattr(self, "_wrap_mode_combo", None)
if combo is not None:
data = combo.currentData()
if data:
return str(data)
return str(self._setting("wrap_fix_mode", "relayout") or "relayout")
def _current_wrap_line_text(self) -> str:
"""Return the selected line's text from the loaded document (or cache)."""
if self._current_wrap_doc is not None and self._current_wrap_hit_id is not None:
line = wolf_ws.locate_line(self._current_wrap_doc, self._current_wrap_hit_id)
if line is not None:
return str(line.get("text") or "")
return getattr(self, "_current_wrap_text", "") or ""
def _on_wrap_mode_changed(self, _index: int = 0):
mode = self._wrap_mode()
self._save_setting("wrap_fix_mode", mode)
self._sync_wrap_mode_controls()
self._update_wrap_preview()
hit = self._current_wrap_hit
doc = self._current_wrap_doc
if hit is not None and doc is not None and hasattr(self, "_wrap_detail_label"):
text = self._current_wrap_line_text() or hit.text
self._wrap_detail_label.setText(
self._format_wrap_detail(hit, doc, text, self._active_wrap_width())
)
def _sync_wrap_mode_controls(self):
mode = self._wrap_mode()
is_manual = mode == "manual"
if hasattr(self, "_wrap_relayout_row"):
self._wrap_relayout_row.setVisible(not is_manual)
if hasattr(self, "_wrap_manual_row"):
self._wrap_manual_row.setVisible(is_manual)
def _active_wrap_width(self) -> int:
if self._wrap_mode() == "manual" and hasattr(self, "_wrap_manual_width_spin"):
return int(self._wrap_manual_width_spin.value())
if hasattr(self, "_wrap_width_spin"):
return int(self._wrap_width_spin.value())
return 36
def _wrap_font_value(self) -> int:
spin = getattr(self, "_wrap_font_spin", None)
if spin is not None:
return int(spin.value())
try:
return int(self._setting("wrap_fix_font", 18) or 18)
except (TypeError, ValueError):
return 18
def _on_wrap_manual_width_changed(self, value: int):
self._save_setting("wrap_fix_manual_width", value)
self._update_wrap_preview()
hit = self._current_wrap_hit
doc = self._current_wrap_doc
if hit is not None and doc is not None and hasattr(self, "_wrap_detail_label"):
text = self._current_wrap_line_text() or hit.text
self._wrap_detail_label.setText(
self._format_wrap_detail(hit, doc, text, value)
)
def _on_wrap_font_changed(self, value: int):
self._save_setting("wrap_fix_font", value)
def _wrap_max_lines_value(self) -> int:
spin = getattr(self, "_wrap_max_lines_spin", None)
if spin is not None:
return int(spin.value())
try:
return int(self._setting("wrap_fix_max_lines", 0) or 0)
except (TypeError, ValueError):
return 0
def _on_wrap_max_lines_changed(self, value: int):
self._save_setting("wrap_fix_max_lines", value)
def _on_wrap_width_changed(self, value: int):
self._save_setting("wrap_fix_width", value)
@ -2630,9 +2766,63 @@ class WolfWorkflowTab(QWidget):
hit = self._current_wrap_hit
doc = self._current_wrap_doc
if hit is not None and doc is not None and hasattr(self, "_wrap_detail_label"):
text = self._wrap_text_editor.toPlainText() if self._wrap_text_editor.isEnabled() else hit.text
text = self._current_wrap_line_text() or hit.text
self._wrap_detail_label.setText(self._format_wrap_detail(hit, doc, text, value))
def _load_names_geometry_into_spins(self, hit: wolf_ws.WrapHit):
"""Prefer wolfdawn-roles.json, then wrap_profile, for names category geometry."""
width = None
max_lines = None
font = None
if self._current_wrap_path is not None:
roles = wolf_names.load_name_wrap_roles(
wolf_names.roles_json_path_for_names(self._current_wrap_path)
)
role = wolf_names.get_name_wrap_role(roles, hit.sheet_name)
if role:
if role.get("width"):
try:
width = int(role["width"])
except (TypeError, ValueError):
pass
if role.get("maxLines") is not None:
try:
max_lines = int(role["maxLines"])
except (TypeError, ValueError):
pass
if role.get("font"):
try:
font = int(role["font"])
except (TypeError, ValueError):
pass
work = self._wolf_json_work_dir()
if work is not None:
profile = wolf_ws.load_wrap_profile(work)
if width is None:
# 0 = JP auto for names-wrap; do not fall back to DB default 36.
width = wolf_ws.get_sheet_width(profile, hit.sheet_name, default=0)
if max_lines is None:
max_lines = wolf_ws.get_sheet_max_lines(profile, hit.sheet_name, default=0)
if width is None:
width = 0
if max_lines is None:
max_lines = 0
self._wrap_width_spin.blockSignals(True)
self._wrap_width_spin.setValue(int(width))
self._wrap_width_spin.blockSignals(False)
if hasattr(self, "_wrap_manual_width_spin") and width > 0:
self._wrap_manual_width_spin.blockSignals(True)
self._wrap_manual_width_spin.setValue(int(width))
self._wrap_manual_width_spin.blockSignals(False)
if hasattr(self, "_wrap_max_lines_spin"):
self._wrap_max_lines_spin.blockSignals(True)
self._wrap_max_lines_spin.setValue(int(max_lines))
self._wrap_max_lines_spin.blockSignals(False)
if font is not None and hasattr(self, "_wrap_font_spin"):
self._wrap_font_spin.blockSignals(True)
self._wrap_font_spin.setValue(int(font))
self._wrap_font_spin.blockSignals(False)
def _format_wrap_detail(
self,
hit: wolf_ws.WrapHit,
@ -2659,9 +2849,23 @@ class WolfWorkflowTab(QWidget):
else ""
)
if hit.kind == "names":
mode = self._wrap_mode()
if mode == "manual":
font = self._wrap_font_value()
return (
f"{kind_lbl}: {hit.sheet_name} · {hit.json_file} · "
f"entry #{hit.row} · Manual wrap={width}, body \\f[{font}] · "
"emphasis \\f scales with body"
)
lines = self._wrap_max_lines_value()
geom = (
f"width={'auto' if width <= 0 else width}"
+ (f", max lines={lines}" if lines > 0 else ", max lines=auto")
)
return (
f"{kind_lbl}: {hit.sheet_name} · {hit.json_file} · "
f"entry #{hit.row}{overflow_note}"
f"entry #{hit.row} · Relayout {geom} · "
"Wrap all in group → wolf names-wrap (JP slots + \\f shrink)"
)
if hit.kind == "db":
return (
@ -2676,16 +2880,18 @@ class WolfWorkflowTab(QWidget):
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():
text = self._current_wrap_line_text()
if not text.strip() and self._current_wrap_hit is None:
self._wrap_preview.clear()
if hasattr(self, "_wrap_preview_label"):
self._wrap_preview_label.setText("Wrap preview")
if editor is not None:
editor.setExtraSelections([])
self._wrap_preview_label.setStyleSheet(
"color:#858585;font-size:11px;background:transparent;"
)
return
text = editor.toPlainText()
width = self._wrap_width_spin.value()
if self._current_wrap_hit is not None and not text.strip():
text = self._current_wrap_hit.text
width = self._active_wrap_width()
summary = wolf_ws.wrap_preview_summary(text, width)
preview = wolf_ws.format_wrap_preview(text, width)
info = wolf_ws.wrap_preview_info(text, width)
@ -2707,47 +2913,6 @@ class WolfWorkflowTab(QWidget):
"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()
@ -2760,6 +2925,7 @@ class WolfWorkflowTab(QWidget):
)
self._wrap_results_list.clear()
self._current_wrap_hit = None
self._current_wrap_text = ""
if not hits:
where = "translated/, files/"
if extra:
@ -2768,11 +2934,9 @@ class WolfWorkflowTab(QWidget):
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()
width = self._active_wrap_width()
for hit in hits:
item = QListWidgetItem(hit.summary(width))
item.setData(Qt.UserRole, hit.hit_id)
@ -2796,24 +2960,31 @@ class WolfWorkflowTab(QWidget):
)
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 hit.kind == "names":
self._load_names_geometry_into_spins(hit)
else:
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 hasattr(self, "_wrap_max_lines_spin"):
work = self._wolf_json_work_dir()
max_lines = 0
if work is not None:
max_lines = wolf_ws.get_sheet_max_lines(
wolf_ws.load_wrap_profile(work), hit.sheet_name, default=0
)
self._wrap_max_lines_spin.blockSignals(True)
self._wrap_max_lines_spin.setValue(max_lines)
self._wrap_max_lines_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)
self._current_wrap_text = text
self._wrap_detail_label.setText(
self._format_wrap_detail(hit, doc, text, self._wrap_width_spin.value())
self._format_wrap_detail(hit, doc, text, self._active_wrap_width())
)
if hasattr(self, "_wrap_relayout_btn"):
show_relayout = hit.kind in ("map", "common")
self._wrap_relayout_btn.setVisible(show_relayout)
if hasattr(self, "_wrap_names_smart_btn"):
self._wrap_names_smart_btn.setVisible(hit.kind == "names")
self._sync_wrap_font_controls(text)
self._update_wrap_preview()
@ -2821,61 +2992,78 @@ class WolfWorkflowTab(QWidget):
if not hasattr(self, "_wrap_font_spin"):
return
sizes = wolf_codes.detect_font_sizes(text)
if sizes:
base = wolf_codes.infer_base_font_size(text) if sizes else None
if sizes and base is not None:
self._wrap_font_spin.blockSignals(True)
self._wrap_font_spin.setValue(sizes[-1])
self._wrap_font_spin.setValue(base)
self._wrap_font_spin.blockSignals(False)
shown = ", ".join(f"\\f[{s}]" for s in sizes[:4])
if len(sizes) > 4:
shown += ", …"
self._wrap_font_detect_label.setText(f"In text: {shown}")
self._wrap_font_detect_label.setText(
f"In text: {shown} (body ≈ \\f[{base}])"
)
else:
self._wrap_font_detect_label.setText("No \\f[N] in text — Apply adds \\f at start")
def _apply_wrap_font_size(self):
if not self._wrap_text_editor.isEnabled():
QMessageBox.information(self, "Fix wrapping", "Select a search result first.")
return
text = self._wrap_text_editor.toPlainText()
size = self._wrap_font_spin.value()
new_text, count = wolf_codes.apply_font_size(text, size)
if new_text == text:
self._wrap_status_label.setText("No font size change applied.")
return
self._wrap_text_editor.setPlainText(new_text)
if self._current_wrap_hit_id and self._current_wrap_path and self._current_wrap_doc:
line = wolf_ws.locate_line(self._current_wrap_doc, self._current_wrap_hit_id)
if line is not None:
line["text"] = new_text
wolf_ws.save_document(self._current_wrap_path, self._current_wrap_doc)
self._sync_wrap_font_controls(new_text)
self._update_wrap_preview()
self._wrap_status_label.setText(
f"Set \\f[{size}] on {count} code(s). Save or inject when ready."
)
self._log(f"✅ Applied \\f[{size}] to wrap editor text ({count} change(s)).")
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}")
self._wrap_font_detect_label.setText(
"No \\f[N] yet — Wrap adds leading body \\f"
)
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
if self._wrap_mode() == "manual":
width = self._active_wrap_width()
font = self._wrap_font_value()
changed = wolf_ws.wrap_hit_manual(
self._current_wrap_path,
self._current_wrap_doc,
self._current_wrap_hit_id,
width,
font=font,
)
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(),
)
self._current_wrap_path = path
self._current_wrap_doc = doc
if line is not None:
text = str(line.get("text") or "")
self._current_wrap_text = text
self._sync_wrap_font_controls(text)
msg = (
f"Manual wrap at width {width}, body \\f[{font}]."
if changed
else "Line already fits (manual)."
)
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()
return
# Relayout mode: single-line dazedwrap at cell width (names group uses wolf).
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()
if width <= 0:
QMessageBox.information(
self,
"Fix wrapping",
"Set Cell width to a positive value before wrapping a single line.\n"
"For names Relayout, use Wrap all in group (0 = JP auto).",
)
return
max_lines = self._wrap_max_lines_value()
changed = wolf_ws.wrap_hit_in_file(
self._current_wrap_path,
self._current_wrap_doc,
@ -2887,6 +3075,7 @@ class WolfWorkflowTab(QWidget):
self._current_wrap_hit.sheet_name,
width,
self._current_wrap_hit.json_file,
max_lines=max_lines or None,
)
path, doc, line = wolf_ws.load_hit_from_id(
self._translated_and_files_dirs()[0],
@ -2894,8 +3083,10 @@ class WolfWorkflowTab(QWidget):
files_dir=self._translated_and_files_dirs()[1],
extra_dirs=self._wrap_search_extra_dirs(),
)
self._current_wrap_path = path
self._current_wrap_doc = doc
if line is not None:
self._wrap_text_editor.setPlainText(str(line.get("text") or ""))
self._current_wrap_text = str(line.get("text") or "")
msg = f"Wrapped line at width {width}." if changed else "Line already fits at this width."
self._wrap_status_label.setText(msg + f" Inject {self._current_wrap_path.name} from Step 6.")
self._log(f"{'' if changed else ''} {msg}")
@ -2906,18 +3097,74 @@ class WolfWorkflowTab(QWidget):
if not hit or not self._current_wrap_path or not self._current_wrap_doc:
QMessageBox.information(self, "Fix wrapping", "Select a search result first.")
return
if self._wrap_mode() == "manual":
width = self._active_wrap_width()
font = self._wrap_font_value()
count = wolf_ws.wrap_overflow_manual_in_scope(
self._current_wrap_path,
self._current_wrap_doc,
hit,
width,
font=font,
)
self._remember_sheet_width(hit.sheet_name, width, hit.json_file)
if hit.kind == "names":
roles_path = wolf_names.roles_json_path_for_names(self._current_wrap_path)
wolf_names.upsert_name_wrap_role(
roles_path,
hit.sheet_name,
width=width,
font=font,
)
scope = wolf_ws.scope_label(hit)
msg = (
f"Manual-wrapped {count} line(s) in {scope} "
f"(width {width}, body \\f[{font}])."
)
self._wrap_status_label.setText(msg + f" Inject {self._current_wrap_path.name}.")
self._log(f"{msg}")
self._reload_wrap_hit_editor(hit, width)
return
if hit.kind == "names":
width = self._wrap_width_spin.value()
max_lines = self._wrap_max_lines_value()
self._run_names_wrap_on_path(
self._current_wrap_path,
note=hit.sheet_name,
hit=hit,
width=width if width > 0 else None,
lines=max_lines or None,
quiet=False,
)
return
width = self._wrap_width_spin.value()
if width <= 0:
QMessageBox.information(
self,
"Fix wrapping",
"Set Cell width to a positive value (halfwidth cells) before wrapping.",
)
return
max_lines = self._wrap_max_lines_value()
count = wolf_ws.wrap_overflow_in_scope(
self._current_wrap_path,
self._current_wrap_doc,
hit,
width,
)
self._remember_sheet_width(hit.sheet_name, width, hit.json_file)
self._remember_sheet_width(
hit.sheet_name, width, hit.json_file, max_lines=max_lines or None
)
scope = wolf_ws.scope_label(hit)
msg = f"Wrapped {count} line(s) in {scope} at width {width}."
self._wrap_status_label.setText(msg + f" Inject {self._current_wrap_path.name}.")
self._log(f"{msg}")
self._reload_wrap_hit_editor(hit, width)
def _reload_wrap_hit_editor(self, hit: wolf_ws.WrapHit, width: int):
path, doc, line = wolf_ws.load_hit_from_id(
self._translated_and_files_dirs()[0],
hit.hit_id,
@ -2927,13 +3174,92 @@ class WolfWorkflowTab(QWidget):
self._current_wrap_path = path
self._current_wrap_doc = doc
if line is not None:
self._wrap_text_editor.setPlainText(str(line.get("text") or ""))
text = str(line.get("text") or "")
self._current_wrap_text = text
self._sync_wrap_font_controls(text)
if doc is not None:
self._wrap_detail_label.setText(
self._format_wrap_detail(hit, doc, self._wrap_text_editor.toPlainText(), width)
self._format_wrap_detail(
hit, doc, self._current_wrap_line_text(), width
)
)
self._update_wrap_preview()
def _run_names_wrap_on_path(
self,
names_path: Path,
*,
note: str | None,
hit: wolf_ws.WrapHit | None = None,
width: int | None = None,
lines: int | None = None,
quiet: bool = False,
):
persist_note = note
persist_width = width
persist_lines = lines
def task(log, progress=None):
if persist_note and (
(persist_width is not None and int(persist_width) > 0)
or (persist_lines is not None and int(persist_lines) > 0)
):
roles_path = wolf_names.roles_json_path_for_names(names_path)
wolf_names.upsert_name_wrap_role(
roles_path,
persist_note,
width=persist_width,
max_lines=persist_lines,
)
log(
f"Saved nameWraps rule for {persist_note}{roles_path.name}"
+ (f" width={persist_width}" if persist_width else "")
+ (f" maxLines={persist_lines}" if persist_lines else "")
)
work = self._wolf_json_work_dir()
if work is not None and persist_width and int(persist_width) > 0:
wolf_ws.set_sheet_width(
work,
persist_note,
int(persist_width),
json_file=NAMES_JSON,
max_lines=persist_lines,
)
ok, summary = wolf_names.apply_names_wrap(
names_path,
note=note,
width=width,
lines=lines,
dry_run=False,
log_fn=log,
)
if not ok:
return False, summary
self._sync_translated_json_to_wolf_json(NAMES_JSON, self._work_dir())
roles_src = wolf_names.roles_json_path_for_names(names_path)
if roles_src.is_file():
work = self._work_dir()
if work.is_dir():
dest = work / roles_src.name
try:
if roles_src.resolve() != dest.resolve():
shutil.copy2(roles_src, dest)
except Exception:
pass
return True, summary
def _after(ok: bool, summary: str):
if not ok:
return
if hit is not None:
self._reload_wrap_hit_editor(hit, self._active_wrap_width())
if not quiet and hasattr(self, "_wrap_status_label"):
self._wrap_status_label.setText(
summary + " Re-inject names.json from Step 6."
)
self._run_task(task, on_done=_after)
def _inject_current_wrap_file(self):
hit = self._current_wrap_hit
if not hit:
@ -3273,7 +3599,7 @@ class WolfWorkflowTab(QWidget):
self._run_task(task)
def _run_names_wrap(self, *, manual: bool = False, dry_run: bool = False):
def _run_names_wrap(self, *, manual: bool = False, dry_run: bool = False, note: str | None = None):
"""Run ``wolf names-wrap`` on translated/names.json."""
names_path = self._tool_root() / "translated" / NAMES_JSON
if not names_path.is_file():
@ -3286,21 +3612,46 @@ class WolfWorkflowTab(QWidget):
)
return
hit = (
self._current_wrap_hit
if self._current_wrap_hit is not None and self._current_wrap_hit.kind == "names"
else None
)
width = self._wrap_width_spin.value() if hasattr(self, "_wrap_width_spin") else 0
max_lines = self._wrap_max_lines_value()
wrap_width = width if width > 0 else None
wrap_lines = max_lines or None
def task(log, progress=None):
if note and not dry_run and (wrap_width or wrap_lines):
roles_path = wolf_names.roles_json_path_for_names(names_path)
wolf_names.upsert_name_wrap_role(
roles_path,
note,
width=wrap_width,
max_lines=wrap_lines,
)
ok, summary = wolf_names.apply_names_wrap(
names_path,
note=note,
width=wrap_width,
lines=wrap_lines,
dry_run=dry_run,
log_fn=log,
)
if not ok:
return False, f"names-wrap failed — {summary}"
if manual:
if manual and not dry_run:
self._sync_translated_json_to_wolf_json(NAMES_JSON, self._work_dir())
return True, summary + (
"" if dry_run else " Re-inject names.json from Step 6 if needed."
)
self._run_task(task)
def _after(ok: bool, _msg: str):
if ok and manual and not dry_run and hit is not None and hit.kind == "names":
self._reload_wrap_hit_editor(hit, self._active_wrap_width())
self._run_task(task, on_done=_after)
# ── Step 5: Package ────────────────────────────────────────────────────────

View file

@ -62,7 +62,8 @@ class WolfCodesRepairTests(unittest.TestCase):
def test_apply_font_size_replaces_existing_codes(self):
text = r"Go to the \c[21]\f[20]tavern\c[19]\f[18]!"
new_text, count = wolf_codes.apply_font_size(text, 16)
self.assertEqual(count, 2)
self.assertEqual(count, 3) # 2 replaced + leading body
self.assertTrue(new_text.startswith(r"\f[16]"))
self.assertIn(r"\f[16]", new_text)
self.assertNotIn(r"\f[20]", new_text)
self.assertNotIn(r"\f[18]", new_text)
@ -73,6 +74,81 @@ class WolfCodesRepairTests(unittest.TestCase):
self.assertEqual(count, 1)
self.assertTrue(new_text.startswith(r"\f[18]"))
def test_infer_base_font_prefers_body_over_emphasis(self):
text = r"So they even take worries\nlike this in the\n\c[21]\f[20]confessional\c[19]\f[18]..."
self.assertEqual(wolf_codes.infer_base_font_size(text), 18)
def test_scale_font_sizes_keeps_emphasis_ratio(self):
text = (
r"So they even take worries\nlike this in the\n"
r"\c[21]\f[20]confessional\c[19]\f[18]..."
)
new_text, count = wolf_codes.scale_font_sizes(text, 14)
self.assertEqual(count, 3) # 2 scaled + leading body insert
# 20 * 14/18 ≈ 15.56 → 16; 18 * 14/18 = 14
self.assertTrue(new_text.startswith(r"\f[14]"))
self.assertIn(r"\f[16]", new_text)
self.assertIn(r"\f[14]", new_text)
self.assertNotIn(r"\f[20]", new_text)
self.assertNotIn(r"\f[18]", new_text)
def test_scale_font_sizes_matches_names_wrap_style_shrink(self):
source_style = r"\c[21]\f[20]Brothel\c[19]\f[18] doesn't get much use"
new_text, _ = wolf_codes.scale_font_sizes(source_style, 17)
# 20*17/18 ≈ 18.89 → 19; body 17
self.assertTrue(new_text.startswith(r"\f[17]"))
self.assertIn(r"\f[19]", new_text)
self.assertIn(r"\f[17]", new_text)
def test_scale_font_sizes_leads_with_body_for_midline_emphasis(self):
# Cafe-style: body only appears after emphasis; Wolf needs \\f at start.
text = (
'"That \\c[21]\\f[14]cafe\\c[19]\\f[13] over there has been\n'
'packed lately." "Apparently\n'
"they're hiring waitresses.\""
)
new_text, count = wolf_codes.scale_font_sizes(text, 13)
self.assertGreaterEqual(count, 2)
self.assertTrue(new_text.startswith(r"\f[13]"))
self.assertIn(r"\c[21]\f[14]cafe\c[19]\f[13]", new_text)
def test_ensure_leading_font_size_idempotent(self):
text = r'\f[13]"That \c[21]\f[14]cafe\c[19]\f[13] over there"'
self.assertEqual(wolf_codes.ensure_leading_font_size(text, 13), text)
def test_apply_font_size_also_leads(self):
text = r'"That \c[21]\f[20]cafe\c[19]\f[18] over there"'
new_text, _ = wolf_codes.apply_font_size(text, 13)
self.assertTrue(new_text.startswith(r"\f[13]"))
self.assertNotIn(r"\f[20]", new_text)
def test_font_size_codes_differ_detects_names_wrap_shrink(self):
source = (
r"「おい!例の\c[21]\f[20]教会の特別奉仕活動\c[19]\f[18]があるってよ!」"
)
text = (
r'\f[14]"Hey! Apparently there\'s one of those '
r'\c[21]\f[16]Special Service Activities at the church\c[19]\f[14]!"'
)
self.assertTrue(wolf_codes.font_size_codes_differ(source, text))
def test_font_size_codes_differ_false_when_color_missing(self):
source = r"\c[21]\f[20]娼館\c[19]\f[18]"
text = r"\f[14]Brothel" # dropped colour codes
self.assertFalse(wolf_codes.font_size_codes_differ(source, text))
def test_names_doc_has_font_size_drift(self):
doc = {
"kind": "names",
"names": [
{
"source": r"\c[21]\f[20]娼館\c[19]\f[18]",
"text": r"\f[14]\c[21]\f[16]Brothel\c[19]\f[14]",
}
],
}
self.assertTrue(wolf_codes.names_doc_has_font_size_drift(doc))
if __name__ == "__main__":
unittest.main()

View file

@ -127,5 +127,46 @@ class TestDeriveDbLabels(unittest.TestCase):
self.assertEqual(wn.derive_db_labels(self.dir / "nope"), {})
class TestNameWrapRoles(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.dir = Path(self._tmp.name)
self.names = self.dir / "names.json"
self.names.write_text("{}", encoding="utf-8")
def tearDown(self):
self._tmp.cleanup()
def test_roles_path_beside_names(self):
self.assertEqual(
wn.roles_json_path_for_names(self.names),
self.dir / "wolfdawn-roles.json",
)
def test_upsert_and_load_name_wrap_role(self):
roles_path = wn.roles_json_path_for_names(self.names)
wn.upsert_name_wrap_role(roles_path, "説明", width=48, max_lines=3)
roles = wn.load_name_wrap_roles(roles_path)
role = wn.get_name_wrap_role(roles, "説明")
self.assertIsNotNone(role)
assert role is not None
self.assertEqual(role["width"], 48)
self.assertEqual(role["maxLines"], 3)
def test_upsert_updates_existing_note(self):
roles_path = wn.roles_json_path_for_names(self.names)
wn.upsert_name_wrap_role(roles_path, "説明", width=40)
wn.upsert_name_wrap_role(roles_path, "説明", width=52, max_lines=4)
role = wn.get_name_wrap_role(wn.load_name_wrap_roles(roles_path), "説明")
self.assertEqual(role["width"], 52)
self.assertEqual(role["maxLines"], 4)
def test_upsert_persists_font(self):
roles_path = wn.roles_json_path_for_names(self.names)
wn.upsert_name_wrap_role(roles_path, "説明", width=40, font=14)
role = wn.get_name_wrap_role(wn.load_name_wrap_roles(roles_path), "説明")
self.assertEqual(role["font"], 14)
if __name__ == "__main__":
unittest.main()

View file

@ -322,5 +322,65 @@ class TestEventScopeWrap(unittest.TestCase):
self.assertEqual(changed, 1)
class TestManualFontAndWrap(unittest.TestCase):
def test_scales_emphasis_then_wraps(self):
text = (
r"So they even take worries like this in the "
r"\c[21]\f[20]confessional\c[19]\f[18] booth every day."
)
new_text, changed = ws.apply_manual_font_and_wrap(text, 40, font=14)
self.assertTrue(changed)
self.assertIn(r"\f[16]", new_text)
self.assertIn(r"\f[14]", new_text)
self.assertLessEqual(dazedwrap.max_line_visible_length(new_text), 40)
def test_group_manual_skips_short_plain_lines(self):
text = "Short."
new_text, changed = ws.apply_manual_font_and_wrap(
text, 40, font=14, only_overflow_or_fonts=True
)
self.assertFalse(changed)
self.assertEqual(new_text, text)
def test_wrap_overflow_manual_in_names_category(self):
tmp = tempfile.TemporaryDirectory()
try:
path = Path(tmp.name) / "names.json"
doc = {
"kind": "names",
"names": [
{
"note": "",
"source": "a",
"text": (
r"So they even take worries like this in the "
r"\c[21]\f[20]confessional\c[19]\f[18] booth."
),
},
{"note": "", "source": "b", "text": "OK"},
{"note": "other", "source": "c", "text": "x" * 80},
],
}
path.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8")
hit = ws.WrapHit(
json_file="names.json",
kind="names",
sheet_name="",
row=0,
field_name="name",
text=doc["names"][0]["text"],
max_line_len=80,
)
changed = ws.wrap_overflow_manual_in_scope(path, doc, hit, 36, font=14)
self.assertEqual(changed, 1)
saved = json.loads(path.read_text(encoding="utf-8"))
out = saved["names"][0]["text"]
self.assertIn(r"\f[14]", out)
self.assertEqual(saved["names"][1]["text"], "OK")
self.assertEqual(saved["names"][2]["text"], "x" * 80)
finally:
tmp.cleanup()
if __name__ == "__main__":
unittest.main()

View file

@ -130,6 +130,55 @@ class TestRelayoutWrappers(unittest.TestCase):
wolfdawn.names_wrap("/tmp/names.json", dry_run=True)
self.assertEqual(captured["args"], ["names-wrap", "/tmp/names.json", "--dry-run"])
def test_names_wrap_note_and_width(self):
captured = {}
def fake_run(args, log_fn=None):
captured["args"] = list(args)
return wolfdawn.WolfResult(0, "", "", [str(a) for a in args])
with patch.object(wolfdawn, "_run", side_effect=fake_run):
wolfdawn.names_wrap(
"/tmp/names.json",
note="├■街の噂MOB",
width=66,
)
self.assertEqual(
captured["args"],
["names-wrap", "/tmp/names.json", "--width", "66", "--note", "├■街の噂MOB"],
)
def test_names_wrap_lines(self):
captured = {}
def fake_run(args, log_fn=None):
captured["args"] = list(args)
return wolfdawn.WolfResult(0, "", "", [str(a) for a in args])
with patch.object(wolfdawn, "_run", side_effect=fake_run):
wolfdawn.names_wrap("/tmp/names.json", width=50, lines=3, note="説明")
self.assertEqual(
captured["args"],
[
"names-wrap",
"/tmp/names.json",
"--width",
"50",
"--lines",
"3",
"--note",
"説明",
],
)
def test_parse_names_wrap_shrink_count(self):
blob = (
' shrunk to \\f[14] to fit its slot: "Hey! ..."\n'
' shrunk to \\f[12] to fit its slot: "That sister..."\n'
"names-wrap: 7 entry(ies) re-wrapped"
)
self.assertEqual(wolfdawn.parse_names_wrap_shrink_count(blob), 2)
if __name__ == "__main__":
unittest.main(verbosity=2)

View file

@ -51,6 +51,7 @@ __all__ = [
"names_check",
"names_wrap",
"parse_names_wrap_counts",
"parse_names_wrap_shrink_count",
"relayout",
"desc_relayout",
]
@ -663,6 +664,7 @@ _NAMES_WRAP_WOULD_RE = re.compile(
_NAMES_WRAP_APPLIED_RE = re.compile(
r"(\d+)\s+entry\(ies\)\s+re-wrapped", re.IGNORECASE
)
_NAMES_WRAP_SHRUNK_RE = re.compile(r"shrunk to \\f\[(\d+)\]", re.IGNORECASE)
def parse_names_wrap_counts(stdout: str, stderr: str = "") -> int | None:
@ -675,18 +677,35 @@ def parse_names_wrap_counts(stdout: str, stderr: str = "") -> int | None:
return None
def parse_names_wrap_shrink_count(stdout: str, stderr: str = "") -> int:
"""Return how many entries names-wrap shrunk with a leading ``\\f[N]``."""
blob = f"{stdout}\n{stderr}"
return len(_NAMES_WRAP_SHRUNK_RE.findall(blob))
def names_wrap(
names_json: PathLike,
*,
width: int | None = None,
lines: int | None = None,
note: str | None = None,
dry_run: bool = False,
log_fn=None,
) -> WolfResult:
"""``wolf names-wrap <names.json> [--dry-run]``.
"""``wolf names-wrap <names.json> [--width N] [--lines N] [--note <category>] [--dry-run]``.
Re-wraps translated multi-line glossary entries to each category's
JP-measured width and line count. One-line names are never wrapped.
Re-wraps translated multi-line glossary entries to each category's slot
geometry (JP-measured, ``wolfdawn-roles.json`` overrides, or auto-expanded).
``lines`` overrides each entry's JP line budget (``2+`` also wraps one-line
names). Entries that still overflow shrink via a leading ``\\f[N]``.
"""
args = ["names-wrap", _str(names_json)]
if width is not None and int(width) > 0:
args += ["--width", str(int(width))]
if lines is not None and int(lines) > 0:
args += ["--lines", str(int(lines))]
if note:
args += ["--note", str(note)]
if dry_run:
args.append("--dry-run")
return _run(args, log_fn=log_fn)

View file

@ -46,22 +46,170 @@ def detect_font_sizes(text: str) -> list[int]:
return sorted(sizes)
def apply_font_size(text: str, size: int) -> tuple[str, int]:
"""Replace every ``\\f[N]`` in *text* with ``\\f[size]``.
def infer_base_font_size(text: str, *, default: int = 18) -> int:
"""Guess the body / \"normal\" ``\\f[N]`` size for *text*.
Returns ``(new_text, replacements)``. When no ``\\f[N]`` codes exist, prepends
``\\f[size]`` at the start of the string (after an optional opening quote).
Emphasis overrides (e.g. ``\\f[20]\\f[18]``) keep a larger size for a span
then restore the body size. Prefer the most common size; on a tie, the
smaller one (body over emphasis).
"""
if not isinstance(text, str) or not text:
return int(default)
counts: dict[int, int] = {}
for match in WOLF_FONT_SIZE_RE.finditer(text):
try:
size = int(match.group(1))
except (TypeError, ValueError):
continue
counts[size] = counts.get(size, 0) + 1
if not counts:
return int(default)
top = max(counts.values())
return min(size for size, n in counts.items() if n == top)
def ensure_leading_font_size(text: str, size: int) -> str:
"""Ensure *text* begins with ``\\f[size]`` so Wolf sets line height from the start.
Mid-line ``\\f[N]`` alone (e.g. ``"That \\c[21]\\f[14]cafe…``) leaves the
opening glyphs on the default face and mis-aligns the row. WolfDawn
``names-wrap`` always leads with the body size for the same reason.
"""
if not isinstance(text, str) or not text or int(size) <= 0:
return text
size = int(size)
lead = rf"\f[{size}]"
# Already starts with the desired body size.
m = WOLF_FONT_SIZE_RE.match(text)
if m and int(m.group(1)) == size:
return text
# Replace a different leading \\f[N] with the body size.
if m:
return lead + text[m.end() :]
return lead + text
def scale_font_sizes(
text: str,
new_base: int,
*,
old_base: int | None = None,
min_size: int = 8,
) -> tuple[str, int]:
"""Scale every ``\\f[N]`` proportionally when the body font changes.
Example: body ``\\f[18]`` with emphasis ``\\f[20]`` shrunk to body 14 becomes
``\\f[16]`` / ``\\f[14]`` (``round(N * new_base / old_base)``, floored at
*min_size*). Always ensures a leading ``\\f[new_base]`` so Wolf measures the
line from the body size (fixes mid-line emphasis like
``"That \\c[21]\\f[14]cafe\\c[19]\\f[13]…``).
When *text* has no ``\\f[N]``, prepends ``\\f[new_base]``.
Returns ``(new_text, replacements)``.
"""
if not isinstance(text, str) or not text.strip() or int(new_base) <= 0:
return text, 0
new_base = int(new_base)
min_size = max(1, int(min_size))
matches = list(WOLF_FONT_SIZE_RE.finditer(text))
if not matches:
return ensure_leading_font_size(text, new_base), 1
base = int(old_base) if old_base is not None and int(old_base) > 0 else infer_base_font_size(text)
if base <= 0:
base = new_base
def _scale(match: re.Match) -> str:
try:
old = int(match.group(1))
except (TypeError, ValueError):
return match.group(0)
scaled = max(min_size, int(round(old * new_base / base)))
return rf"\f[{scaled}]"
had_leading = bool(WOLF_FONT_SIZE_RE.match(text))
new_text = WOLF_FONT_SIZE_RE.sub(_scale, text)
new_text = ensure_leading_font_size(new_text, new_base)
count = len(matches) + (0 if had_leading else 1)
return new_text, count
def apply_font_size(text: str, size: int) -> tuple[str, int]:
"""Force every ``\\f[N]`` in *text* to the same *size* (no proportional scale).
Prefer :func:`scale_font_sizes` when the line has emphasis overrides that
should stay relatively larger than the body font.
Always ensures a leading ``\\f[size]``. Returns ``(new_text, replacements)``.
"""
if not isinstance(text, str) or not text.strip() or size <= 0:
return text, 0
size = int(size)
matches = list(WOLF_FONT_SIZE_RE.finditer(text))
if matches:
had_leading = bool(WOLF_FONT_SIZE_RE.match(text))
new_text = WOLF_FONT_SIZE_RE.sub(rf"\\f[{size}]", text)
return new_text, len(matches)
if text.startswith('"'):
return rf'\f[{size}]' + text, 1
return rf"\f[{size}]{text}", 1
new_text = ensure_leading_font_size(new_text, size)
count = len(matches) + (0 if had_leading else 1)
return new_text, count
return ensure_leading_font_size(text, size), 1
def _control_code_tokens(text: str) -> list[str]:
"""Return the sequence of WolfDawn-compared control codes in *text*."""
if not isinstance(text, str) or not text:
return []
return [m.group(0) for m in WOLF_INLINE_CODE_RE.finditer(text)]
def _normalize_font_size_codes(text: str) -> str:
"""Replace every ``\\f[N]`` with a placeholder so size values are ignored."""
if not isinstance(text, str) or not text:
return text if isinstance(text, str) else ""
return WOLF_FONT_SIZE_RE.sub(r"\\f[_]", text)
def font_size_codes_differ(source: str, text: str) -> bool:
"""True when *source* and *text* differ only in ``\\f[N]`` size values / count.
Used after ``wolf names-wrap`` shrinks overflow with a leading ``\\f[N]`` and
scales inline font codes. Other control-code drift (missing ``\\c``, etc.)
returns False so inject still requires an explicit allow-code-drift.
"""
if not isinstance(source, str) or not isinstance(text, str):
return False
if source == text:
return False
src_codes = _control_code_tokens(source)
txt_codes = _control_code_tokens(text)
if src_codes == txt_codes:
return False
# Same non-font codes, but font sizes / leading shrink differ.
src_norm = _normalize_font_size_codes(source)
txt_norm = _normalize_font_size_codes(text)
if _control_code_tokens(src_norm) == _control_code_tokens(txt_norm):
return True
# Leading shrink adds an extra \\f that source never had.
src_f = WOLF_FONT_SIZE_RE.findall(source)
txt_f = WOLF_FONT_SIZE_RE.findall(text)
if not txt_f:
return False
src_non_f = [c for c in src_codes if not WOLF_FONT_SIZE_RE.fullmatch(c)]
txt_non_f = [c for c in txt_codes if not WOLF_FONT_SIZE_RE.fullmatch(c)]
return src_non_f == txt_non_f and (src_f != txt_f or len(txt_f) > len(src_f))
def names_doc_has_font_size_drift(doc: dict[str, Any]) -> bool:
"""True when any names.json entry has font-size-only control-code drift."""
if not isinstance(doc, dict) or doc.get("kind") != "names":
return False
for entry in doc.get("names") or []:
if not isinstance(entry, dict):
continue
src, txt = entry.get("source"), entry.get("text")
if isinstance(src, str) and isinstance(txt, str) and font_size_codes_differ(src, txt):
return True
return False
def _tokenize(rest: str) -> list[tuple[str, str]]:

View file

@ -216,6 +216,8 @@ def _prepare_for_names_inject(
originals_dir: Path,
game_root: Path,
log_fn: Callable[[str], None] | None = None,
*,
allow_code_drift: bool = False,
) -> str | None:
"""Restore live Data/ and ensure names-inject can match Japanese sources."""
emit = log_fn or (lambda _msg: None)
@ -227,7 +229,9 @@ def _prepare_for_names_inject(
emit(f"{warning}")
_restore()
would_apply = wolf_originals.names_inject_would_apply(names_src, data_dir)
would_apply = wolf_originals.names_inject_would_apply(
names_src, data_dir, allow_code_drift=allow_code_drift
)
if wolfdawn.inject_had_applied(would_apply):
return None
@ -236,7 +240,9 @@ def _prepare_for_names_inject(
game_root, originals_dir, force=True, log_fn=log_fn
):
_restore()
would_apply = wolf_originals.names_inject_would_apply(names_src, data_dir)
would_apply = wolf_originals.names_inject_would_apply(
names_src, data_dir, allow_code_drift=allow_code_drift
)
if wolfdawn.inject_had_applied(would_apply):
emit(f" ✓ ready — dry run would apply {would_apply} name change(s)")
return None
@ -253,7 +259,9 @@ def _inject_names(
log_fn: Callable[[str], None] | None = None,
) -> FileInjectResult:
emit = log_fn or (lambda _msg: None)
would_apply = wolf_originals.names_inject_would_apply(names_src, data_dir)
would_apply = wolf_originals.names_inject_would_apply(
names_src, data_dir, allow_code_drift=allow_code_drift
)
if not wolfdawn.inject_had_applied(would_apply):
return FileInjectResult(
NAMES_JSON,
@ -276,7 +284,9 @@ def _inject_names(
if not result.success:
return result
remaining = wolf_originals.names_inject_would_apply(names_src, data_dir)
remaining = wolf_originals.names_inject_would_apply(
names_src, data_dir, allow_code_drift=allow_code_drift
)
if wolfdawn.inject_had_applied(remaining):
result = FileInjectResult(
NAMES_JSON,
@ -401,6 +411,20 @@ def inject_selected(
if NAMES_JSON in todo:
emit("Preparing live Data/ for names.json…")
names_src = translated_dir / NAMES_JSON
names_drift = allow_code_drift
if not names_drift:
try:
names_doc = json.loads(names_src.read_text(encoding="utf-8-sig"))
except Exception:
names_doc = None
if isinstance(names_doc, dict) and wolf_codes.names_doc_has_font_size_drift(
names_doc
):
names_drift = True
emit(
" names-wrap changed \\f[N] sizes — "
"passing --allow-code-drift for names-inject"
)
prep_error = _prepare_for_names_inject(
names_src,
manifest_entries,
@ -408,6 +432,7 @@ def inject_selected(
originals_dir,
game_root,
log_fn=log_fn,
allow_code_drift=names_drift,
)
if prep_error:
result = FileInjectResult(NAMES_JSON, False, prep_error)
@ -419,7 +444,7 @@ def inject_selected(
result = _inject_names(
names_src,
data_dir,
allow_code_drift=allow_code_drift,
allow_code_drift=names_drift,
en_punct=en_punct,
log_fn=log_fn,
)

View file

@ -233,11 +233,17 @@ def collect_name_notes(data: dict[str, Any]) -> list[str]:
def apply_names_wrap(
names_path: PathLike,
*,
note: str | None = None,
width: int | None = None,
lines: int | None = None,
dry_run: bool = False,
log_fn=None,
) -> tuple[bool, str]:
"""Run ``wolf names-wrap`` on one names.json file.
When *note* is set, only that category (``names[].note``) is processed.
Omit *width* / *lines* to measure slot geometry from JP text (recommended).
Returns ``(ok, summary)``. *ok* is False only when the CLI exits with a hard
error; overflow warnings still count as success.
"""
@ -248,7 +254,14 @@ def apply_names_wrap(
if not path.is_file():
return False, f"names.json not found: {path}"
res = wolfdawn.names_wrap(path, dry_run=dry_run, log_fn=None)
res = wolfdawn.names_wrap(
path,
note=note,
width=width,
lines=lines,
dry_run=dry_run,
log_fn=None,
)
for line in (res.stdout or "").splitlines():
line = line.strip()
if line:
@ -262,14 +275,104 @@ def apply_names_wrap(
return False, f"names-wrap exited {res.returncode}"
count = wolfdawn.parse_names_wrap_counts(res.stdout, res.stderr)
shrunk = wolfdawn.parse_names_wrap_shrink_count(res.stdout, res.stderr)
scope = f"category {note}" if note else "names.json"
parts: list[str] = []
if dry_run:
summary = (
f"dry run: {count} entr{'y' if count == 1 else 'ies'} would be re-wrapped"
if count is not None
else "dry run complete"
)
if count is not None:
parts.append(
f"dry run: {count} entr{'y' if count == 1 else 'ies'} in {scope} "
"would be re-wrapped"
)
else:
parts.append(f"dry run complete for {scope}")
elif count is not None:
summary = f"re-wrapped {count} entr{'y' if count == 1 else 'ies'}"
parts.append(
f"re-wrapped {count} entr{'y' if count == 1 else 'ies'} in {scope}"
)
else:
summary = "names-wrap complete"
return True, summary
parts.append(f"names-wrap complete for {scope}")
if width is not None and int(width) > 0:
parts.append(f"width={int(width)}")
if lines is not None and int(lines) > 0:
parts.append(f"lines={int(lines)}")
if shrunk:
parts.append(f"{shrunk} shrunk with \\f[N]")
return True, "; ".join(parts)
def roles_json_path_for_names(names_path: PathLike) -> Path:
"""Return ``wolfdawn-roles.json`` beside *names_path* (WolfDawn looks there)."""
return Path(names_path).resolve().parent / "wolfdawn-roles.json"
def load_name_wrap_roles(roles_path: PathLike) -> dict[str, Any]:
"""Load ``wolfdawn-roles.json``, or an empty document."""
path = Path(roles_path)
if not path.is_file():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8-sig"))
return data if isinstance(data, dict) else {}
except Exception:
return {}
def get_name_wrap_role(roles: dict[str, Any], note: str) -> dict[str, Any] | None:
"""Return the ``nameWraps`` entry matching *note*, if any."""
wraps = roles.get("nameWraps")
if not isinstance(wraps, list):
return None
for entry in wraps:
if not isinstance(entry, dict):
continue
entry_note = str(entry.get("note") or "")
if entry_note == note or (note and note in entry_note) or (entry_note and entry_note in note):
return entry
return None
def upsert_name_wrap_role(
roles_path: PathLike,
note: str,
*,
width: int | None = None,
max_lines: int | None = None,
font: int | None = None,
min_font: int | None = None,
) -> Path:
"""Create/update a ``nameWraps`` rule for *note* in ``wolfdawn-roles.json``.
WolfDawn reads this file from the same directory as ``names.json``.
"""
path = Path(roles_path)
data = load_name_wrap_roles(path)
wraps = data.get("nameWraps")
if not isinstance(wraps, list):
wraps = []
data["nameWraps"] = wraps
entry = None
for item in wraps:
if isinstance(item, dict) and str(item.get("note") or "") == note:
entry = item
break
if entry is None:
entry = {"note": note}
wraps.append(entry)
if width is not None and int(width) > 0:
entry["width"] = int(width)
if max_lines is not None and int(max_lines) > 0:
entry["maxLines"] = int(max_lines)
if font is not None and int(font) > 0:
entry["font"] = int(font)
if min_font is not None and int(min_font) > 0:
entry["minFont"] = int(min_font)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(data, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
return path

View file

@ -70,10 +70,19 @@ def rebuild_originals_from_archives(
return True
def names_inject_would_apply(names_json: Path, data_dir: Path) -> int | None:
def names_inject_would_apply(
names_json: Path,
data_dir: Path,
*,
allow_code_drift: bool = False,
) -> int | None:
"""Return how many name changes wolf would apply (dry run), or None if unknown."""
res = wolfdawn.names_inject(
str(names_json), str(data_dir), dry_run=True, log_fn=None
str(names_json),
str(data_dir),
dry_run=True,
allow_code_drift=allow_code_drift,
log_fn=None,
)
applied, _drifted = wolfdawn.parse_names_inject_counts(res.stdout, res.stderr)
return applied

View file

@ -153,14 +153,40 @@ def set_sheet_width(
width: int,
*,
json_file: str,
max_lines: int | None = None,
) -> None:
profile = load_wrap_profile(work_dir)
sheets = profile.setdefault("sheets", {})
sheets[sheet_name] = {"width": int(width), "json_file": json_file}
entry = sheets.get(sheet_name)
if not isinstance(entry, dict):
entry = {}
sheets[sheet_name] = entry
entry["width"] = int(width)
entry["json_file"] = json_file
if max_lines is not None and int(max_lines) > 0:
entry["max_lines"] = int(max_lines)
elif "max_lines" in entry and max_lines == 0:
entry.pop("max_lines", None)
profile["default_width"] = profile.get("default_width", DEFAULT_WRAP_WIDTH)
save_wrap_profile(work_dir, profile)
def get_sheet_max_lines(
profile: dict[str, Any],
sheet_name: str,
*,
default: int = 0,
) -> int:
sheets = profile.get("sheets") or {}
entry = sheets.get(sheet_name)
if isinstance(entry, dict) and entry.get("max_lines") is not None:
try:
return int(entry["max_lines"])
except (TypeError, ValueError):
pass
return default
def _load_json(path: Path) -> dict[str, Any] | None:
try:
return json.loads(path.read_text(encoding="utf-8-sig"))
@ -493,6 +519,189 @@ def wrap_line_in_doc(line: dict[str, Any], width: int) -> bool:
return True
def apply_manual_font_and_wrap(
text: str,
width: int,
*,
font: int | None = None,
old_base: int | None = None,
only_overflow_or_fonts: bool = False,
) -> tuple[str, bool]:
"""Scale body ``\\f`` (keeping emphasis ratios), then wrap to *width*.
When *only_overflow_or_fonts* is True (group wrap), skip lines that already
fit and have no ``\\f[N]`` so short UI labels are not given a forced font.
Returns ``(new_text, changed)``.
"""
from util.wolfdawn import codes as wolf_codes
if not isinstance(text, str) or not text.strip():
return text, False
new_text = text
has_f = bool(wolf_codes.detect_font_sizes(new_text))
needs_wrap = width > 0 and line_needs_wrap(new_text, width)
if only_overflow_or_fonts and not has_f and not needs_wrap:
return text, False
if font is not None and int(font) > 0 and (has_f or needs_wrap or not only_overflow_or_fonts):
new_text, _ = wolf_codes.scale_font_sizes(
new_text, int(font), old_base=old_base
)
needs_wrap = width > 0 and line_needs_wrap(new_text, width)
if needs_wrap:
new_text = wrap_line_text(new_text, width)
return new_text, new_text != text
def wrap_hit_manual(
path: Path,
doc: dict[str, Any],
hit_id: dict[str, Any],
width: int,
*,
font: int | None = None,
) -> bool:
"""Apply manual font scale + wrap to one hit line."""
line = locate_line(doc, hit_id)
if line is None:
return False
text = line.get("text")
if not isinstance(text, str):
return False
new_text, changed = apply_manual_font_and_wrap(text, width, font=font)
if not changed:
return False
line["text"] = new_text
save_document(path, doc)
return True
def wrap_overflow_manual_in_scope(
path: Path,
doc: dict[str, Any],
hit: WrapHit | dict[str, Any],
width: int,
*,
font: int | None = None,
) -> int:
"""Manual wrap (+ optional proportional font) for every line in *hit*'s group."""
if isinstance(hit, WrapHit):
kind, sheet_name = hit.kind, hit.sheet_name
else:
kind = str(hit.get("kind") or doc.get("kind") or "")
sheet_name = str(hit.get("sheet_name") or "")
if kind == "names":
return _wrap_manual_in_names_category(path, doc, sheet_name, width, font=font)
if kind == "db":
return _wrap_manual_in_db_sheet(path, doc, sheet_name, width, font=font)
if kind in ("map", "common"):
return _wrap_manual_in_event_file(path, doc, width, font=font)
if kind == "gamedat":
return _wrap_manual_in_gamedat(path, doc, width, font=font)
return 0
def _apply_manual_to_line_dict(
line: dict[str, Any],
width: int,
*,
font: int | None,
) -> bool:
text = line.get("text")
if not isinstance(text, str) or not text.strip():
return False
new_text, changed = apply_manual_font_and_wrap(
text, width, font=font, only_overflow_or_fonts=True
)
if not changed:
return False
line["text"] = new_text
return True
def _wrap_manual_in_names_category(
path: Path,
doc: dict[str, Any],
category: str,
width: int,
*,
font: int | None = None,
) -> int:
if doc.get("kind") != "names":
return 0
changed = 0
for entry in doc.get("names") or []:
if not isinstance(entry, dict):
continue
if str(entry.get("note") or "") != category:
continue
if _apply_manual_to_line_dict(entry, width, font=font):
changed += 1
if changed:
save_document(path, doc)
return changed
def _wrap_manual_in_db_sheet(
path: Path,
doc: dict[str, Any],
sheet_name: str,
width: int,
*,
font: int | None = None,
) -> int:
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 []:
if _apply_manual_to_line_dict(line, width, font=font):
changed += 1
if changed:
save_document(path, doc)
return changed
def _wrap_manual_in_event_file(
path: Path,
doc: dict[str, Any],
width: int,
*,
font: int | None = None,
) -> int:
if doc.get("kind") not in ("map", "common"):
return 0
changed = 0
for scene in doc.get("scenes") or []:
for line in scene.get("lines") or []:
if _apply_manual_to_line_dict(line, width, font=font):
changed += 1
if changed:
save_document(path, doc)
return changed
def _wrap_manual_in_gamedat(
path: Path,
doc: dict[str, Any],
width: int,
*,
font: int | None = None,
) -> int:
if doc.get("kind") != "gamedat":
return 0
changed = 0
for line in doc.get("lines") or []:
if _apply_manual_to_line_dict(line, width, font=font):
changed += 1
if changed:
save_document(path, doc)
return changed
@dataclass
class ScopeStats:
"""Line counts for the wrap group containing one search hit."""