Can we get dialogue wrap working

This commit is contained in:
DazedAnon 2026-07-09 15:39:31 -05:00
parent 9db8089076
commit 3fcde613a8
5 changed files with 846 additions and 133 deletions

View file

@ -2812,10 +2812,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 +2832,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 +2880,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)
@ -3050,11 +3111,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 +3153,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 +3182,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 +3240,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 +3305,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 +3379,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 +3437,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 +3475,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(
@ -3417,15 +3532,21 @@ 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, self._current_wrap_doc)
if max_lines > 0:

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

@ -243,58 +243,87 @@ class TestManualFontAndWrap(unittest.TestCase):
class TestEventScopeWrap(unittest.TestCase):
def test_wrap_all_in_map_scene_only(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": long_a},
{"text": "Short line."},
{
"speaker": "セルリア",
"speaker_src": "literal_line1_lowconf",
"text": long_a,
},
],
},
{
"event": 2,
"name": "Other",
"lines": [{"text": long_b}],
"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="UI",
field_name="セルリア",
text="very long line",
max_line_len=80,
scene_index=0,
line_index=0,
map_file="Map001.mps",
)
stats = ws.scope_stats(doc, hit, 34)
self.assertEqual(stats.total, 2)
self.assertEqual(stats.overflow, 1)
self.assertIn("event 1", stats.label)
self.assertIn("Intro", stats.label)
self.assertEqual(ws.wrap_overflow_in_scope(path, doc, hit, 34), 1)
# Scene 2 must stay untouched.
self.assertEqual(doc["scenes"][1]["lines"][0]["text"], long_b)
self.assertNotEqual(doc["scenes"][0]["lines"][0]["text"], long_a)
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):
@ -389,5 +418,95 @@ 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_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

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

@ -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,12 +1065,15 @@ 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]``.
@ -761,30 +1085,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 +1142,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,6 +1163,7 @@ 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."""
kind, sheet_name = _hit_kind_and_sheet(hit, doc)
@ -827,8 +1173,13 @@ def wrap_overflow_manual_in_scope(
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_scene(
path, doc, _hit_scene_index(hit), 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)
@ -845,7 +1196,12 @@ def _apply_manual_to_line_dict(
if not isinstance(text, str) or not text.strip():
return False
new_text, changed = apply_manual_font_and_wrap(
text, width, font=font, only_overflow_or_fonts=True
text,
width,
font=font,
only_overflow_or_fonts=True,
speaker_src=str(line.get("speaker_src") or ""),
speaker=str(line.get("speaker") or ""),
)
if not changed:
return False
@ -898,29 +1254,52 @@ def _wrap_manual_in_db_sheet(
return changed
def _wrap_manual_in_event_scene(
def _wrap_manual_in_event_format(
path: Path,
doc: dict[str, Any],
scene_index: int | None,
speaker_src: str,
width: int,
*,
font: int | None = None,
) -> int:
"""Manual-wrap overflowing lines in one map/CommonEvent scene."""
"""Manual-wrap overflowing lines in one file that share *speaker_src*'s format."""
if doc.get("kind") not in ("map", "common"):
return 0
scenes = doc.get("scenes") or []
if scene_index is None or not (0 <= scene_index < len(scenes)):
return 0
want = wrap_format_key(speaker_src)
changed = 0
for line in scenes[scene_index].get("lines") or []:
if _apply_manual_to_line_dict(line, width, font=font):
changed += 1
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:
save_document(path, doc)
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],
@ -968,8 +1347,24 @@ def _hit_scene_index(hit: WrapHit | dict[str, Any]) -> int | None:
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:
"""Label for one map/CommonEvent scene (the wrap-group unit)."""
"""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
@ -1016,7 +1411,7 @@ def scope_label(hit: WrapHit | dict[str, Any], doc: dict[str, Any] | None = None
if kind == "gamedat":
return "Game.dat"
if kind in ("map", "common"):
return _scene_group_label(doc, hit)
return wrap_format_label(_hit_speaker_src(hit, doc))
return sheet or jf
@ -1056,20 +1451,21 @@ def scope_stats(
if line_needs_wrap(text, width):
overflow += 1
elif kind in ("map", "common"):
si = _hit_scene_index(hit)
scenes = doc.get("scenes") or []
if si is not None and 0 <= si < len(scenes):
scene_iter = [scenes[si]]
else:
# No scene on the hit: fall back to whole file (legacy).
scene_iter = scenes
for scene in scene_iter:
for line in (scene.get("lines") or []):
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 []:
@ -1085,45 +1481,82 @@ def scope_stats(
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_scene(
def _wrap_overflow_in_event_format(
path: Path,
doc: dict[str, Any],
scene_index: int | None,
speaker_src: str,
width: int,
*,
max_lines: int | None = None,
) -> int:
"""Wrap overflowing dialogue lines in one map or CommonEvent scene."""
"""Wrap overflowing lines in one file that share *speaker_src*'s format."""
if doc.get("kind") not in ("map", "common"):
return 0
scenes = doc.get("scenes") or []
if scene_index is None or not (0 <= scene_index < len(scenes)):
return 0
want = wrap_format_key(speaker_src)
changed = 0
for line in scenes[scene_index].get("lines") or []:
text = line.get("text")
if not isinstance(text, str) or not text.strip():
continue
if not _line_needs_relayout(text, width, max_lines):
continue
if apply_relayout_to_line_dict(line, width, max_lines=max_lines):
changed += 1
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
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
if changed:
save_document(path, doc)
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],
@ -1156,18 +1589,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)
@ -1176,8 +1608,13 @@ 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_scene(
path, doc, _hit_scene_index(hit), 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)