Compare commits

...

6 commits

Author SHA1 Message Date
f25ce9f990 get Speaker for wolf 2026-07-09 16:09:20 -05:00
04c6fb63bb New button 2026-07-09 15:55:28 -05:00
e23e8f3b10 Dialogue working 2026-07-09 15:42:53 -05:00
3fcde613a8 Can we get dialogue wrap working 2026-07-09 15:39:31 -05:00
9db8089076 Wrap group is based on scene for maps/events 2026-07-09 14:53:51 -05:00
5ff504e549 We dont need this window 2026-07-09 14:43:45 -05:00
10 changed files with 1372 additions and 209 deletions

View file

@ -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()

View file

@ -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)

View file

@ -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",

View file

@ -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__":

View file

@ -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()

View file

@ -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):

View file

@ -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),

View file

@ -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)

View file

@ -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."

View file

@ -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 "&nbsp;"
# 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 "&nbsp;")
gutter = (
f'<span style="color:{gutter_color};font-family:monospace;font-size:11px">'
f"{marker} {i + 1:2d} ({vis:2d})&nbsp;&nbsp;</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