Working Wrap
This commit is contained in:
parent
dd048f48f1
commit
a2411b3e31
12 changed files with 1291 additions and 199 deletions
2
TODO.md
2
TODO.md
|
|
@ -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
|
||||
|
|
@ -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 ────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]]:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Reference in a new issue