Compare commits
6 commits
fb628f5f85
...
f25ce9f990
| Author | SHA1 | Date | |
|---|---|---|---|
| f25ce9f990 | |||
| 04c6fb63bb | |||
| e23e8f3b10 | |||
| 3fcde613a8 | |||
| 9db8089076 | |||
| 5ff504e549 |
10 changed files with 1372 additions and 209 deletions
|
|
@ -17,8 +17,9 @@ Mirrors the RPGMaker WorkflowTab, driven by the vendored WolfDawn ``wolf`` CLI
|
|||
Step 5 Maps/Events - .mps maps, CommonEvent, Game.dat, Evtext; speaker handling
|
||||
Step 6 Precheck - name consistency + reconcile, then dry-run inject for
|
||||
safety-guard skips; edit those lines before writing binaries
|
||||
Step 7 Inject - always inject every translated JSON into Data/ and
|
||||
refresh wolf_json/ (full pass avoids name/DB wipe bugs);
|
||||
Step 7 Inject - inject every JSON into Data/ from translated/ (default)
|
||||
or from the game's wolf_json/; translated inject also
|
||||
refreshes wolf_json/ (full pass avoids name/DB wipe bugs);
|
||||
optional layout-restore re-applies source whitespace pads
|
||||
Step 8 Package - run from a loose Data/ folder or repack Data.wolf;
|
||||
optional save rewrite for existing .sav files
|
||||
|
|
@ -2533,7 +2534,9 @@ class WolfWorkflowTab(QWidget):
|
|||
def _build_step7_inject(self, layout: QVBoxLayout):
|
||||
layout.addWidget(_make_section_label("Step 7 · Inject Translations"))
|
||||
layout.addWidget(self._desc(
|
||||
"Inject every translated JSON into the game's Data/ binaries in one pass. "
|
||||
"Inject every JSON into the game's Data/ binaries in one pass. "
|
||||
"Inject all uses translated/; Inject from wolf_json uses the game's "
|
||||
"wolf_json/ when that folder is the source of truth. "
|
||||
"A full inject keeps names.json and DB/map files in sync (partial injects "
|
||||
"can wipe name-only fields like rumor boards). Run Step 6 (Precheck) first "
|
||||
"if you want to catch safety-guard skips before writing."
|
||||
|
|
@ -2578,9 +2581,18 @@ class WolfWorkflowTab(QWidget):
|
|||
inject_all_btn = self._register(_make_btn("Inject all", "#00a86b"))
|
||||
inject_all_btn.setToolTip(
|
||||
"Always injects every file in translated/ that the manifest knows about. "
|
||||
"names.json runs first; DB/map injects keep name-only fields."
|
||||
"names.json runs first; DB/map injects keep name-only fields. "
|
||||
"After inject, copies those JSON files into the game's wolf_json/."
|
||||
)
|
||||
inject_all_btn.clicked.connect(self._inject_all)
|
||||
inject_wolf_btn = self._register(_make_btn("Inject from wolf_json", "#007acc"))
|
||||
inject_wolf_btn.setToolTip(
|
||||
"Inject every injectable JSON from the game's wolf_json/ folder instead "
|
||||
"of translated/. Use when wolf_json/ is the source of truth (e.g. after "
|
||||
"editing or restoring committed game-repo JSON). Does not copy from "
|
||||
"translated/."
|
||||
)
|
||||
inject_wolf_btn.clicked.connect(self._inject_all_from_wolf_json)
|
||||
layout_restore_btn = self._register(_make_btn("Layout-restore", "#3a3a3a"))
|
||||
layout_restore_btn.setToolTip(
|
||||
"Re-run wolf layout-restore on every injectable translated JSON. "
|
||||
|
|
@ -2589,6 +2601,7 @@ class WolfWorkflowTab(QWidget):
|
|||
)
|
||||
layout_restore_btn.clicked.connect(self._layout_restore_all)
|
||||
btn_row.addWidget(inject_all_btn)
|
||||
btn_row.addWidget(inject_wolf_btn)
|
||||
btn_row.addStretch()
|
||||
btn_row.addWidget(layout_restore_btn)
|
||||
layout.addLayout(btn_row)
|
||||
|
|
@ -2812,10 +2825,16 @@ class WolfWorkflowTab(QWidget):
|
|||
work = self._wolf_json_work_dir()
|
||||
return [work] if work is not None else []
|
||||
|
||||
def _wrap_profile_width(self, sheet_name: str) -> int:
|
||||
def _wrap_profile_width(self, sheet_name: str, *, kind: str | None = None) -> int:
|
||||
work = self._wolf_json_work_dir()
|
||||
if work is not None:
|
||||
profile = wolf_ws.load_wrap_profile(work)
|
||||
if kind in ("map", "common"):
|
||||
geom = wolf_ws.get_format_geometry(
|
||||
profile, self._current_wrap_speaker_src()
|
||||
)
|
||||
if geom.get("width"):
|
||||
return int(geom["width"])
|
||||
return wolf_ws.get_sheet_width(
|
||||
profile,
|
||||
sheet_name,
|
||||
|
|
@ -2826,6 +2845,47 @@ class WolfWorkflowTab(QWidget):
|
|||
except (TypeError, ValueError):
|
||||
return 36
|
||||
|
||||
def _current_wrap_speaker_src(self) -> str:
|
||||
kw = self._current_wrap_speaker_kw()
|
||||
return str(kw.get("speaker_src") or "")
|
||||
|
||||
def _load_dialogue_geometry_into_spins(self):
|
||||
"""Load wrap settings for this line's ``speaker_src`` format bucket."""
|
||||
work = self._wolf_json_work_dir()
|
||||
width = None
|
||||
max_lines = None
|
||||
font = None
|
||||
speaker_src = self._current_wrap_speaker_src()
|
||||
if work is not None:
|
||||
geom = wolf_ws.get_format_geometry(
|
||||
wolf_ws.load_wrap_profile(work), speaker_src
|
||||
)
|
||||
width = geom.get("width")
|
||||
max_lines = geom.get("max_lines")
|
||||
font = geom.get("font")
|
||||
if width is None:
|
||||
try:
|
||||
width = int(self._setting("wrap_fix_width", 36) or 36)
|
||||
except (TypeError, ValueError):
|
||||
width = 36
|
||||
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"):
|
||||
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 _remember_sheet_width(
|
||||
self,
|
||||
sheet_name: str,
|
||||
|
|
@ -2833,16 +2893,30 @@ class WolfWorkflowTab(QWidget):
|
|||
json_file: str,
|
||||
*,
|
||||
max_lines: int | None = None,
|
||||
font: int | None = None,
|
||||
kind: str | None = None,
|
||||
speaker_src: str | None = None,
|
||||
):
|
||||
work = self._wolf_json_work_dir()
|
||||
if work is not None:
|
||||
wolf_ws.set_sheet_width(
|
||||
if work is None:
|
||||
return
|
||||
if kind in ("map", "common"):
|
||||
src = speaker_src if speaker_src is not None else self._current_wrap_speaker_src()
|
||||
wolf_ws.set_format_geometry(
|
||||
work,
|
||||
sheet_name,
|
||||
width,
|
||||
json_file=json_file,
|
||||
src,
|
||||
width=width,
|
||||
max_lines=max_lines,
|
||||
font=font,
|
||||
)
|
||||
return
|
||||
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)
|
||||
|
|
@ -3015,7 +3089,7 @@ class WolfWorkflowTab(QWidget):
|
|||
scope = (
|
||||
wolf_ws.scope_stats(doc, hit, width)
|
||||
if doc is not None
|
||||
else wolf_ws.ScopeStats(label=wolf_ws.scope_label(hit), total=0, overflow=0)
|
||||
else wolf_ws.ScopeStats(label=wolf_ws.scope_label(hit, doc), total=0, overflow=0)
|
||||
)
|
||||
overflow_note = (
|
||||
f" · {scope.overflow} of {scope.total} lines overflow at width {width}"
|
||||
|
|
@ -3050,11 +3124,32 @@ class WolfWorkflowTab(QWidget):
|
|||
f"{kind_lbl}: {hit.sheet_name} · {hit.json_file} · "
|
||||
f"row {hit.row} · {hit.field_name}{overflow_note}"
|
||||
)
|
||||
if hit.kind in ("map", "common"):
|
||||
fmt = wolf_ws.wrap_format_label(self._current_wrap_speaker_src())
|
||||
return (
|
||||
f"{kind_lbl}: {scope.label} · {hit.json_file} · "
|
||||
f"{hit.field_name}{overflow_note} · "
|
||||
f"shared wrap: {fmt}"
|
||||
)
|
||||
return (
|
||||
f"{kind_lbl}: {hit.sheet_name} · {hit.json_file} · "
|
||||
f"{hit.field_name}{overflow_note}"
|
||||
)
|
||||
|
||||
def _current_wrap_speaker_kw(self) -> dict[str, str]:
|
||||
"""speaker_src / speaker for nameplate-aware wrap preview."""
|
||||
line = None
|
||||
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 isinstance(line, dict):
|
||||
return {
|
||||
"speaker_src": str(line.get("speaker_src") or ""),
|
||||
"speaker": str(line.get("speaker") or ""),
|
||||
}
|
||||
return {"speaker_src": "", "speaker": ""}
|
||||
|
||||
def _update_wrap_preview(self):
|
||||
if not hasattr(self, "_wrap_preview"):
|
||||
return
|
||||
|
|
@ -3071,11 +3166,21 @@ class WolfWorkflowTab(QWidget):
|
|||
text = self._current_wrap_hit.text
|
||||
width = self._active_wrap_width()
|
||||
body_font = self._wrap_font_value() if self._wrap_mode() == "manual" else None
|
||||
plate_kw = self._current_wrap_speaker_kw()
|
||||
# Manual: preview scaled fonts. Relayout + max lines: preview fit-to-box.
|
||||
preview_src = text
|
||||
preview_width = width
|
||||
if body_font is not None and preview_src.strip():
|
||||
preview_src, _ = wolf_codes.scale_font_sizes(preview_src, body_font)
|
||||
prefix, nameplate, body = wolf_ws.split_nameplate_body(
|
||||
preview_src, **plate_kw
|
||||
)
|
||||
work = body if nameplate else preview_src
|
||||
work, _ = wolf_codes.scale_font_sizes(work, body_font)
|
||||
preview_src = (
|
||||
wolf_ws.join_nameplate_body(prefix, nameplate, work)
|
||||
if nameplate
|
||||
else work
|
||||
)
|
||||
elif self._wrap_mode() == "relayout" and width > 0:
|
||||
max_lines = self._wrap_max_lines_value()
|
||||
if max_lines > 0 and preview_src.strip():
|
||||
|
|
@ -3090,23 +3195,34 @@ class WolfWorkflowTab(QWidget):
|
|||
if isinstance(source, str) and wolf_codes.detect_font_sizes(source):
|
||||
box_font = wolf_codes.infer_base_font_size(source)
|
||||
preview_src, _ = wolf_ws.fit_text_to_box(
|
||||
preview_src, width, max_lines=max_lines, box_font=box_font
|
||||
preview_src,
|
||||
width,
|
||||
max_lines=max_lines,
|
||||
box_font=box_font,
|
||||
**plate_kw,
|
||||
)
|
||||
# Soft breaks are already final; don't reflow at the narrow cell width.
|
||||
_, nameplate, body = wolf_ws.split_nameplate_body(
|
||||
preview_src, **plate_kw
|
||||
)
|
||||
measure = body if nameplate else preview_src
|
||||
preview_width = max(
|
||||
width,
|
||||
dazedwrap.max_line_visible_length(preview_src),
|
||||
dazedwrap.max_line_visible_length(measure),
|
||||
)
|
||||
summary = wolf_ws.wrap_preview_summary(preview_src, preview_width)
|
||||
summary = wolf_ws.wrap_preview_summary(
|
||||
preview_src, preview_width, **plate_kw
|
||||
)
|
||||
preview_html = wolf_ws.format_wrap_preview_html(
|
||||
preview_src if self._wrap_mode() == "relayout" else text,
|
||||
preview_width,
|
||||
body_font=body_font,
|
||||
**plate_kw,
|
||||
)
|
||||
info = wolf_ws.wrap_preview_info(preview_src, preview_width)
|
||||
info = wolf_ws.wrap_preview_info(preview_src, preview_width, **plate_kw)
|
||||
if hasattr(self, "_wrap_preview_label"):
|
||||
if summary:
|
||||
soft = wolf_ws.count_soft_lines(preview_src)
|
||||
soft = wolf_ws.count_body_soft_lines(preview_src, **plate_kw)
|
||||
max_lines = self._wrap_max_lines_value()
|
||||
if (
|
||||
self._wrap_mode() == "relayout"
|
||||
|
|
@ -3137,7 +3253,10 @@ class WolfWorkflowTab(QWidget):
|
|||
over_lines = False
|
||||
if self._wrap_mode() == "relayout":
|
||||
max_lines = self._wrap_max_lines_value()
|
||||
over_lines = max_lines > 0 and wolf_ws.count_soft_lines(preview_src) > max_lines
|
||||
over_lines = (
|
||||
max_lines > 0
|
||||
and wolf_ws.count_body_soft_lines(preview_src, **plate_kw) > max_lines
|
||||
)
|
||||
border = "#ce9178" if info.get("needs_wrap") or over_lines else "#007acc"
|
||||
self._wrap_preview.setStyleSheet(
|
||||
"QTextEdit{background-color:#1e1e1e;color:#d4d4d4;border:1px solid #3c3c3c;"
|
||||
|
|
@ -3199,8 +3318,10 @@ class WolfWorkflowTab(QWidget):
|
|||
self._current_wrap_doc = doc
|
||||
if hit.kind == "names":
|
||||
self._load_names_geometry_into_spins(hit)
|
||||
elif hit.kind in ("map", "common"):
|
||||
self._load_dialogue_geometry_into_spins()
|
||||
else:
|
||||
w = self._wrap_profile_width(hit.sheet_name)
|
||||
w = self._wrap_profile_width(hit.sheet_name, kind=hit.kind)
|
||||
self._wrap_width_spin.blockSignals(True)
|
||||
self._wrap_width_spin.setValue(w)
|
||||
self._wrap_width_spin.blockSignals(False)
|
||||
|
|
@ -3271,6 +3392,8 @@ class WolfWorkflowTab(QWidget):
|
|||
self._current_wrap_hit.sheet_name,
|
||||
width,
|
||||
self._current_wrap_hit.json_file,
|
||||
font=font,
|
||||
kind=self._current_wrap_hit.kind,
|
||||
)
|
||||
path, doc, line = wolf_ws.load_hit_from_id(
|
||||
self._translated_and_files_dirs()[0],
|
||||
|
|
@ -3327,6 +3450,7 @@ class WolfWorkflowTab(QWidget):
|
|||
width,
|
||||
self._current_wrap_hit.json_file,
|
||||
max_lines=max_lines or None,
|
||||
kind=self._current_wrap_hit.kind,
|
||||
)
|
||||
path, doc, line = wolf_ws.load_hit_from_id(
|
||||
self._translated_and_files_dirs()[0],
|
||||
|
|
@ -3364,14 +3488,18 @@ class WolfWorkflowTab(QWidget):
|
|||
if self._wrap_mode() == "manual":
|
||||
width = self._active_wrap_width()
|
||||
font = self._wrap_font_value()
|
||||
tdir = self._translated_and_files_dirs()[0]
|
||||
count = wolf_ws.wrap_overflow_manual_in_scope(
|
||||
self._current_wrap_path,
|
||||
self._current_wrap_doc,
|
||||
hit,
|
||||
width,
|
||||
font=font,
|
||||
translated_dir=tdir if hit.kind in ("map", "common") else None,
|
||||
)
|
||||
self._remember_sheet_width(
|
||||
hit.sheet_name, width, hit.json_file, font=font, kind=hit.kind
|
||||
)
|
||||
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(
|
||||
|
|
@ -3380,7 +3508,7 @@ class WolfWorkflowTab(QWidget):
|
|||
width=width,
|
||||
font=font,
|
||||
)
|
||||
scope = wolf_ws.scope_label(hit)
|
||||
scope = wolf_ws.scope_label(hit, self._current_wrap_doc)
|
||||
font_note = (
|
||||
"fonts unchanged"
|
||||
if font is None
|
||||
|
|
@ -3417,17 +3545,23 @@ class WolfWorkflowTab(QWidget):
|
|||
)
|
||||
return
|
||||
max_lines = self._wrap_max_lines_value()
|
||||
tdir = self._translated_and_files_dirs()[0]
|
||||
count = wolf_ws.wrap_overflow_in_scope(
|
||||
self._current_wrap_path,
|
||||
self._current_wrap_doc,
|
||||
hit,
|
||||
width,
|
||||
max_lines=max_lines or None,
|
||||
translated_dir=tdir if hit.kind in ("map", "common") else None,
|
||||
)
|
||||
self._remember_sheet_width(
|
||||
hit.sheet_name, width, hit.json_file, max_lines=max_lines or None
|
||||
hit.sheet_name,
|
||||
width,
|
||||
hit.json_file,
|
||||
max_lines=max_lines or None,
|
||||
kind=hit.kind,
|
||||
)
|
||||
scope = wolf_ws.scope_label(hit)
|
||||
scope = wolf_ws.scope_label(hit, self._current_wrap_doc)
|
||||
if max_lines > 0:
|
||||
msg = (
|
||||
f"Relayout {count} line(s) in {scope} "
|
||||
|
|
@ -3553,17 +3687,15 @@ class WolfWorkflowTab(QWidget):
|
|||
return p
|
||||
return None
|
||||
|
||||
def _injectable_filenames(self) -> list[str]:
|
||||
"""JSON files in translated/ that the manifest knows how to inject."""
|
||||
def _injectable_filenames(self, source_dir: Path | None = None) -> list[str]:
|
||||
"""JSON files in *source_dir* (default translated/) the manifest can inject."""
|
||||
from util.wolfdawn import inject as wolf_inject
|
||||
|
||||
manifest = self._read_manifest()
|
||||
if not manifest:
|
||||
return []
|
||||
return wolf_inject.list_injectable(
|
||||
self._tool_root() / "translated",
|
||||
manifest.get("entries", []),
|
||||
)
|
||||
root = source_dir if source_dir is not None else self._tool_root() / "translated"
|
||||
return wolf_inject.list_injectable(root, manifest.get("entries", []))
|
||||
|
||||
def _sync_translated_json_to_wolf_json(
|
||||
self, json_name: str, game_json_dir: Path
|
||||
|
|
@ -3585,13 +3717,29 @@ class WolfWorkflowTab(QWidget):
|
|||
except Exception as exc:
|
||||
return False, str(exc)
|
||||
|
||||
def _inject(self, selected: set[str]):
|
||||
"""Inject the selected translated JSON files into live Data/."""
|
||||
def _inject(
|
||||
self,
|
||||
selected: set[str],
|
||||
*,
|
||||
source_dir: Path | None = None,
|
||||
sync_to_wolf_json: bool = True,
|
||||
):
|
||||
"""Inject the selected JSON files into live Data/.
|
||||
|
||||
*source_dir* defaults to ``translated/``. When injecting from the game's
|
||||
``wolf_json/``, pass that path and set *sync_to_wolf_json* False (the
|
||||
source already is wolf_json/).
|
||||
"""
|
||||
if not self._require_manifest():
|
||||
return
|
||||
manifest = self._read_manifest()
|
||||
en_punct = self._inject_flag_en_punct()
|
||||
game_json_dir = self._work_dir()
|
||||
inject_dir = (
|
||||
Path(source_dir)
|
||||
if source_dir is not None
|
||||
else self._tool_root() / "translated"
|
||||
)
|
||||
|
||||
def task(log, progress=None):
|
||||
from util.wolfdawn import inject as wolf_inject
|
||||
|
|
@ -3603,42 +3751,50 @@ class WolfWorkflowTab(QWidget):
|
|||
|
||||
self._ensure_originals(manifest, log, progress, quiet=True)
|
||||
|
||||
log(f"Inject source: {inject_dir}")
|
||||
# allow_code_drift=False: inject still auto-passes it for \\f shrink.
|
||||
report = wolf_inject.inject_selected(
|
||||
sorted(selected),
|
||||
manifest_entries=entries,
|
||||
data_dir=data_dir_path,
|
||||
originals_dir=originals_dir,
|
||||
translated_dir=self._tool_root() / "translated",
|
||||
translated_dir=inject_dir,
|
||||
game_root=game_root,
|
||||
allow_code_drift=False,
|
||||
en_punct=en_punct,
|
||||
log_fn=log,
|
||||
)
|
||||
|
||||
for json_name in sorted(selected):
|
||||
ok, err = self._sync_translated_json_to_wolf_json(
|
||||
json_name, game_json_dir
|
||||
)
|
||||
if not ok:
|
||||
report.sync_failures.append((json_name, err or "unknown error"))
|
||||
log(f" ✗ sync {json_name}: {err}")
|
||||
if sync_to_wolf_json and game_json_dir is not None:
|
||||
for json_name in sorted(selected):
|
||||
ok, err = self._sync_translated_json_to_wolf_json(
|
||||
json_name, game_json_dir
|
||||
)
|
||||
if not ok:
|
||||
report.sync_failures.append(
|
||||
(json_name, err or "unknown error")
|
||||
)
|
||||
log(f" ✗ sync {json_name}: {err}")
|
||||
|
||||
title, body = wolf_inject.format_report_dialog(report)
|
||||
inject_state["dialog"] = (title, body)
|
||||
dialog = wolf_inject.format_report_dialog(report)
|
||||
status = wolf_inject.format_report_status(report)
|
||||
inject_state["dialog"] = dialog
|
||||
inject_state["report"] = report
|
||||
inject_state["selected"] = set(selected)
|
||||
return report.ok, title
|
||||
log(status)
|
||||
return report.ok, status
|
||||
|
||||
def _after_inject(ok: bool, msg: str):
|
||||
title, body = inject_state.get("dialog", ("Inject", msg))
|
||||
dialog = inject_state.get("dialog")
|
||||
report = inject_state.get("report")
|
||||
if report and (report.failed or report.sync_failures):
|
||||
if dialog:
|
||||
title, body = dialog
|
||||
QMessageBox.warning(self, title, body)
|
||||
elif report and report.succeeded:
|
||||
QMessageBox.information(self, title, body)
|
||||
elif body:
|
||||
QMessageBox.information(self, title, body)
|
||||
elif report is not None and not report.succeeded and not (
|
||||
report.failed or report.sync_failures
|
||||
):
|
||||
QMessageBox.information(self, "Inject", msg or "Nothing was injected.")
|
||||
# Full success: no popup (per-file detail is already in the log).
|
||||
|
||||
inject_state: dict = {}
|
||||
self._run_task(task, on_done=_after_inject)
|
||||
|
|
@ -3981,6 +4137,35 @@ class WolfWorkflowTab(QWidget):
|
|||
return
|
||||
self._inject(set(files))
|
||||
|
||||
def _inject_all_from_wolf_json(self):
|
||||
"""Inject every injectable JSON from the game's wolf_json/ folder."""
|
||||
if not self._require_manifest():
|
||||
return
|
||||
if not self._game_root:
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Inject from wolf_json",
|
||||
"No game folder selected. Detect a project in Step 0 first.",
|
||||
)
|
||||
return
|
||||
work = self._work_dir()
|
||||
if not work.is_dir():
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Inject from wolf_json",
|
||||
f"No wolf_json/ folder at {work}.",
|
||||
)
|
||||
return
|
||||
files = self._injectable_filenames(work)
|
||||
if not files:
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Nothing to inject",
|
||||
f"No injectable JSON found in {work}.",
|
||||
)
|
||||
return
|
||||
self._inject(set(files), source_dir=work, sync_to_wolf_json=False)
|
||||
|
||||
def _layout_restore_all(self):
|
||||
"""Re-run wolf layout-restore on every injectable translated JSON."""
|
||||
files = self._injectable_filenames()
|
||||
|
|
|
|||
|
|
@ -35,9 +35,11 @@ When unset, all database lines are translated.
|
|||
|
||||
Speakers: WolfDawn tags each line with ``speaker`` / ``speaker_src``. For the
|
||||
first-line formats (``literal_line1`` / ``literal_line1_lowconf``) the speaker
|
||||
name is baked into line 1 of ``source``. Those lines are reshaped into the shared
|
||||
``[Speaker]: line`` convention (which the prompt already translates) and restored
|
||||
to WOLF's native ``Speaker\nline`` layout on write-back. See ``util.speakers``.
|
||||
name is baked into line 1 of ``source``. Those names are resolved to English first
|
||||
(vocab hit or a cached short-string ``getSpeaker`` call, same pattern as the other
|
||||
engines), then the line is reshaped into ``[Speaker]: line`` with the English tag
|
||||
and restored to WOLF's native ``Speaker\nline`` layout on write-back using that
|
||||
resolved name (not whatever the dialogue model echoed). See ``util.speakers``.
|
||||
Detection is WolfDawn's, so the reliable nameplate (``literal_line1``) is always
|
||||
reshaped; only the low-confidence guess (``literal_line1_lowconf``) is gated by a
|
||||
per-game, AI-recommended setting from the workflow.
|
||||
|
|
@ -62,6 +64,7 @@ from util.translation import (
|
|||
translateAI as sharedtranslateAI,
|
||||
getPricingConfig,
|
||||
calculateCost,
|
||||
parseVocabWithCategories,
|
||||
)
|
||||
from util import speakers as wolf_speakers
|
||||
from util import vocab as wolf_vocab
|
||||
|
|
@ -98,11 +101,15 @@ _WOLF_CODE_RE = re.compile(
|
|||
r"\\(?:r\[[^\]]*\]|c(?:self)?\[[^\]]*\]|[A-Za-z]+\[[^\]]*\]|[A-Za-z])"
|
||||
)
|
||||
|
||||
# Speaker handling: for first-line-speaker formats, reshape the line into the
|
||||
# shared "[Speaker]: line" transport before translating and restore WOLF's
|
||||
# native "Speaker\nline" layout on write-back. Which formats are reshaped is
|
||||
# configurable from the workflow (data/wolf_speakers.json).
|
||||
# Speaker handling: for first-line-speaker formats, resolve the nameplate to
|
||||
# English (vocab / cached short-string translation), reshape into the shared
|
||||
# "[Speaker]: line" transport, then restore WOLF's native "Speaker\nline"
|
||||
# layout on write-back. Which formats are reshaped is configurable from the
|
||||
# workflow (data/wolf_speakers.json).
|
||||
SPEAKER_CONFIG = wolf_speakers.load_config()
|
||||
NAMESLIST = [] # [[jp, en], ...] session glossary of resolved speaker names
|
||||
_speakerCache = {}
|
||||
_speakerCacheLock = threading.Lock()
|
||||
|
||||
# Pricing / batching from the configured model
|
||||
PRICING_CONFIG = getPricingConfig(MODEL)
|
||||
|
|
@ -250,9 +257,10 @@ def collectEntries(data):
|
|||
def _text_check_body(text: str, speaker_src: str = "") -> str:
|
||||
"""Body text used for skip-translated checks (nameplates / codes ignored).
|
||||
|
||||
Already-translated WOLF dialogue often keeps a Japanese speaker name on
|
||||
Already-translated WOLF dialogue may still have a Japanese speaker name on
|
||||
line 1 (``司祭\\nSorry to keep you...``) and may embed Japanese only inside
|
||||
control codes like ``\\r[我,わ]``. Those lines are finished translations.
|
||||
control codes like ``\\r[我,わ]``. Body-only checks treat those as finished
|
||||
dialogue; ``_maybe_fix_nameplate`` then swaps the nameplate to English.
|
||||
"""
|
||||
if not isinstance(text, str):
|
||||
return ""
|
||||
|
|
@ -285,6 +293,149 @@ def _text_still_needs_translation(entry) -> bool:
|
|||
return bool(re.search(LANGREGEX, _text_check_body(txt, entry.get("speaker_src", ""))))
|
||||
|
||||
|
||||
def _vocab_speaker_lookup(speaker: str) -> str | None:
|
||||
"""Return an English gloss for *speaker* from vocab.txt, or None."""
|
||||
if not speaker or not VOCAB:
|
||||
return None
|
||||
try:
|
||||
for item in parseVocabWithCategories(VOCAB):
|
||||
# parseVocabWithCategories yields ((jp, en), line, category) or (term, ...)
|
||||
term = item[0]
|
||||
if not isinstance(term, tuple) or len(term) != 2:
|
||||
continue
|
||||
jp, en = term
|
||||
if jp == speaker and isinstance(en, str) and en.strip():
|
||||
# Prefer a gloss that is not still Japanese.
|
||||
if not re.search(LANGREGEX, en):
|
||||
return en.strip()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_speaker_name(translated: str) -> str:
|
||||
"""Title-case / tidy a short NPC-name translation (mirrors other engines)."""
|
||||
out = translated.strip().title().replace("'S", "'s").replace("Speaker: ", "")
|
||||
return re.sub(
|
||||
r"(\d)(St|Nd|Rd|Th)\b",
|
||||
lambda m: m.group(1) + m.group(2).lower(),
|
||||
out,
|
||||
)
|
||||
|
||||
|
||||
def getSpeaker(speaker: str):
|
||||
"""Resolve a WolfDawn nameplate to English with caching.
|
||||
|
||||
Order: session cache / NAMESLIST -> vocab.txt -> live short-string
|
||||
translation (same prompt as the other engines). Single-string calls go
|
||||
through the live API even during batch collect, so later dialogue batches
|
||||
embed a stable English tag.
|
||||
"""
|
||||
global NAMESLIST
|
||||
if not speaker:
|
||||
return ["", [0, 0]]
|
||||
# Already English / no target-language characters - keep as-is.
|
||||
if not re.search(LANGREGEX, speaker):
|
||||
return [speaker, [0, 0]]
|
||||
|
||||
with _speakerCacheLock:
|
||||
cached = _speakerCache.get(speaker)
|
||||
if cached is not None:
|
||||
return [cached, [0, 0]]
|
||||
for jp, en in NAMESLIST:
|
||||
if jp == speaker and en:
|
||||
_speakerCache[speaker] = en
|
||||
return [en, [0, 0]]
|
||||
|
||||
vocab_hit = _vocab_speaker_lookup(speaker)
|
||||
if vocab_hit is not None:
|
||||
with _speakerCacheLock:
|
||||
_speakerCache[speaker] = vocab_hit
|
||||
if not any(jp == speaker for jp, _en in NAMESLIST):
|
||||
NAMESLIST.append([speaker, vocab_hit])
|
||||
return [vocab_hit, [0, 0]]
|
||||
|
||||
# Estimate / preflight: do not spend tokens; leave Japanese for a real run.
|
||||
if ESTIMATE:
|
||||
return [speaker, [0, 0]]
|
||||
|
||||
response = translateAI(
|
||||
speaker,
|
||||
"Reply with the " + LANGUAGE + " translation of the NPC name.",
|
||||
)
|
||||
translated = _normalize_speaker_name(
|
||||
response[0] if isinstance(response[0], str) else str(response[0])
|
||||
)
|
||||
|
||||
# Retry once if the model returned something with no Latin / ? characters.
|
||||
if re.search(r"([a-zA-Z??])", translated) is None:
|
||||
response = translateAI(
|
||||
speaker,
|
||||
"Reply with the " + LANGUAGE + " translation of the NPC name.",
|
||||
)
|
||||
translated = _normalize_speaker_name(
|
||||
response[0] if isinstance(response[0], str) else str(response[0])
|
||||
)
|
||||
|
||||
# Still Japanese after retries - keep original rather than inventing junk.
|
||||
if re.search(LANGREGEX, translated) and not re.search(r"[A-Za-z]", translated):
|
||||
translated = speaker
|
||||
|
||||
with _speakerCacheLock:
|
||||
if speaker not in _speakerCache:
|
||||
_speakerCache[speaker] = translated
|
||||
if not any(jp == speaker for jp, _en in NAMESLIST):
|
||||
NAMESLIST.append([speaker, translated])
|
||||
return [translated, response[1]]
|
||||
|
||||
|
||||
def _resolve_nameplate(speaker: str, total_tokens: list) -> str:
|
||||
"""Translate *speaker* and accumulate token cost into *total_tokens*."""
|
||||
en, tokens = getSpeaker(speaker)
|
||||
total_tokens[0] += tokens[0]
|
||||
total_tokens[1] += tokens[1]
|
||||
return en
|
||||
|
||||
|
||||
def _body_after_dropped_tag(text: str, original_speaker: str) -> str:
|
||||
"""When the model drops ``[Speaker]:``, peel a leading JP nameplate if present."""
|
||||
if not isinstance(text, str) or "\n" not in text:
|
||||
return text
|
||||
line1, rest = text.split("\n", 1)
|
||||
if not line1.strip():
|
||||
return text
|
||||
if original_speaker and line1 == original_speaker:
|
||||
return rest
|
||||
# Short Japanese first line - treat as an echoed nameplate.
|
||||
if 0 < len(line1.strip()) <= 20 and re.search(LANGREGEX, line1):
|
||||
return rest
|
||||
return text
|
||||
|
||||
|
||||
def _maybe_fix_nameplate(entry, total_tokens: list) -> None:
|
||||
"""Swap a Japanese first-line nameplate for English on an otherwise-done line."""
|
||||
if ESTIMATE or _batch_phase() == "collect":
|
||||
return
|
||||
speaker_src = entry.get("speaker_src", "")
|
||||
if not wolf_speakers.is_firstline_enabled(speaker_src, SPEAKER_CONFIG):
|
||||
return
|
||||
txt = entry.get("text")
|
||||
if not isinstance(txt, str) or not txt.strip():
|
||||
return
|
||||
# Only touch finished dialogue (body already English / skip-translated).
|
||||
if _text_still_needs_translation(entry):
|
||||
return
|
||||
split = wolf_speakers.split_source(txt, speaker_src, SPEAKER_CONFIG)
|
||||
if split is None:
|
||||
return
|
||||
prefix, speaker, body = split
|
||||
if not re.search(LANGREGEX, speaker):
|
||||
return
|
||||
speaker_en = _resolve_nameplate(speaker, total_tokens)
|
||||
if speaker_en and speaker_en != speaker:
|
||||
entry["text"] = wolf_speakers.restore_source(prefix, speaker_en, body)
|
||||
|
||||
|
||||
def parseDocument(data, filename):
|
||||
"""Translate every translatable leaf entry and return [data, tokens, error]."""
|
||||
global PBAR
|
||||
|
|
@ -306,6 +457,16 @@ def parseDocument(data, filename):
|
|||
return False
|
||||
return True
|
||||
|
||||
# Fix Japanese nameplates on lines whose dialogue body is already done, so
|
||||
# resume / re-wrap runs do not leave ``セルリア\\nPlease hold on...`` behind.
|
||||
if IGNORETLTEXT and not ESTIMATE and _batch_phase() != "collect":
|
||||
for entry in entries:
|
||||
if _translatable(entry):
|
||||
continue
|
||||
src = entry.get("source")
|
||||
if isinstance(src, str) and re.search(LANGREGEX, src):
|
||||
_maybe_fix_nameplate(entry, totalTokens)
|
||||
|
||||
# Only translate entries that still need work; names.json also requires a
|
||||
# safe badge. Untouched leaves keep ``text == source`` so WolfDawn
|
||||
# treats them as no-ops on inject.
|
||||
|
|
@ -316,10 +477,10 @@ def parseDocument(data, filename):
|
|||
PBAR = pbar
|
||||
if translatable:
|
||||
# Reshape first-line-speaker lines into the shared "[Speaker]: line"
|
||||
# transport format. plans[i] carries what is needed to restore each
|
||||
# entry after translation.
|
||||
# transport format with a pre-resolved English nameplate. plans[i]
|
||||
# carries what is needed to restore each entry after translation.
|
||||
sources = []
|
||||
plans = [] # (entry, prefix, has_speaker, is_firstline, code_map)
|
||||
plans = [] # (entry, prefix, has_speaker, is_firstline, code_map, speaker_en)
|
||||
for entry in translatable:
|
||||
src = entry["source"]
|
||||
protected_src, code_map = wolf_codes.protect_wolf_codes(src)
|
||||
|
|
@ -329,11 +490,12 @@ def parseDocument(data, filename):
|
|||
)
|
||||
if split is not None:
|
||||
prefix, speaker, body = split
|
||||
sources.append(wolf_speakers.to_prefixed(speaker, body))
|
||||
plans.append((entry, prefix, True, is_firstline, code_map))
|
||||
speaker_en = _resolve_nameplate(speaker, totalTokens)
|
||||
sources.append(wolf_speakers.to_prefixed(speaker_en, body))
|
||||
plans.append((entry, prefix, True, is_firstline, code_map, speaker_en))
|
||||
else:
|
||||
sources.append(protected_src)
|
||||
plans.append((entry, "", False, is_firstline, code_map))
|
||||
plans.append((entry, "", False, is_firstline, code_map, ""))
|
||||
|
||||
try:
|
||||
response = translateAI(sources, [])
|
||||
|
|
@ -354,7 +516,7 @@ def parseDocument(data, filename):
|
|||
and isinstance(translated, list)
|
||||
and len(translated) == len(plans)
|
||||
):
|
||||
for (entry, prefix, has_speaker, is_firstline, code_map), text, src in zip(
|
||||
for (entry, prefix, has_speaker, is_firstline, code_map, speaker_en), text, src in zip(
|
||||
plans, translated, sources
|
||||
):
|
||||
if not isinstance(text, str):
|
||||
|
|
@ -364,13 +526,27 @@ def parseDocument(data, filename):
|
|||
continue
|
||||
text = wolf_codes.restore_wolf_code_placeholders(text, code_map)
|
||||
if has_speaker:
|
||||
speaker_en, body_en = wolf_speakers.parse_prefixed(text)
|
||||
if speaker_en is not None:
|
||||
entry["text"] = wolf_speakers.restore_source(
|
||||
prefix, speaker_en, body_en
|
||||
model_speaker, body_en = wolf_speakers.parse_prefixed(text)
|
||||
if model_speaker is None:
|
||||
body_en = _body_after_dropped_tag(
|
||||
text, entry.get("speaker") or ""
|
||||
)
|
||||
else:
|
||||
entry["text"] = prefix + text
|
||||
# Always prefer the pre-resolved English nameplate. Only
|
||||
# take the model's tag when ours is still Japanese and
|
||||
# the model produced a Latin gloss.
|
||||
final_speaker = speaker_en or model_speaker or (
|
||||
entry.get("speaker") or ""
|
||||
)
|
||||
if (
|
||||
re.search(LANGREGEX, final_speaker)
|
||||
and isinstance(model_speaker, str)
|
||||
and model_speaker
|
||||
and not re.search(LANGREGEX, model_speaker)
|
||||
):
|
||||
final_speaker = model_speaker
|
||||
entry["text"] = wolf_speakers.restore_source(
|
||||
prefix, final_speaker, body_en
|
||||
)
|
||||
else:
|
||||
entry["text"] = text
|
||||
wolf_codes.repair_entry(entry)
|
||||
|
|
|
|||
|
|
@ -78,6 +78,25 @@ class WolfCodesRepairTests(unittest.TestCase):
|
|||
fixed = wolf_codes.rebuild_text_preserving_source_codes(source, text)
|
||||
self.assertEqual(fixed, text)
|
||||
|
||||
def test_rebuild_keeps_nameplate_body_font(self):
|
||||
"""Spoken ``Name\\n\\f[N]body`` must not become ``\\f[N]Name\\n`` on inject repair."""
|
||||
source = (
|
||||
"市民\n俺達の税金で好き放題しやがって……、\n"
|
||||
"王子が代行として実権を握ってからは\n"
|
||||
"毎晩毎晩パーティー三昧だって話じゃねえか……。"
|
||||
)
|
||||
text = (
|
||||
"Citizen\n\\f[21]Spending our tax money however he\n"
|
||||
"pleases...... ever since the Prince\n"
|
||||
"took over as regent, it's been\n"
|
||||
"nothing but parties every single\n"
|
||||
"night, I tell you......"
|
||||
)
|
||||
fixed = wolf_codes.rebuild_text_preserving_source_codes(source, text)
|
||||
self.assertEqual(fixed, text)
|
||||
self.assertIn("Spending our tax money", fixed)
|
||||
self.assertFalse(fixed.rstrip().endswith("Citizen"))
|
||||
|
||||
def test_document_has_font_size_drift_for_db(self):
|
||||
doc = {
|
||||
"kind": "db",
|
||||
|
|
|
|||
|
|
@ -244,7 +244,17 @@ class InjectOrderTests(unittest.TestCase):
|
|||
|
||||
|
||||
class ReportDialogTests(unittest.TestCase):
|
||||
def test_failure_dialog_lists_every_problem(self):
|
||||
def test_success_has_no_dialog(self):
|
||||
report = wi.InjectReport(
|
||||
files=[
|
||||
wi.FileInjectResult("a.json", True, "applied 3 line(s)"),
|
||||
wi.FileInjectResult("b.json", True, "no changes needed"),
|
||||
],
|
||||
)
|
||||
self.assertIsNone(wi.format_report_dialog(report))
|
||||
self.assertEqual(wi.format_report_status(report), "Inject complete: 2 file(s).")
|
||||
|
||||
def test_failure_dialog_lists_only_problems(self):
|
||||
report = wi.InjectReport(
|
||||
files=[
|
||||
wi.FileInjectResult("a.json", True, "applied 3 line(s)"),
|
||||
|
|
@ -252,11 +262,18 @@ class ReportDialogTests(unittest.TestCase):
|
|||
],
|
||||
sync_failures=[("c.json", "permission denied")],
|
||||
)
|
||||
title, body = wi.format_report_dialog(report)
|
||||
dialog = wi.format_report_dialog(report)
|
||||
self.assertIsNotNone(dialog)
|
||||
title, body = dialog
|
||||
self.assertIn("errors", title)
|
||||
self.assertIn("✗ b.json", body)
|
||||
self.assertIn("✗ sync c.json", body)
|
||||
self.assertIn("✓ a.json", body)
|
||||
self.assertNotIn("✓ a.json", body)
|
||||
self.assertIn("1 file(s) succeeded", body)
|
||||
self.assertEqual(
|
||||
wi.format_report_status(report),
|
||||
"Inject: 1 ok, 2 failed (see dialog).",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -235,50 +235,96 @@ class TestManualFontAndWrap(unittest.TestCase):
|
|||
max_line_len=80,
|
||||
)
|
||||
changed = ws.wrap_overflow_manual_in_scope(path, doc, hit, 36, font=14)
|
||||
self.assertEqual(changed, 1)
|
||||
# Both 噂 entries get body font (short "OK" included); other category skipped.
|
||||
self.assertEqual(changed, 2)
|
||||
saved = json.loads(path.read_text(encoding="utf-8"))
|
||||
self.assertIn(r"\f[14]", saved["names"][0]["text"])
|
||||
self.assertEqual(saved["names"][1]["text"], "OK")
|
||||
self.assertEqual(saved["names"][1]["text"], r"\f[14]OK")
|
||||
self.assertEqual(saved["names"][2]["text"], "x" * 80)
|
||||
|
||||
|
||||
class TestEventScopeWrap(unittest.TestCase):
|
||||
def test_wrap_all_in_map_file(self):
|
||||
def test_wrap_all_in_spoken_format_across_scenes(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "Map001.mps.json"
|
||||
root = Path(tmp)
|
||||
long_a = (
|
||||
"Celria\n"
|
||||
"This is a very long line of dialogue that "
|
||||
"should wrap at thirty four visible chars."
|
||||
)
|
||||
long_b = (
|
||||
"Citizen\n"
|
||||
"Another very long line of dialogue that also "
|
||||
"should wrap at thirty four visible chars."
|
||||
)
|
||||
ui_line = (
|
||||
"\\f[24]Rosa profile card that is also long enough "
|
||||
"to wrap at thirty four visible chars easily."
|
||||
)
|
||||
path = root / "CommonEvent.dat.json"
|
||||
doc = {
|
||||
"kind": "map",
|
||||
"file": "Map001.mps",
|
||||
"kind": "common",
|
||||
"file": "CommonEvent.dat",
|
||||
"scenes": [
|
||||
{
|
||||
"event": 1,
|
||||
"name": "Intro",
|
||||
"lines": [
|
||||
{
|
||||
"text": (
|
||||
"This is a very long line of dialogue that "
|
||||
"should wrap at thirty four visible chars."
|
||||
),
|
||||
"speaker": "セルリア",
|
||||
"speaker_src": "literal_line1_lowconf",
|
||||
"text": long_a,
|
||||
},
|
||||
{"text": "Short line."},
|
||||
],
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": 2,
|
||||
"name": "Town",
|
||||
"lines": [
|
||||
{
|
||||
"speaker": "市民",
|
||||
"speaker_src": "literal_line1_lowconf",
|
||||
"text": long_b,
|
||||
},
|
||||
{
|
||||
"speaker": "UI",
|
||||
"speaker_src": "ui",
|
||||
"text": ui_line,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
path.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8")
|
||||
hit = ws.WrapHit(
|
||||
json_file="Map001.mps.json",
|
||||
kind="map",
|
||||
sheet_name="Map001.mps",
|
||||
json_file="CommonEvent.dat.json",
|
||||
kind="common",
|
||||
sheet_name="CommonEvent",
|
||||
row=1,
|
||||
field_name="scene 0 line 0",
|
||||
field_name="セルリア",
|
||||
text="very long line",
|
||||
max_line_len=80,
|
||||
scene_index=0,
|
||||
line_index=0,
|
||||
map_file="Map001.mps",
|
||||
)
|
||||
self.assertEqual(ws.scope_stats(doc, hit, 34).overflow, 1)
|
||||
self.assertEqual(ws.wrap_overflow_in_scope(path, doc, hit, 34), 1)
|
||||
stats = ws.scope_stats(doc, hit, 34)
|
||||
self.assertEqual(stats.total, 2)
|
||||
self.assertEqual(stats.overflow, 2)
|
||||
self.assertIn("spoken", stats.label)
|
||||
changed = ws.wrap_overflow_in_scope(
|
||||
path, doc, hit, 34, translated_dir=root
|
||||
)
|
||||
self.assertEqual(changed, 2)
|
||||
saved = json.loads(path.read_text(encoding="utf-8"))
|
||||
a = saved["scenes"][0]["lines"][0]["text"]
|
||||
b = saved["scenes"][1]["lines"][0]["text"]
|
||||
ui = saved["scenes"][1]["lines"][1]["text"]
|
||||
self.assertTrue(a.startswith("Celria\n"))
|
||||
self.assertTrue(b.startswith("Citizen\n"))
|
||||
self.assertNotEqual(a, long_a)
|
||||
self.assertNotEqual(b, long_b)
|
||||
# UI format must stay untouched.
|
||||
self.assertEqual(ui, ui_line)
|
||||
|
||||
|
||||
class TestFitTextToBox(unittest.TestCase):
|
||||
|
|
@ -373,5 +419,117 @@ class TestFitTextToBox(unittest.TestCase):
|
|||
)
|
||||
|
||||
|
||||
class TestNameplateWrap(unittest.TestCase):
|
||||
TEXT = (
|
||||
"Celria\n"
|
||||
"The number of cases involving 'illegal Magic Drugs' and 'monster attacks' "
|
||||
"seems strangely high\n"
|
||||
"for a town of this size."
|
||||
)
|
||||
|
||||
def test_preserves_speaker_newline(self):
|
||||
wrapped = ws.wrap_preserving_nameplate(
|
||||
self.TEXT,
|
||||
50,
|
||||
speaker_src="literal_line1_lowconf",
|
||||
speaker="セルリア",
|
||||
)
|
||||
self.assertTrue(wrapped.startswith("Celria\n"))
|
||||
self.assertNotIn("Celria The", wrapped)
|
||||
|
||||
def test_never_wipes_body(self):
|
||||
"""Regression: group wrap must not leave ``\\f[N]Name\\n`` with empty body."""
|
||||
text = (
|
||||
"Citizen\n"
|
||||
"Spending our tax money however he pleases...... ever since\n"
|
||||
"the Prince took over as regent, it's been nothing but\n"
|
||||
"parties every single night, I tell you......"
|
||||
)
|
||||
kw = dict(speaker_src="literal_line1_lowconf", speaker="市民")
|
||||
for width, max_lines, font in (
|
||||
(35, 2, None),
|
||||
(40, 1, None),
|
||||
(21, 2, 21),
|
||||
(50, 0, 18),
|
||||
):
|
||||
if font is not None:
|
||||
out, _ = ws.apply_manual_font_and_wrap(
|
||||
text, width, font=font, **kw
|
||||
)
|
||||
else:
|
||||
out, _ = ws.fit_text_to_box(
|
||||
text, width, max_lines=max_lines or None, **kw
|
||||
)
|
||||
self.assertIn("\n", out, msg=repr(out))
|
||||
body = out.split("\n", 1)[1]
|
||||
self.assertTrue(body.strip(), msg=f"wiped at w={width}: {out!r}")
|
||||
self.assertIn("Spending", body)
|
||||
self.assertIn("parties", body)
|
||||
|
||||
def test_fit_max_lines_counts_body_only(self):
|
||||
fitted, changed = ws.fit_text_to_box(
|
||||
self.TEXT,
|
||||
40,
|
||||
max_lines=2,
|
||||
speaker_src="literal_line1_lowconf",
|
||||
speaker="セルリア",
|
||||
)
|
||||
self.assertTrue(changed)
|
||||
self.assertTrue(fitted.startswith("Celria\n"))
|
||||
body_lines = ws.count_body_soft_lines(
|
||||
fitted,
|
||||
speaker_src="literal_line1_lowconf",
|
||||
speaker="セルリア",
|
||||
)
|
||||
self.assertLessEqual(body_lines, 2)
|
||||
|
||||
def test_manual_group_applies_font_to_short_spoken_lines(self):
|
||||
"""Group wrap with body font must not skip short lines that already fit."""
|
||||
short = "Celria\nJust now...... what did you say?"
|
||||
line = {
|
||||
"text": short,
|
||||
"speaker": "セルリア",
|
||||
"speaker_src": "literal_line1_lowconf",
|
||||
}
|
||||
self.assertTrue(ws._apply_manual_to_line_dict(line, 70, font=21))
|
||||
self.assertEqual(
|
||||
line["text"],
|
||||
"Celria\n\\f[21]Just now...... what did you say?",
|
||||
)
|
||||
# Font 0 / None: still skip short plain lines (width-only group wrap).
|
||||
line2 = {
|
||||
"text": short,
|
||||
"speaker": "セルリア",
|
||||
"speaker_src": "literal_line1_lowconf",
|
||||
}
|
||||
self.assertFalse(ws._apply_manual_to_line_dict(line2, 70, font=None))
|
||||
self.assertEqual(line2["text"], short)
|
||||
|
||||
def test_dialogue_geometry_roundtrip(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
work = Path(tmp)
|
||||
ws.set_format_geometry(
|
||||
work, "literal_line1_lowconf", width=48, max_lines=3, font=16
|
||||
)
|
||||
ws.set_format_geometry(work, "ui", width=28, max_lines=8, font=24)
|
||||
spoken = ws.get_format_geometry(
|
||||
ws.load_wrap_profile(work), "literal_line1"
|
||||
)
|
||||
ui = ws.get_format_geometry(ws.load_wrap_profile(work), "ui")
|
||||
self.assertEqual(spoken["width"], 48)
|
||||
self.assertEqual(spoken["max_lines"], 3)
|
||||
self.assertEqual(ui["width"], 28)
|
||||
self.assertEqual(ui["font"], 24)
|
||||
# Spoken aliases share one bucket.
|
||||
self.assertEqual(
|
||||
ws.wrap_format_key("literal_line1"),
|
||||
ws.wrap_format_key("literal_line1_lowconf"),
|
||||
)
|
||||
self.assertNotEqual(
|
||||
ws.wrap_format_key("literal_line1"),
|
||||
ws.wrap_format_key("ui"),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -52,12 +52,18 @@ class _WolfTranslateHarness:
|
|||
orig_t = wd.translateAI
|
||||
orig_estimate = wd.ESTIMATE
|
||||
orig_ignore = wd.IGNORETLTEXT
|
||||
orig_vocab = wd.VOCAB
|
||||
orig_update = wd.wolf_vocab.update_vocab_section
|
||||
orig_labels = wd.wolf_names.derive_db_labels
|
||||
orig_db_filter = wd.wolf_db.load_db_filter_config
|
||||
orig_names = list(wd.NAMESLIST)
|
||||
orig_cache = dict(wd._speakerCache)
|
||||
wd.translateAI = translate
|
||||
wd.ESTIMATE = estimate
|
||||
wd.IGNORETLTEXT = ignore_tl_text
|
||||
wd.VOCAB = "" # isolate speaker lookup from the real glossary
|
||||
wd.NAMESLIST = []
|
||||
wd._speakerCache.clear()
|
||||
# Never touch the real glossary / DB files during tests.
|
||||
wd.wolf_vocab.update_vocab_section = capture_vocab
|
||||
wd.wolf_names.derive_db_labels = lambda _p: {}
|
||||
|
|
@ -70,9 +76,13 @@ class _WolfTranslateHarness:
|
|||
wd.translateAI = orig_t
|
||||
wd.ESTIMATE = orig_estimate
|
||||
wd.IGNORETLTEXT = orig_ignore
|
||||
wd.VOCAB = orig_vocab
|
||||
wd.wolf_vocab.update_vocab_section = orig_update
|
||||
wd.wolf_names.derive_db_labels = orig_labels
|
||||
wd.wolf_db.load_db_filter_config = orig_db_filter
|
||||
wd.NAMESLIST = orig_names
|
||||
wd._speakerCache.clear()
|
||||
wd._speakerCache.update(orig_cache)
|
||||
|
||||
|
||||
MAP_DOC = {
|
||||
|
|
@ -453,9 +463,12 @@ class TestTranslationWriteback(unittest.TestCase):
|
|||
(data, _t, err), captured = _WolfTranslateHarness().run(doc, "nameplate.mps.json")
|
||||
self.assertIsNone(err)
|
||||
lines = data["scenes"][0]["lines"]
|
||||
self.assertEqual(lines[0]["text"], "司祭\nSorry to keep you all waiting......")
|
||||
# Dialogue body is skipped; Japanese nameplate is fixed via getSpeaker
|
||||
# (mock returns EN_*, then title-cased like the other engines).
|
||||
self.assertEqual(lines[0]["text"], "En_司祭\nSorry to keep you all waiting......")
|
||||
self.assertEqual(lines[1]["text"], "EN_まだだ")
|
||||
self.assertEqual(captured, [["まだだ"]])
|
||||
# Short-string speaker resolve, then the remaining dialogue batch.
|
||||
self.assertEqual(captured, ["司祭", ["まだだ"]])
|
||||
|
||||
def test_ignore_tl_text_false_retranslates(self):
|
||||
doc = {
|
||||
|
|
@ -627,15 +640,23 @@ class _SpeakerHarness:
|
|||
self.captured.append(copy.deepcopy(text))
|
||||
return _mock_translate_speaker(text, history, history_ctx)
|
||||
|
||||
orig = (wd.translateAI, wd.ESTIMATE, wd.SPEAKER_CONFIG)
|
||||
orig = (wd.translateAI, wd.ESTIMATE, wd.SPEAKER_CONFIG, wd.VOCAB)
|
||||
orig_names = list(wd.NAMESLIST)
|
||||
orig_cache = dict(wd._speakerCache)
|
||||
wd.translateAI = translate
|
||||
wd.ESTIMATE = False
|
||||
wd.SPEAKER_CONFIG = self.config
|
||||
wd.VOCAB = ""
|
||||
wd.NAMESLIST = []
|
||||
wd._speakerCache.clear()
|
||||
try:
|
||||
result = wd.parseDocument(copy.deepcopy(data), filename)
|
||||
return result, self.captured
|
||||
finally:
|
||||
(wd.translateAI, wd.ESTIMATE, wd.SPEAKER_CONFIG) = orig
|
||||
(wd.translateAI, wd.ESTIMATE, wd.SPEAKER_CONFIG, wd.VOCAB) = orig
|
||||
wd.NAMESLIST = orig_names
|
||||
wd._speakerCache.clear()
|
||||
wd._speakerCache.update(orig_cache)
|
||||
|
||||
|
||||
class TestSpeakerReshaping(unittest.TestCase):
|
||||
|
|
@ -643,15 +664,18 @@ class TestSpeakerReshaping(unittest.TestCase):
|
|||
cfg = {"literal_line1": True, "literal_line1_lowconf": True}
|
||||
(data, _t, err), captured = _SpeakerHarness(cfg).run(SPEAKER_MAP_DOC)
|
||||
self.assertIsNone(err)
|
||||
# Model saw the [Speaker]: transport for the two nameplate lines.
|
||||
# Speakers are resolved first (live short-string), then the batch uses
|
||||
# English nameplates in the [Speaker]: transport.
|
||||
self.assertEqual(captured[0], "市民")
|
||||
self.assertEqual(captured[1], "セルリア")
|
||||
self.assertEqual(
|
||||
captured[0],
|
||||
["[市民]: おはよう\n元気?", "[セルリア]: ふふふ", "むかしむかし"],
|
||||
captured[2],
|
||||
["[En_市民]: おはよう\n元気?", "[En_セルリア]: ふふふ", "むかしむかし"],
|
||||
)
|
||||
lines = data["scenes"][0]["lines"]
|
||||
# Restored to WOLF's native Speaker\nbody layout.
|
||||
self.assertEqual(lines[0]["text"], "EN_市民\nEN_おはよう\n元気?")
|
||||
self.assertEqual(lines[1]["text"], "EN_セルリア\nEN_ふふふ")
|
||||
# Restored with the pre-resolved English nameplate (not the model's tag).
|
||||
self.assertEqual(lines[0]["text"], "En_市民\nEN_おはよう\n元気?")
|
||||
self.assertEqual(lines[1]["text"], "En_セルリア\nEN_ふふふ")
|
||||
# Narration was translated as a plain blob.
|
||||
self.assertEqual(lines[2]["text"], "EN_むかしむかし")
|
||||
# Sources are preserved for the inject drift guard.
|
||||
|
|
@ -663,12 +687,66 @@ class TestSpeakerReshaping(unittest.TestCase):
|
|||
cfg = {"literal_line1": True, "literal_line1_lowconf": False}
|
||||
(data, _t, err), captured = _SpeakerHarness(cfg).run(SPEAKER_MAP_DOC)
|
||||
self.assertIsNone(err)
|
||||
# Low-confidence line is sent as the raw source (no reshaping).
|
||||
self.assertIn("市民\nおはよう\n元気?", captured[0])
|
||||
self.assertIn("[セルリア]: ふふふ", captured[0])
|
||||
# High-confidence speaker is resolved; low-confidence line stays raw.
|
||||
self.assertEqual(captured[0], "セルリア")
|
||||
batch = captured[1]
|
||||
self.assertIn("市民\nおはよう\n元気?", batch)
|
||||
self.assertIn("[En_セルリア]: ふふふ", batch)
|
||||
lines = data["scenes"][0]["lines"]
|
||||
self.assertEqual(lines[0]["text"], "EN_市民\nおはよう\n元気?")
|
||||
|
||||
def test_writeback_keeps_preresolved_speaker_when_model_leaves_japanese(self):
|
||||
"""Model echoing a JP tag must not overwrite the pre-resolved English name."""
|
||||
cfg = {"literal_line1": True, "literal_line1_lowconf": True}
|
||||
doc = {
|
||||
"kind": "map",
|
||||
"scenes": [
|
||||
{
|
||||
"event": 0,
|
||||
"name": "ev",
|
||||
"lines": [
|
||||
{
|
||||
"cmd": 59,
|
||||
"str": 0,
|
||||
"speaker": "セルリア",
|
||||
"speaker_src": "literal_line1_lowconf",
|
||||
"source": "セルリア\nも、もう少し、耐えてください!",
|
||||
"text": "セルリア\nも、もう少し、耐えてください!",
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
def bad_model(text, history=None, history_ctx=None):
|
||||
# Speakers resolve normally; dialogue batch keeps the JP tag.
|
||||
if isinstance(text, str):
|
||||
return [f"EN_{text}", [1, 1]]
|
||||
return [["[セルリア]: Please hold on just a little longer!"], [1, 1]]
|
||||
|
||||
orig = (wd.translateAI, wd.ESTIMATE, wd.SPEAKER_CONFIG, wd.VOCAB)
|
||||
orig_names = list(wd.NAMESLIST)
|
||||
orig_cache = dict(wd._speakerCache)
|
||||
wd.translateAI = bad_model
|
||||
wd.ESTIMATE = False
|
||||
wd.SPEAKER_CONFIG = cfg
|
||||
wd.VOCAB = ""
|
||||
wd.NAMESLIST = []
|
||||
wd._speakerCache.clear()
|
||||
try:
|
||||
data, _t, err = wd.parseDocument(copy.deepcopy(doc), "bad.mps.json")
|
||||
finally:
|
||||
(wd.translateAI, wd.ESTIMATE, wd.SPEAKER_CONFIG, wd.VOCAB) = orig
|
||||
wd.NAMESLIST = orig_names
|
||||
wd._speakerCache.clear()
|
||||
wd._speakerCache.update(orig_cache)
|
||||
|
||||
self.assertIsNone(err)
|
||||
self.assertEqual(
|
||||
data["scenes"][0]["lines"][0]["text"],
|
||||
"En_セルリア\nPlease hold on just a little longer!",
|
||||
)
|
||||
|
||||
|
||||
class TestOpenFiles(unittest.TestCase):
|
||||
def test_rejects_unknown_kind(self):
|
||||
|
|
|
|||
|
|
@ -346,9 +346,10 @@ def _has_japanese(text: str) -> bool:
|
|||
# "市民\nおぉっ!来た!帰ってきたぞ!" speaker_src = literal_line1_lowconf
|
||||
# "セルリア\nほーら、ローザも手を振って。" speaker_src = literal_line1
|
||||
#
|
||||
# When translating we reshape those into ``[Speaker]: body`` (the prompt already
|
||||
# knows to translate the tag) and, on write-back, restore WOLF's native
|
||||
# ``Speaker\nbody`` layout so injection stays byte-faithful.
|
||||
# When translating we resolve the nameplate to English first (vocab /
|
||||
# ``getSpeaker``), reshape into ``[Speaker]: body`` with that English tag, and
|
||||
# on write-back restore WOLF's native ``Speaker\nbody`` layout using the
|
||||
# pre-resolved name so a model that echoes Japanese cannot poison ``text``.
|
||||
#
|
||||
# WolfDawn does the detection, so there is nothing to configure for the reliable
|
||||
# format: ``literal_line1`` is a real nameplate (a face window precedes the line),
|
||||
|
|
|
|||
|
|
@ -251,6 +251,10 @@ def rebuild_text_preserving_source_codes(source: str, text: str) -> str:
|
|||
``\\f[N]`` sizes are taken from *text* when present (manual wrap / names-wrap
|
||||
shrink). Other control codes (``\\c``, ``\\^``, …) always come from *source*.
|
||||
A leading body ``\\f[N]`` that *source* lacks is kept from *text*.
|
||||
|
||||
When *source* has no inline codes at all (typical spoken ``Name\\nbody``),
|
||||
*text* is returned unchanged so a body ``\\f[N]`` after the nameplate cannot
|
||||
be mis-read as a code slot and wipe the dialogue.
|
||||
"""
|
||||
if not isinstance(source, str) or not isinstance(text, str):
|
||||
return text
|
||||
|
|
@ -272,6 +276,13 @@ def rebuild_text_preserving_source_codes(source: str, text: str) -> str:
|
|||
if not src_parts:
|
||||
return out_prefix + txt_rest
|
||||
|
||||
# Source has no inline codes to restore. Keep the translation intact -
|
||||
# including Manual-wrap ``Name\\n\\f[N]body`` (body font after the nameplate).
|
||||
# Slot-rebuilding would treat the mid-line ``\\f`` as a break, keep only
|
||||
# ``Name\\n``, prepend ``\\f[N]``, and drop the body (``\\f[N]Name\\n``).
|
||||
if not any(kind == "code" for kind, _ in src_parts):
|
||||
return out_prefix + txt_rest
|
||||
|
||||
# Rebuild using source code skeleton + translated literal slots.
|
||||
# Font sizes prefer the translation (intentional shrink / body \\f).
|
||||
rebuilt: list[str] = []
|
||||
|
|
@ -297,8 +308,14 @@ def rebuild_text_preserving_source_codes(source: str, text: str) -> str:
|
|||
rebuilt.append(val)
|
||||
else:
|
||||
if lit_i < len(txt_lit):
|
||||
rebuilt.append(txt_lit[lit_i])
|
||||
lit_i += 1
|
||||
# Last source literal: keep every remaining translated literal so
|
||||
# extra mid-line ``\\f[N]`` in *text* cannot truncate the body.
|
||||
if lit_i == len(src_lit) - 1 and len(txt_lit) > lit_i + 1:
|
||||
rebuilt.append("".join(txt_lit[lit_i:]))
|
||||
lit_i = len(txt_lit)
|
||||
else:
|
||||
rebuilt.append(txt_lit[lit_i])
|
||||
lit_i += 1
|
||||
else:
|
||||
rebuilt.append(val)
|
||||
return out_prefix + "".join(rebuilt)
|
||||
|
|
|
|||
|
|
@ -516,23 +516,33 @@ def inject_selected(
|
|||
return report
|
||||
|
||||
|
||||
def format_report_dialog(report: InjectReport) -> tuple[str, str]:
|
||||
"""Return (title, body) for a completion dialog."""
|
||||
if report.ok and report.succeeded:
|
||||
title = "Inject complete"
|
||||
lines = [f"✓ {r.json_name}: {r.summary}" for r in report.succeeded]
|
||||
elif report.failed or report.sync_failures:
|
||||
title = "Inject finished with errors"
|
||||
lines = []
|
||||
for r in report.succeeded:
|
||||
lines.append(f"✓ {r.json_name}: {r.summary}")
|
||||
def format_report_dialog(report: InjectReport) -> tuple[str, str] | None:
|
||||
"""Return ``(title, body)`` for a failure dialog, or ``None`` on full success.
|
||||
|
||||
Success details already go to the workflow log; a per-file success popup
|
||||
overflows the screen on large games.
|
||||
"""
|
||||
if report.failed or report.sync_failures:
|
||||
lines: list[str] = []
|
||||
ok_n = len(report.succeeded)
|
||||
if ok_n:
|
||||
lines.append(f"{ok_n} file(s) succeeded; failures:")
|
||||
for r in report.failed:
|
||||
lines.append(f"✗ {r.json_name}: {r.summary}")
|
||||
if r.detail:
|
||||
lines.append(f" {r.detail}")
|
||||
for name, err in report.sync_failures:
|
||||
lines.append(f"✗ sync {name}: {err}")
|
||||
else:
|
||||
title = "Inject"
|
||||
lines = ["Nothing was injected."]
|
||||
return title, "\n".join(lines)
|
||||
return "Inject finished with errors", "\n".join(lines)
|
||||
return None
|
||||
|
||||
|
||||
def format_report_status(report: InjectReport) -> str:
|
||||
"""One-line status for the log / status bar after inject."""
|
||||
ok_n = len(report.succeeded)
|
||||
fail_n = len(report.failed) + len(report.sync_failures)
|
||||
if fail_n:
|
||||
return f"Inject: {ok_n} ok, {fail_n} failed (see dialog)."
|
||||
if ok_n:
|
||||
return f"Inject complete: {ok_n} file(s)."
|
||||
return "Inject: nothing was injected."
|
||||
|
|
|
|||
|
|
@ -1,8 +1,15 @@
|
|||
"""Search translated WolfDawn JSON for text to fix wrapping.
|
||||
|
||||
Paste in-game text, open the matching line, then wrap that line or every
|
||||
overflowing line in the same group (database sheet, names category, map /
|
||||
CommonEvent file, or Game.dat).
|
||||
overflowing line in the same group:
|
||||
|
||||
* database sheet / names category / Game.dat file
|
||||
* map / CommonEvent: group = ``speaker_src`` format (spoken / ui / …) across
|
||||
all map + CommonEvent JSON; shared width/font remembered per format
|
||||
|
||||
Dialogue lines with ``speaker_src`` ``literal_line1`` / ``literal_line1_lowconf``
|
||||
keep the nameplate on its own first line (``Celria\\n…``); only the body is
|
||||
reflowed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -18,6 +25,8 @@ from util.wolfdawn.selective_wrap import line_needs_wrap, wrap_line_text
|
|||
|
||||
WRAP_PROFILE_NAME = "wrap_profile.json"
|
||||
DEFAULT_WRAP_WIDTH = 36
|
||||
# Legacy single-bucket key (migrated to spoken format on read).
|
||||
DIALOGUE_PROFILE_KEY = "__dialogue__"
|
||||
|
||||
_APOSTROPHE_NORMALIZE = str.maketrans(
|
||||
{
|
||||
|
|
@ -29,6 +38,29 @@ _APOSTROPHE_NORMALIZE = str.maketrans(
|
|||
)
|
||||
|
||||
|
||||
def wrap_format_key(speaker_src: str | None) -> str:
|
||||
"""Profile key for shared wrap geometry by WolfDawn ``speaker_src``.
|
||||
|
||||
Same detection class ≈ same on-screen box across maps/CommonEvent.
|
||||
``literal_line1`` and ``literal_line1_lowconf`` share one spoken-dialogue
|
||||
bucket; other tags (``ui``, ``narration``, ``choice``, …) each get their own.
|
||||
"""
|
||||
src = str(speaker_src or "").strip().lower() or "default"
|
||||
if src in ("literal_line1", "literal_line1_lowconf"):
|
||||
return "__format:spoken__"
|
||||
safe = re.sub(r"[^a-z0-9_]+", "_", src).strip("_") or "default"
|
||||
return f"__format:{safe}__"
|
||||
|
||||
|
||||
def wrap_format_label(speaker_src: str | None) -> str:
|
||||
"""Short UI label for the format bucket of *speaker_src*."""
|
||||
key = wrap_format_key(speaker_src)
|
||||
if key == "__format:spoken__":
|
||||
return "spoken dialogue (Name\\nbody)"
|
||||
tag = key.removeprefix("__format:").removesuffix("__")
|
||||
return f"format:{tag}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class WrapHit:
|
||||
"""One searchable line in translated JSON."""
|
||||
|
|
@ -174,6 +206,191 @@ def get_sheet_max_lines(
|
|||
return default
|
||||
|
||||
|
||||
def get_format_geometry(
|
||||
profile: dict[str, Any],
|
||||
speaker_src: str | None,
|
||||
) -> dict[str, int]:
|
||||
"""Return shared wrap settings for this ``speaker_src`` format bucket."""
|
||||
sheets = profile.get("sheets") or {}
|
||||
key = wrap_format_key(speaker_src)
|
||||
entry = sheets.get(key)
|
||||
# Migrate legacy single dialogue bucket → spoken format only.
|
||||
if not isinstance(entry, dict) and key == "__format:spoken__":
|
||||
entry = sheets.get(DIALOGUE_PROFILE_KEY)
|
||||
out: dict[str, int] = {}
|
||||
if not isinstance(entry, dict):
|
||||
return out
|
||||
for field in ("width", "max_lines", "font"):
|
||||
if entry.get(field) is None:
|
||||
continue
|
||||
try:
|
||||
out[field] = int(entry[field])
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def set_format_geometry(
|
||||
work_dir: str | Path,
|
||||
speaker_src: str | None,
|
||||
*,
|
||||
width: int | None = None,
|
||||
max_lines: int | None = None,
|
||||
font: int | None = None,
|
||||
) -> None:
|
||||
"""Persist wrap settings for one ``speaker_src`` format (map + CommonEvent)."""
|
||||
profile = load_wrap_profile(work_dir)
|
||||
sheets = profile.setdefault("sheets", {})
|
||||
key = wrap_format_key(speaker_src)
|
||||
entry = sheets.get(key)
|
||||
if not isinstance(entry, dict):
|
||||
entry = {"json_file": key, "speaker_src": str(speaker_src or "")}
|
||||
sheets[key] = entry
|
||||
if width is not None and int(width) > 0:
|
||||
entry["width"] = int(width)
|
||||
if max_lines is not None:
|
||||
if int(max_lines) > 0:
|
||||
entry["max_lines"] = int(max_lines)
|
||||
else:
|
||||
entry.pop("max_lines", None)
|
||||
if font is not None:
|
||||
if int(font) > 0:
|
||||
entry["font"] = int(font)
|
||||
else:
|
||||
entry.pop("font", None)
|
||||
entry["speaker_src"] = str(speaker_src or "")
|
||||
save_wrap_profile(work_dir, profile)
|
||||
|
||||
|
||||
# Back-compat aliases (spoken / legacy __dialogue__).
|
||||
def get_dialogue_geometry(profile: dict[str, Any]) -> dict[str, int]:
|
||||
return get_format_geometry(profile, "literal_line1")
|
||||
|
||||
|
||||
def set_dialogue_geometry(
|
||||
work_dir: str | Path,
|
||||
*,
|
||||
width: int | None = None,
|
||||
max_lines: int | None = None,
|
||||
font: int | None = None,
|
||||
) -> None:
|
||||
set_format_geometry(
|
||||
work_dir,
|
||||
"literal_line1",
|
||||
width=width,
|
||||
max_lines=max_lines,
|
||||
font=font,
|
||||
)
|
||||
|
||||
|
||||
def split_nameplate_body(
|
||||
text: str,
|
||||
*,
|
||||
speaker_src: str = "",
|
||||
speaker: str = "",
|
||||
) -> tuple[str, str, str]:
|
||||
"""Split ``Name\\nbody`` dialogue into ``(prefix, nameplate, body)``.
|
||||
|
||||
Uses WolfDawn ``speaker_src`` (``literal_line1`` / ``literal_line1_lowconf``)
|
||||
when enabled. Falls back to matching the ``speaker`` field to line 1.
|
||||
When there is no nameplate, returns ``("", "", text)``.
|
||||
"""
|
||||
from util import speakers as wolf_speakers
|
||||
|
||||
if not isinstance(text, str) or not text:
|
||||
return "", "", text if isinstance(text, str) else ""
|
||||
|
||||
# Normalize newlines so ``\\r\\n`` nameplates still split cleanly.
|
||||
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
||||
|
||||
src = str(speaker_src or "")
|
||||
if wolf_speakers.is_firstline_enabled(src):
|
||||
parts = wolf_speakers.split_source(text, src)
|
||||
if parts is not None:
|
||||
prefix, line1, body = parts
|
||||
return prefix, line1, body
|
||||
|
||||
name = str(speaker or "").strip()
|
||||
if name and "\n" in text:
|
||||
prefix, rest = wolf_speakers.split_window_prefix(text)
|
||||
line1, body = rest.split("\n", 1)
|
||||
# Match plain name or ``\\f[N]Name`` nameplate to the speaker field.
|
||||
plate_core = re.sub(r"^(\\f\[\d+\])+", "", line1).strip()
|
||||
if plate_core == name or line1.strip() == name:
|
||||
return prefix, line1, body
|
||||
return "", "", text
|
||||
|
||||
|
||||
def join_nameplate_body(prefix: str, nameplate: str, body: str) -> str:
|
||||
"""Reassemble a nameplate dialogue line."""
|
||||
if nameplate:
|
||||
return f"{prefix}{nameplate}\n{body}"
|
||||
return f"{prefix}{body}"
|
||||
|
||||
|
||||
def _reject_empty_body_result(original: str, new_text: str) -> str:
|
||||
"""Never allow wrap/font passes to drop a previously non-empty body.
|
||||
|
||||
A past group-wrap bug left thousands of spoken lines as ``\\f[N]Name\\n`` with
|
||||
an empty body. Refuse that class of result and keep *original*.
|
||||
"""
|
||||
if not isinstance(original, str) or not isinstance(new_text, str):
|
||||
return original if isinstance(original, str) else new_text
|
||||
if "\n" not in original:
|
||||
return new_text
|
||||
old_body = original.split("\n", 1)[1]
|
||||
if not old_body.strip():
|
||||
return new_text
|
||||
if "\n" not in new_text:
|
||||
return original
|
||||
new_body = new_text.split("\n", 1)[1]
|
||||
if not new_body.strip():
|
||||
return original
|
||||
return new_text
|
||||
|
||||
|
||||
def wrap_preserving_nameplate(
|
||||
text: str,
|
||||
width: int,
|
||||
*,
|
||||
speaker_src: str = "",
|
||||
speaker: str = "",
|
||||
) -> str:
|
||||
"""Word-wrap *text*, keeping a leading speaker nameplate on its own line."""
|
||||
if not isinstance(text, str) or not text.strip() or width <= 0:
|
||||
return text if isinstance(text, str) else ""
|
||||
prefix, nameplate, body = split_nameplate_body(
|
||||
text, speaker_src=speaker_src, speaker=speaker
|
||||
)
|
||||
if not nameplate:
|
||||
return wrap_line_text(text, width)
|
||||
if not body.strip():
|
||||
return text
|
||||
wrapped_body = wrap_line_text(body, width)
|
||||
if not wrapped_body.strip() and body.strip():
|
||||
return text
|
||||
return _reject_empty_body_result(
|
||||
text, join_nameplate_body(prefix, nameplate, wrapped_body)
|
||||
)
|
||||
|
||||
|
||||
def line_needs_wrap_preserving_nameplate(
|
||||
text: str,
|
||||
width: int,
|
||||
*,
|
||||
speaker_src: str = "",
|
||||
speaker: str = "",
|
||||
) -> bool:
|
||||
"""Overflow check that ignores the nameplate line (body only when present)."""
|
||||
if not isinstance(text, str) or not text.strip() or width <= 0:
|
||||
return False
|
||||
prefix, nameplate, body = split_nameplate_body(
|
||||
text, speaker_src=speaker_src, speaker=speaker
|
||||
)
|
||||
check = body if nameplate else text
|
||||
return line_needs_wrap(check, width)
|
||||
|
||||
|
||||
def _load_json(path: Path) -> dict[str, Any] | None:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
|
|
@ -346,7 +563,13 @@ def search_translated_text(
|
|||
return hits
|
||||
|
||||
|
||||
def wrap_preview_info(text: str, width: int) -> dict[str, Any]:
|
||||
def wrap_preview_info(
|
||||
text: str,
|
||||
width: int,
|
||||
*,
|
||||
speaker_src: str = "",
|
||||
speaker: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Return wrapped text and metrics for the Step 7 live preview."""
|
||||
if not isinstance(text, str) or not text.strip() or width <= 0:
|
||||
return {
|
||||
|
|
@ -357,9 +580,21 @@ def wrap_preview_info(text: str, width: int) -> dict[str, Any]:
|
|||
"output_line_count": 0,
|
||||
"line_stats": [],
|
||||
}
|
||||
wrapped = wrap_line_text(text, width)
|
||||
norm_in = text.replace("\r\n", "\n").replace("\r", "\n")
|
||||
norm_out = wrapped.replace("\r\n", "\n").replace("\r", "\n")
|
||||
wrapped = wrap_preserving_nameplate(
|
||||
text, width, speaker_src=speaker_src, speaker=speaker
|
||||
)
|
||||
_, nameplate, body = split_nameplate_body(
|
||||
text, speaker_src=speaker_src, speaker=speaker
|
||||
)
|
||||
# Metrics / overflow are about the dialogue body (nameplate is outside the box).
|
||||
measure_in = body if nameplate else text
|
||||
measure_out = (
|
||||
split_nameplate_body(wrapped, speaker_src=speaker_src, speaker=speaker)[2]
|
||||
if nameplate
|
||||
else wrapped
|
||||
)
|
||||
norm_in = measure_in.replace("\r\n", "\n").replace("\r", "\n")
|
||||
norm_out = measure_out.replace("\r\n", "\n").replace("\r", "\n")
|
||||
in_lines = norm_in.split("\n") if norm_in else [""]
|
||||
out_lines = norm_out.split("\n") if norm_out else [""]
|
||||
line_stats: list[dict[str, Any]] = []
|
||||
|
|
@ -382,20 +617,33 @@ def wrap_preview_info(text: str, width: int) -> dict[str, Any]:
|
|||
"input_line_count": len(in_lines),
|
||||
"output_line_count": len(out_lines),
|
||||
"line_stats": line_stats,
|
||||
"nameplate": nameplate,
|
||||
}
|
||||
|
||||
|
||||
def wrap_preview_summary(text: str, width: int) -> str:
|
||||
def wrap_preview_summary(
|
||||
text: str,
|
||||
width: int,
|
||||
*,
|
||||
speaker_src: str = "",
|
||||
speaker: str = "",
|
||||
) -> str:
|
||||
"""One-line status for the preview header."""
|
||||
info = wrap_preview_info(text, width)
|
||||
info = wrap_preview_info(
|
||||
text, width, speaker_src=speaker_src, speaker=speaker
|
||||
)
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
return ""
|
||||
longest = int(info["longest"])
|
||||
plate = f" (nameplate kept)" if info.get("nameplate") else ""
|
||||
if not info["needs_wrap"]:
|
||||
return f"Fits at width {width} (longest line {longest} visible chars)."
|
||||
return (
|
||||
f"Fits at width {width} (longest body line {longest} visible chars)"
|
||||
f"{plate}."
|
||||
)
|
||||
return (
|
||||
f"Will wrap to {info['output_line_count']} line(s) at width {width} "
|
||||
f"(longest input line {longest} visible chars)."
|
||||
f"Will wrap body to {info['output_line_count']} line(s) at width {width} "
|
||||
f"(longest body line {longest} visible chars){plate}."
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -499,41 +747,52 @@ def format_wrap_preview_html(
|
|||
width: int,
|
||||
*,
|
||||
body_font: int | None = None,
|
||||
speaker_src: str = "",
|
||||
speaker: str = "",
|
||||
) -> str:
|
||||
"""Rich HTML wrap preview: line gutters + simulated ``\\c`` / ``\\f`` styling.
|
||||
|
||||
When *body_font* is set, proportionally scale every ``\\f[N]`` (and ensure a
|
||||
leading body size) before wrapping/rendering - same as Manual wrap would do.
|
||||
Nameplate lines stay on their own first row in the preview.
|
||||
"""
|
||||
from util.wolfdawn import codes as wolf_codes
|
||||
|
||||
preview_text = text if isinstance(text, str) else ""
|
||||
if body_font is not None and int(body_font) > 0 and preview_text.strip():
|
||||
preview_text, _ = wolf_codes.scale_font_sizes(preview_text, int(body_font))
|
||||
plate_kw = {"speaker_src": speaker_src, "speaker": speaker}
|
||||
prefix, nameplate, body = split_nameplate_body(preview_text, **plate_kw)
|
||||
work = body if nameplate else preview_text
|
||||
if body_font is not None and int(body_font) > 0 and work.strip():
|
||||
work, _ = wolf_codes.scale_font_sizes(work, int(body_font))
|
||||
preview_text = (
|
||||
join_nameplate_body(prefix, nameplate, work) if nameplate else work
|
||||
)
|
||||
|
||||
info = wrap_preview_info(preview_text, width)
|
||||
info = wrap_preview_info(preview_text, width, **plate_kw)
|
||||
wrapped = info.get("wrapped") or ""
|
||||
if not wrapped:
|
||||
return ""
|
||||
|
||||
body = (
|
||||
default_px = (
|
||||
int(body_font)
|
||||
if body_font is not None and int(body_font) > 0
|
||||
else wolf_codes.infer_base_font_size(preview_text, default=18)
|
||||
else wolf_codes.infer_base_font_size(work if nameplate else preview_text, default=18)
|
||||
)
|
||||
lines = wrapped.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||
rows: list[str] = []
|
||||
for i, line in enumerate(lines):
|
||||
is_nameplate = bool(nameplate) and i == 0 and line == nameplate
|
||||
vis = dazedwrap.max_line_visible_length(line)
|
||||
overflow = vis > width > 0
|
||||
gutter_color = "#ce9178" if overflow else "#858585"
|
||||
marker = "⚠" if overflow else " "
|
||||
# Nameplate sits outside the message box - never flag as overflow.
|
||||
overflow = (not is_nameplate) and vis > width > 0
|
||||
gutter_color = "#ce9178" if overflow else ("#c8b89a" if is_nameplate else "#858585")
|
||||
marker = "⚠" if overflow else ("◆" if is_nameplate else " ")
|
||||
gutter = (
|
||||
f'<span style="color:{gutter_color};font-family:monospace;font-size:11px">'
|
||||
f"{marker} {i + 1:2d} ({vis:2d}) </span>"
|
||||
)
|
||||
body_html = render_wolf_text_html(
|
||||
line, default_color="#d4d4d4", default_font_px=body
|
||||
line, default_color="#d4d4d4", default_font_px=default_px
|
||||
)
|
||||
rows.append(
|
||||
f'<div style="white-space:pre-wrap;line-height:1.35;margin:0 0 2px 0">'
|
||||
|
|
@ -626,7 +885,12 @@ def wrap_line_in_doc(line: dict[str, Any], width: int) -> bool:
|
|||
text = line.get("text")
|
||||
if not isinstance(text, str) or not text.strip() or width <= 0:
|
||||
return False
|
||||
new_text = wrap_line_text(text, width)
|
||||
new_text = wrap_preserving_nameplate(
|
||||
text,
|
||||
width,
|
||||
speaker_src=str(line.get("speaker_src") or ""),
|
||||
speaker=str(line.get("speaker") or ""),
|
||||
)
|
||||
if new_text == text:
|
||||
return False
|
||||
line["text"] = new_text
|
||||
|
|
@ -641,6 +905,19 @@ def count_soft_lines(text: str) -> int:
|
|||
return max(1, len(norm.split("\n")))
|
||||
|
||||
|
||||
def count_body_soft_lines(
|
||||
text: str,
|
||||
*,
|
||||
speaker_src: str = "",
|
||||
speaker: str = "",
|
||||
) -> int:
|
||||
"""Soft line count for the message body (excludes nameplate when present)."""
|
||||
_, nameplate, body = split_nameplate_body(
|
||||
text, speaker_src=speaker_src, speaker=speaker
|
||||
)
|
||||
return count_soft_lines(body if nameplate else text)
|
||||
|
||||
|
||||
def fit_text_to_box(
|
||||
text: str,
|
||||
width: int,
|
||||
|
|
@ -648,6 +925,8 @@ def fit_text_to_box(
|
|||
max_lines: int | None = None,
|
||||
min_font: int = 8,
|
||||
box_font: int | None = None,
|
||||
speaker_src: str = "",
|
||||
speaker: str = "",
|
||||
) -> tuple[str, bool]:
|
||||
"""Reflow *text* to *width*, then shrink ``\\f[N]`` until it fits *max_lines*.
|
||||
|
||||
|
|
@ -659,18 +938,49 @@ def fit_text_to_box(
|
|||
``round(width * box_font / current_font)`` (same idea as ``wolf relayout`` /
|
||||
``desc-relayout``). *box_font* defaults to the text's current body size
|
||||
(or 18 when there is no ``\\f`` yet).
|
||||
|
||||
Nameplate lines (``Celria\\n…``) are kept intact; only the body is reflowed
|
||||
and counted toward *max_lines*.
|
||||
"""
|
||||
return _fit_text_to_box_impl(
|
||||
text,
|
||||
width,
|
||||
max_lines=max_lines,
|
||||
min_font=min_font,
|
||||
box_font=box_font,
|
||||
speaker_src=speaker_src,
|
||||
speaker=speaker,
|
||||
)
|
||||
|
||||
|
||||
def _fit_text_to_box_impl(
|
||||
text: str,
|
||||
width: int,
|
||||
*,
|
||||
max_lines: int | None = None,
|
||||
min_font: int = 8,
|
||||
box_font: int | None = None,
|
||||
speaker_src: str = "",
|
||||
speaker: str = "",
|
||||
) -> tuple[str, bool]:
|
||||
from util.wolfdawn import codes as wolf_codes
|
||||
|
||||
if not isinstance(text, str) or not text.strip() or width <= 0:
|
||||
return text if isinstance(text, str) else "", False
|
||||
|
||||
plate_kw = {"speaker_src": speaker_src, "speaker": speaker}
|
||||
prefix, nameplate, body = split_nameplate_body(text, **plate_kw)
|
||||
work = body if nameplate else text
|
||||
if nameplate and not body.strip():
|
||||
# Already wiped / nameplate-only - do not invent or further destroy.
|
||||
return text, False
|
||||
|
||||
limit = int(max_lines) if max_lines is not None else 0
|
||||
sizes = wolf_codes.detect_font_sizes(text)
|
||||
sizes = wolf_codes.detect_font_sizes(work)
|
||||
if box_font is not None and int(box_font) > 0:
|
||||
native = int(box_font)
|
||||
elif sizes:
|
||||
native = wolf_codes.infer_base_font_size(text)
|
||||
native = wolf_codes.infer_base_font_size(work)
|
||||
else:
|
||||
native = 18
|
||||
native = max(1, native)
|
||||
|
|
@ -680,28 +990,37 @@ def fit_text_to_box(
|
|||
cur = max(1, int(current_font))
|
||||
return max(1, int(round(width * native / cur)))
|
||||
|
||||
current = wolf_codes.infer_base_font_size(text, default=native) if sizes else native
|
||||
new_text = wrap_line_text(text, _effective_width(current) if sizes else width)
|
||||
current = wolf_codes.infer_base_font_size(work, default=native) if sizes else native
|
||||
new_body = wrap_line_text(work, _effective_width(current) if sizes else width)
|
||||
if nameplate and work.strip() and not new_body.strip():
|
||||
return text, False
|
||||
if limit <= 0:
|
||||
new_text = (
|
||||
join_nameplate_body(prefix, nameplate, new_body) if nameplate else new_body
|
||||
)
|
||||
new_text = _reject_empty_body_result(text, new_text)
|
||||
return new_text, new_text != text
|
||||
|
||||
# No \\f yet and already over budget: introduce the native body size first.
|
||||
if not sizes and count_soft_lines(new_text) > limit:
|
||||
new_text, _ = wolf_codes.scale_font_sizes(new_text, native)
|
||||
if not sizes and count_soft_lines(new_body) > limit:
|
||||
new_body, _ = wolf_codes.scale_font_sizes(new_body, native)
|
||||
current = native
|
||||
new_text = wrap_line_text(new_text, _effective_width(current))
|
||||
new_body = wrap_line_text(new_body, _effective_width(current))
|
||||
|
||||
guard = 0
|
||||
while count_soft_lines(new_text) > limit and guard < 64:
|
||||
while count_soft_lines(new_body) > limit and guard < 64:
|
||||
guard += 1
|
||||
current = wolf_codes.infer_base_font_size(new_text, default=current)
|
||||
current = wolf_codes.infer_base_font_size(new_body, default=current)
|
||||
if current <= min_font:
|
||||
break
|
||||
target = current - 1
|
||||
new_text, _ = wolf_codes.scale_font_sizes(new_text, target)
|
||||
new_body, _ = wolf_codes.scale_font_sizes(new_body, target)
|
||||
current = target
|
||||
new_text = wrap_line_text(new_text, _effective_width(current))
|
||||
new_body = wrap_line_text(new_body, _effective_width(current))
|
||||
|
||||
if nameplate and work.strip() and not new_body.strip():
|
||||
return text, False
|
||||
new_text = join_nameplate_body(prefix, nameplate, new_body) if nameplate else new_body
|
||||
new_text = _reject_empty_body_result(text, new_text)
|
||||
return new_text, new_text != text
|
||||
|
||||
|
||||
|
|
@ -730,6 +1049,8 @@ def apply_relayout_to_line_dict(
|
|||
max_lines=max_lines,
|
||||
min_font=min_font,
|
||||
box_font=native,
|
||||
speaker_src=str(line.get("speaker_src") or ""),
|
||||
speaker=str(line.get("speaker") or ""),
|
||||
)
|
||||
if not changed:
|
||||
return False
|
||||
|
|
@ -744,15 +1065,19 @@ def apply_manual_font_and_wrap(
|
|||
font: int | None = None,
|
||||
old_base: int | None = None,
|
||||
only_overflow_or_fonts: bool = False,
|
||||
speaker_src: str = "",
|
||||
speaker: str = "",
|
||||
) -> tuple[str, bool]:
|
||||
"""Scale body ``\\f`` (keeping emphasis ratios), then reflow to *width*.
|
||||
|
||||
Always reflows when the wrap layout would differ from the current text
|
||||
(same as the live preview). Soft ``\\n`` breaks that already "fit" per line
|
||||
are collapsed and rebuilt so Manual Wrap matches what the preview shows.
|
||||
Nameplate lines (``Celria\\n…``) stay on their own first line.
|
||||
|
||||
When *only_overflow_or_fonts* is True (group wrap), skip short plain lines
|
||||
that already match the target layout and have no ``\\f[N]``.
|
||||
When *only_overflow_or_fonts* is True, skip short plain lines that already
|
||||
match the target layout and have no ``\\f[N]``. Manual group wrap passes
|
||||
False whenever a body font is set so short spoken lines still get ``\\f``.
|
||||
|
||||
Returns ``(new_text, changed)``.
|
||||
"""
|
||||
|
|
@ -761,30 +1086,45 @@ def apply_manual_font_and_wrap(
|
|||
if not isinstance(text, str) or not text.strip():
|
||||
return text, False
|
||||
|
||||
has_f = bool(wolf_codes.detect_font_sizes(text))
|
||||
overflows = width > 0 and line_needs_wrap(text, width)
|
||||
wrapped_as_is = wrap_line_text(text, width) if width > 0 else text
|
||||
plate_kw = {"speaker_src": speaker_src, "speaker": speaker}
|
||||
prefix, nameplate, body = split_nameplate_body(text, **plate_kw)
|
||||
work = body if nameplate else text
|
||||
if nameplate and not body.strip():
|
||||
return text, False
|
||||
|
||||
has_f = bool(wolf_codes.detect_font_sizes(work))
|
||||
overflows = width > 0 and line_needs_wrap(work, width)
|
||||
wrapped_as_is = (
|
||||
wrap_preserving_nameplate(text, width, **plate_kw) if width > 0 else text
|
||||
)
|
||||
layout_differs = width > 0 and wrapped_as_is != text
|
||||
|
||||
if only_overflow_or_fonts and not has_f and not overflows and not layout_differs:
|
||||
return text, False
|
||||
|
||||
new_text = text
|
||||
new_work = work
|
||||
apply_font = (
|
||||
font is not None
|
||||
and int(font) > 0
|
||||
and (has_f or overflows or layout_differs or not only_overflow_or_fonts)
|
||||
)
|
||||
if apply_font:
|
||||
new_text, _ = wolf_codes.scale_font_sizes(
|
||||
new_text, int(font), old_base=old_base
|
||||
new_work, _ = wolf_codes.scale_font_sizes(
|
||||
new_work, int(font), old_base=old_base
|
||||
)
|
||||
|
||||
if width > 0:
|
||||
wrapped = wrap_line_text(new_text, width)
|
||||
if wrapped != new_text:
|
||||
new_text = wrapped
|
||||
wrapped = wrap_line_text(new_work, width)
|
||||
if wrapped != new_work:
|
||||
new_work = wrapped
|
||||
|
||||
if nameplate and work.strip() and not str(new_work).strip():
|
||||
return text, False
|
||||
|
||||
new_text = (
|
||||
join_nameplate_body(prefix, nameplate, new_work) if nameplate else new_work
|
||||
)
|
||||
new_text = _reject_empty_body_result(text, new_text)
|
||||
return new_text, new_text != text
|
||||
|
||||
|
||||
|
|
@ -803,7 +1143,13 @@ def wrap_hit_manual(
|
|||
text = line.get("text")
|
||||
if not isinstance(text, str):
|
||||
return False
|
||||
new_text, changed = apply_manual_font_and_wrap(text, width, font=font)
|
||||
new_text, changed = apply_manual_font_and_wrap(
|
||||
text,
|
||||
width,
|
||||
font=font,
|
||||
speaker_src=str(line.get("speaker_src") or ""),
|
||||
speaker=str(line.get("speaker") or ""),
|
||||
)
|
||||
if not changed:
|
||||
return False
|
||||
line["text"] = new_text
|
||||
|
|
@ -818,20 +1164,24 @@ def wrap_overflow_manual_in_scope(
|
|||
width: int,
|
||||
*,
|
||||
font: int | None = None,
|
||||
translated_dir: Path | 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 "")
|
||||
kind, sheet_name = _hit_kind_and_sheet(hit, doc)
|
||||
|
||||
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)
|
||||
speaker_src = _hit_speaker_src(hit, doc)
|
||||
if translated_dir is not None:
|
||||
return wrap_overflow_manual_in_dialogue_format(
|
||||
translated_dir, speaker_src, width, font=font
|
||||
)
|
||||
return _wrap_manual_in_event_format(
|
||||
path, doc, speaker_src, width, font=font
|
||||
)
|
||||
if kind == "gamedat":
|
||||
return _wrap_manual_in_gamedat(path, doc, width, font=font)
|
||||
return 0
|
||||
|
|
@ -846,8 +1196,16 @@ def _apply_manual_to_line_dict(
|
|||
text = line.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
return False
|
||||
# Font set: apply to every line in the group (short spoken lines included).
|
||||
# Font 0 / None: only reflow lines that overflow or already have \\f[N].
|
||||
only_overflow = font is None or int(font) <= 0
|
||||
new_text, changed = apply_manual_font_and_wrap(
|
||||
text, width, font=font, only_overflow_or_fonts=True
|
||||
text,
|
||||
width,
|
||||
font=font,
|
||||
only_overflow_or_fonts=only_overflow,
|
||||
speaker_src=str(line.get("speaker_src") or ""),
|
||||
speaker=str(line.get("speaker") or ""),
|
||||
)
|
||||
if not changed:
|
||||
return False
|
||||
|
|
@ -900,18 +1258,23 @@ def _wrap_manual_in_db_sheet(
|
|||
return changed
|
||||
|
||||
|
||||
def _wrap_manual_in_event_file(
|
||||
def _wrap_manual_in_event_format(
|
||||
path: Path,
|
||||
doc: dict[str, Any],
|
||||
speaker_src: str,
|
||||
width: int,
|
||||
*,
|
||||
font: int | None = None,
|
||||
) -> int:
|
||||
"""Manual-wrap overflowing lines in one file that share *speaker_src*'s format."""
|
||||
if doc.get("kind") not in ("map", "common"):
|
||||
return 0
|
||||
want = wrap_format_key(speaker_src)
|
||||
changed = 0
|
||||
for scene in doc.get("scenes") or []:
|
||||
for line in scene.get("lines") or []:
|
||||
if wrap_format_key(str(line.get("speaker_src") or "")) != want:
|
||||
continue
|
||||
if _apply_manual_to_line_dict(line, width, font=font):
|
||||
changed += 1
|
||||
if changed:
|
||||
|
|
@ -919,6 +1282,28 @@ def _wrap_manual_in_event_file(
|
|||
return changed
|
||||
|
||||
|
||||
def wrap_overflow_manual_in_dialogue_format(
|
||||
translated_dir: str | Path,
|
||||
speaker_src: str,
|
||||
width: int,
|
||||
*,
|
||||
font: int | None = None,
|
||||
) -> int:
|
||||
"""Manual-wrap matching-format lines across all map/CommonEvent JSON."""
|
||||
root = Path(translated_dir)
|
||||
if not root.is_dir() or width <= 0:
|
||||
return 0
|
||||
total = 0
|
||||
for path in sorted(root.glob("*.json")):
|
||||
doc = _load_json(path)
|
||||
if not isinstance(doc, dict) or doc.get("kind") not in ("map", "common"):
|
||||
continue
|
||||
total += _wrap_manual_in_event_format(
|
||||
path, doc, speaker_src, width, font=font
|
||||
)
|
||||
return total
|
||||
|
||||
|
||||
def _wrap_manual_in_gamedat(
|
||||
path: Path,
|
||||
doc: dict[str, Any],
|
||||
|
|
@ -946,13 +1331,82 @@ class ScopeStats:
|
|||
overflow: int
|
||||
|
||||
|
||||
def scope_label(hit: WrapHit | dict[str, Any]) -> str:
|
||||
"""Human-readable group name for status messages."""
|
||||
def _hit_kind_and_sheet(hit: WrapHit | dict[str, Any], doc: dict[str, Any] | None = None) -> tuple[str, str]:
|
||||
if isinstance(hit, WrapHit):
|
||||
kind, sheet, jf = hit.kind, hit.sheet_name, hit.json_file
|
||||
return hit.kind, hit.sheet_name
|
||||
kind = str(hit.get("kind") or (doc or {}).get("kind") or "")
|
||||
sheet = str(hit.get("sheet_name") or "")
|
||||
return kind, sheet
|
||||
|
||||
|
||||
def _hit_scene_index(hit: WrapHit | dict[str, Any]) -> int | None:
|
||||
if isinstance(hit, WrapHit):
|
||||
return hit.scene_index
|
||||
raw = hit.get("scene_index")
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _hit_speaker_src(
|
||||
hit: WrapHit | dict[str, Any],
|
||||
doc: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""Best-effort ``speaker_src`` for a wrap hit (from live line when possible)."""
|
||||
if doc is not None:
|
||||
hit_id = hit.hit_id if isinstance(hit, WrapHit) else hit
|
||||
if isinstance(hit_id, dict):
|
||||
line = locate_line(doc, hit_id)
|
||||
if isinstance(line, dict) and line.get("speaker_src") is not None:
|
||||
return str(line.get("speaker_src") or "")
|
||||
if isinstance(hit, dict) and hit.get("speaker_src") is not None:
|
||||
return str(hit.get("speaker_src") or "")
|
||||
return ""
|
||||
|
||||
|
||||
def _scene_group_label(doc: dict[str, Any] | None, hit: WrapHit | dict[str, Any]) -> str:
|
||||
"""Deprecated scene label; prefer format label for map/common groups."""
|
||||
kind, sheet = _hit_kind_and_sheet(hit, doc)
|
||||
si = _hit_scene_index(hit)
|
||||
scene: dict[str, Any] | None = None
|
||||
if doc is not None and si is not None:
|
||||
scenes = doc.get("scenes") or []
|
||||
if 0 <= si < len(scenes) and isinstance(scenes[si], dict):
|
||||
scene = scenes[si]
|
||||
event_id = None
|
||||
name = ""
|
||||
if scene is not None:
|
||||
event_id = scene.get("event")
|
||||
name = str(scene.get("name") or "").strip()
|
||||
elif isinstance(hit, WrapHit) and hit.row is not None:
|
||||
event_id = hit.row
|
||||
elif isinstance(hit, dict) and hit.get("row") is not None:
|
||||
event_id = hit.get("row")
|
||||
|
||||
parts: list[str] = []
|
||||
if kind == "common":
|
||||
parts.append("CommonEvent")
|
||||
elif sheet:
|
||||
parts.append(sheet)
|
||||
if event_id is not None:
|
||||
parts.append(f"event {event_id}")
|
||||
if name:
|
||||
parts.append(name)
|
||||
elif si is not None and event_id is None:
|
||||
parts.append(f"scene {si}")
|
||||
return " · ".join(parts) if parts else (sheet or "event")
|
||||
|
||||
|
||||
def scope_label(hit: WrapHit | dict[str, Any], doc: dict[str, Any] | None = None) -> str:
|
||||
"""Human-readable group name for status messages."""
|
||||
kind, sheet = _hit_kind_and_sheet(hit, doc)
|
||||
jf = ""
|
||||
if isinstance(hit, WrapHit):
|
||||
jf = hit.json_file
|
||||
else:
|
||||
kind = str(hit.get("kind") or "")
|
||||
sheet = str(hit.get("sheet_name") or "")
|
||||
jf = str(hit.get("json_file") or "")
|
||||
if kind == "db":
|
||||
return f"sheet {sheet}"
|
||||
|
|
@ -960,10 +1414,8 @@ def scope_label(hit: WrapHit | dict[str, Any]) -> str:
|
|||
return f"category {sheet}"
|
||||
if kind == "gamedat":
|
||||
return "Game.dat"
|
||||
if kind == "common":
|
||||
return "CommonEvent"
|
||||
if kind == "map":
|
||||
return sheet or jf
|
||||
if kind in ("map", "common"):
|
||||
return wrap_format_label(_hit_speaker_src(hit, doc))
|
||||
return sheet or jf
|
||||
|
||||
|
||||
|
|
@ -973,11 +1425,7 @@ def scope_stats(
|
|||
width: int,
|
||||
) -> ScopeStats:
|
||||
"""Count lines and overflows in the same group as *hit*."""
|
||||
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 "")
|
||||
kind, sheet_name = _hit_kind_and_sheet(hit, doc)
|
||||
|
||||
total = 0
|
||||
overflow = 0
|
||||
|
|
@ -1007,13 +1455,21 @@ def scope_stats(
|
|||
if line_needs_wrap(text, width):
|
||||
overflow += 1
|
||||
elif kind in ("map", "common"):
|
||||
want = wrap_format_key(_hit_speaker_src(hit, doc))
|
||||
for scene in doc.get("scenes") or []:
|
||||
for line in scene.get("lines") or []:
|
||||
if wrap_format_key(str(line.get("speaker_src") or "")) != want:
|
||||
continue
|
||||
text = line.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
continue
|
||||
total += 1
|
||||
if line_needs_wrap(text, width):
|
||||
if line_needs_wrap_preserving_nameplate(
|
||||
text,
|
||||
width,
|
||||
speaker_src=str(line.get("speaker_src") or ""),
|
||||
speaker=str(line.get("speaker") or ""),
|
||||
):
|
||||
overflow += 1
|
||||
elif kind == "gamedat":
|
||||
for line in doc.get("lines") or []:
|
||||
|
|
@ -1026,37 +1482,55 @@ def scope_stats(
|
|||
if line_needs_wrap(text, width):
|
||||
overflow += 1
|
||||
|
||||
return ScopeStats(label=scope_label(hit), total=total, overflow=overflow)
|
||||
return ScopeStats(label=scope_label(hit, doc), total=total, overflow=overflow)
|
||||
|
||||
|
||||
def _line_needs_relayout(text: str, width: int, max_lines: int | None) -> bool:
|
||||
def _line_needs_relayout(
|
||||
text: str,
|
||||
width: int,
|
||||
max_lines: int | None,
|
||||
*,
|
||||
speaker_src: str = "",
|
||||
speaker: str = "",
|
||||
) -> bool:
|
||||
"""True if width wrap or max-lines fit would change *text*."""
|
||||
if line_needs_wrap(text, width):
|
||||
plate_kw = {"speaker_src": speaker_src, "speaker": speaker}
|
||||
if line_needs_wrap_preserving_nameplate(text, width, **plate_kw):
|
||||
return True
|
||||
limit = int(max_lines) if max_lines is not None else 0
|
||||
if limit <= 0:
|
||||
return False
|
||||
wrapped = wrap_line_text(text, width)
|
||||
return count_soft_lines(wrapped) > limit or wrapped != text
|
||||
wrapped = wrap_preserving_nameplate(text, width, **plate_kw)
|
||||
return (
|
||||
count_body_soft_lines(wrapped, **plate_kw) > limit or wrapped != text
|
||||
)
|
||||
|
||||
|
||||
def _wrap_overflow_in_event_file(
|
||||
def _wrap_overflow_in_event_format(
|
||||
path: Path,
|
||||
doc: dict[str, Any],
|
||||
speaker_src: str,
|
||||
width: int,
|
||||
*,
|
||||
max_lines: int | None = None,
|
||||
) -> int:
|
||||
"""Wrap overflowing dialogue lines in one map or CommonEvent JSON file."""
|
||||
"""Wrap overflowing lines in one file that share *speaker_src*'s format."""
|
||||
if doc.get("kind") not in ("map", "common"):
|
||||
return 0
|
||||
want = wrap_format_key(speaker_src)
|
||||
changed = 0
|
||||
for scene in doc.get("scenes") or []:
|
||||
for line in scene.get("lines") or []:
|
||||
if wrap_format_key(str(line.get("speaker_src") or "")) != want:
|
||||
continue
|
||||
text = line.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
continue
|
||||
if not _line_needs_relayout(text, width, max_lines):
|
||||
src = str(line.get("speaker_src") or "")
|
||||
spk = str(line.get("speaker") or "")
|
||||
if not _line_needs_relayout(
|
||||
text, width, max_lines, speaker_src=src, speaker=spk
|
||||
):
|
||||
continue
|
||||
if apply_relayout_to_line_dict(line, width, max_lines=max_lines):
|
||||
changed += 1
|
||||
|
|
@ -1065,6 +1539,28 @@ def _wrap_overflow_in_event_file(
|
|||
return changed
|
||||
|
||||
|
||||
def wrap_overflow_in_dialogue_format(
|
||||
translated_dir: str | Path,
|
||||
speaker_src: str,
|
||||
width: int,
|
||||
*,
|
||||
max_lines: int | None = None,
|
||||
) -> int:
|
||||
"""Relayout matching-format lines across all map/CommonEvent JSON."""
|
||||
root = Path(translated_dir)
|
||||
if not root.is_dir() or width <= 0:
|
||||
return 0
|
||||
total = 0
|
||||
for path in sorted(root.glob("*.json")):
|
||||
doc = _load_json(path)
|
||||
if not isinstance(doc, dict) or doc.get("kind") not in ("map", "common"):
|
||||
continue
|
||||
total += _wrap_overflow_in_event_format(
|
||||
path, doc, speaker_src, width, max_lines=max_lines
|
||||
)
|
||||
return total
|
||||
|
||||
|
||||
def _wrap_overflow_in_gamedat(
|
||||
path: Path,
|
||||
doc: dict[str, Any],
|
||||
|
|
@ -1097,18 +1593,17 @@ def wrap_overflow_in_scope(
|
|||
width: int,
|
||||
*,
|
||||
max_lines: int | None = None,
|
||||
translated_dir: Path | None = None,
|
||||
) -> int:
|
||||
"""Wrap overflowing lines in the same group/file as *hit*.
|
||||
"""Wrap overflowing lines in the same group as *hit*.
|
||||
|
||||
For DB / map / common / gamedat, *max_lines* also shrinks ``\\f[N]`` until
|
||||
the soft line count fits (Relayout). Names categories ignore *max_lines*
|
||||
For DB / gamedat, *max_lines* also shrinks ``\\f[N]`` until the soft line
|
||||
count fits. For map / CommonEvent, the group is the ``speaker_src`` format
|
||||
bucket (spoken / ui / …); when *translated_dir* is set, every map and
|
||||
CommonEvent JSON under it is included. Names categories ignore *max_lines*
|
||||
here - use ``wolf names-wrap`` via the GUI instead.
|
||||
"""
|
||||
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 "")
|
||||
kind, sheet_name = _hit_kind_and_sheet(hit, doc)
|
||||
|
||||
if kind == "names":
|
||||
return _wrap_overflow_in_names_category(path, doc, sheet_name, width)
|
||||
|
|
@ -1117,7 +1612,14 @@ def wrap_overflow_in_scope(
|
|||
path, doc, sheet_name, width, kind="db", max_lines=max_lines
|
||||
)
|
||||
if kind in ("map", "common"):
|
||||
return _wrap_overflow_in_event_file(path, doc, width, max_lines=max_lines)
|
||||
speaker_src = _hit_speaker_src(hit, doc)
|
||||
if translated_dir is not None:
|
||||
return wrap_overflow_in_dialogue_format(
|
||||
translated_dir, speaker_src, width, max_lines=max_lines
|
||||
)
|
||||
return _wrap_overflow_in_event_format(
|
||||
path, doc, speaker_src, width, max_lines=max_lines
|
||||
)
|
||||
if kind == "gamedat":
|
||||
return _wrap_overflow_in_gamedat(path, doc, width, max_lines=max_lines)
|
||||
return 0
|
||||
|
|
|
|||
Loading…
Reference in a new issue