diff --git a/TODO.md b/TODO.md index 85aad13..8e11e8e 100644 --- a/TODO.md +++ b/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 \ No newline at end of file diff --git a/gui/wolf_workflow_tab.py b/gui/wolf_workflow_tab.py index 4586bfa..ccc0e93 100644 --- a/gui/wolf_workflow_tab.py +++ b/gui/wolf_workflow_tab.py @@ -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 ──────────────────────────────────────────────────────── diff --git a/tests/test_wolf_codes.py b/tests/test_wolf_codes.py index 870d5c8..dd44b4e 100644 --- a/tests/test_wolf_codes.py +++ b/tests/test_wolf_codes.py @@ -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() diff --git a/tests/test_wolf_names.py b/tests/test_wolf_names.py index 05aea47..5bd806d 100644 --- a/tests/test_wolf_names.py +++ b/tests/test_wolf_names.py @@ -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() diff --git a/tests/test_wolf_wrap_search.py b/tests/test_wolf_wrap_search.py index 9497671..c84d884 100644 --- a/tests/test_wolf_wrap_search.py +++ b/tests/test_wolf_wrap_search.py @@ -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() diff --git a/tests/test_wolfdawn_relayout.py b/tests/test_wolfdawn_relayout.py index 82402aa..fc37aed 100644 --- a/tests/test_wolfdawn_relayout.py +++ b/tests/test_wolfdawn_relayout.py @@ -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) diff --git a/util/wolfdawn/__init__.py b/util/wolfdawn/__init__.py index 0221b1e..150041f 100644 --- a/util/wolfdawn/__init__.py +++ b/util/wolfdawn/__init__.py @@ -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 [--dry-run]``. + """``wolf names-wrap [--width N] [--lines N] [--note ] [--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) diff --git a/util/wolfdawn/codes.py b/util/wolfdawn/codes.py index 8c44478..e454d24 100644 --- a/util/wolfdawn/codes.py +++ b/util/wolfdawn/codes.py @@ -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]]: diff --git a/util/wolfdawn/inject.py b/util/wolfdawn/inject.py index fdce137..b976ade 100644 --- a/util/wolfdawn/inject.py +++ b/util/wolfdawn/inject.py @@ -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, ) diff --git a/util/wolfdawn/names.py b/util/wolfdawn/names.py index b2d51f6..1b98b40 100644 --- a/util/wolfdawn/names.py +++ b/util/wolfdawn/names.py @@ -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 diff --git a/util/wolfdawn/originals.py b/util/wolfdawn/originals.py index 652b2bc..05385e6 100644 --- a/util/wolfdawn/originals.py +++ b/util/wolfdawn/originals.py @@ -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 diff --git a/util/wolfdawn/wrap_search.py b/util/wolfdawn/wrap_search.py index cb482b2..ceaaa6f 100644 --- a/util/wolfdawn/wrap_search.py +++ b/util/wolfdawn/wrap_search.py @@ -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."""