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() work = self._wolf_json_work_dir()
return [work] if work is not None else [] 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() work = self._wolf_json_work_dir()
if work is not None: if work is not None:
profile = wolf_ws.load_wrap_profile(work) 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( return wolf_ws.get_sheet_width(
profile, profile,
sheet_name, sheet_name,
@ -2826,6 +2832,47 @@ class WolfWorkflowTab(QWidget):
except (TypeError, ValueError): except (TypeError, ValueError):
return 36 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( def _remember_sheet_width(
self, self,
sheet_name: str, sheet_name: str,
@ -2833,9 +2880,23 @@ class WolfWorkflowTab(QWidget):
json_file: str, json_file: str,
*, *,
max_lines: int | None = None, max_lines: int | None = None,
font: int | None = None,
kind: str | None = None,
speaker_src: str | None = None,
): ):
work = self._wolf_json_work_dir() work = self._wolf_json_work_dir()
if work is not None: 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,
src,
width=width,
max_lines=max_lines,
font=font,
)
return
wolf_ws.set_sheet_width( wolf_ws.set_sheet_width(
work, work,
sheet_name, sheet_name,
@ -3050,11 +3111,32 @@ class WolfWorkflowTab(QWidget):
f"{kind_lbl}: {hit.sheet_name} · {hit.json_file} · " f"{kind_lbl}: {hit.sheet_name} · {hit.json_file} · "
f"row {hit.row} · {hit.field_name}{overflow_note}" 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 ( return (
f"{kind_lbl}: {hit.sheet_name} · {hit.json_file} · " f"{kind_lbl}: {hit.sheet_name} · {hit.json_file} · "
f"{hit.field_name}{overflow_note}" 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): def _update_wrap_preview(self):
if not hasattr(self, "_wrap_preview"): if not hasattr(self, "_wrap_preview"):
return return
@ -3071,11 +3153,21 @@ class WolfWorkflowTab(QWidget):
text = self._current_wrap_hit.text text = self._current_wrap_hit.text
width = self._active_wrap_width() width = self._active_wrap_width()
body_font = self._wrap_font_value() if self._wrap_mode() == "manual" else None 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. # Manual: preview scaled fonts. Relayout + max lines: preview fit-to-box.
preview_src = text preview_src = text
preview_width = width preview_width = width
if body_font is not None and preview_src.strip(): 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: elif self._wrap_mode() == "relayout" and width > 0:
max_lines = self._wrap_max_lines_value() max_lines = self._wrap_max_lines_value()
if max_lines > 0 and preview_src.strip(): 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): if isinstance(source, str) and wolf_codes.detect_font_sizes(source):
box_font = wolf_codes.infer_base_font_size(source) box_font = wolf_codes.infer_base_font_size(source)
preview_src, _ = wolf_ws.fit_text_to_box( 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. # 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( preview_width = max(
width, width,
dazedwrap.max_line_visible_length(preview_src), dazedwrap.max_line_visible_length(measure),
)
summary = wolf_ws.wrap_preview_summary(
preview_src, preview_width, **plate_kw
) )
summary = wolf_ws.wrap_preview_summary(preview_src, preview_width)
preview_html = wolf_ws.format_wrap_preview_html( preview_html = wolf_ws.format_wrap_preview_html(
preview_src if self._wrap_mode() == "relayout" else text, preview_src if self._wrap_mode() == "relayout" else text,
preview_width, preview_width,
body_font=body_font, 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 hasattr(self, "_wrap_preview_label"):
if summary: 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() max_lines = self._wrap_max_lines_value()
if ( if (
self._wrap_mode() == "relayout" self._wrap_mode() == "relayout"
@ -3137,7 +3240,10 @@ class WolfWorkflowTab(QWidget):
over_lines = False over_lines = False
if self._wrap_mode() == "relayout": if self._wrap_mode() == "relayout":
max_lines = self._wrap_max_lines_value() 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" border = "#ce9178" if info.get("needs_wrap") or over_lines else "#007acc"
self._wrap_preview.setStyleSheet( self._wrap_preview.setStyleSheet(
"QTextEdit{background-color:#1e1e1e;color:#d4d4d4;border:1px solid #3c3c3c;" "QTextEdit{background-color:#1e1e1e;color:#d4d4d4;border:1px solid #3c3c3c;"
@ -3199,8 +3305,10 @@ class WolfWorkflowTab(QWidget):
self._current_wrap_doc = doc self._current_wrap_doc = doc
if hit.kind == "names": if hit.kind == "names":
self._load_names_geometry_into_spins(hit) self._load_names_geometry_into_spins(hit)
elif hit.kind in ("map", "common"):
self._load_dialogue_geometry_into_spins()
else: 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.blockSignals(True)
self._wrap_width_spin.setValue(w) self._wrap_width_spin.setValue(w)
self._wrap_width_spin.blockSignals(False) self._wrap_width_spin.blockSignals(False)
@ -3271,6 +3379,8 @@ class WolfWorkflowTab(QWidget):
self._current_wrap_hit.sheet_name, self._current_wrap_hit.sheet_name,
width, width,
self._current_wrap_hit.json_file, self._current_wrap_hit.json_file,
font=font,
kind=self._current_wrap_hit.kind,
) )
path, doc, line = wolf_ws.load_hit_from_id( path, doc, line = wolf_ws.load_hit_from_id(
self._translated_and_files_dirs()[0], self._translated_and_files_dirs()[0],
@ -3327,6 +3437,7 @@ class WolfWorkflowTab(QWidget):
width, width,
self._current_wrap_hit.json_file, self._current_wrap_hit.json_file,
max_lines=max_lines or None, max_lines=max_lines or None,
kind=self._current_wrap_hit.kind,
) )
path, doc, line = wolf_ws.load_hit_from_id( path, doc, line = wolf_ws.load_hit_from_id(
self._translated_and_files_dirs()[0], self._translated_and_files_dirs()[0],
@ -3364,14 +3475,18 @@ class WolfWorkflowTab(QWidget):
if self._wrap_mode() == "manual": if self._wrap_mode() == "manual":
width = self._active_wrap_width() width = self._active_wrap_width()
font = self._wrap_font_value() font = self._wrap_font_value()
tdir = self._translated_and_files_dirs()[0]
count = wolf_ws.wrap_overflow_manual_in_scope( count = wolf_ws.wrap_overflow_manual_in_scope(
self._current_wrap_path, self._current_wrap_path,
self._current_wrap_doc, self._current_wrap_doc,
hit, hit,
width, width,
font=font, 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": if hit.kind == "names":
roles_path = wolf_names.roles_json_path_for_names(self._current_wrap_path) roles_path = wolf_names.roles_json_path_for_names(self._current_wrap_path)
wolf_names.upsert_name_wrap_role( wolf_names.upsert_name_wrap_role(
@ -3417,15 +3532,21 @@ class WolfWorkflowTab(QWidget):
) )
return return
max_lines = self._wrap_max_lines_value() max_lines = self._wrap_max_lines_value()
tdir = self._translated_and_files_dirs()[0]
count = wolf_ws.wrap_overflow_in_scope( count = wolf_ws.wrap_overflow_in_scope(
self._current_wrap_path, self._current_wrap_path,
self._current_wrap_doc, self._current_wrap_doc,
hit, hit,
width, width,
max_lines=max_lines or None, max_lines=max_lines or None,
translated_dir=tdir if hit.kind in ("map", "common") else None,
) )
self._remember_sheet_width( 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) scope = wolf_ws.scope_label(hit, self._current_wrap_doc)
if max_lines > 0: if max_lines > 0:

View file

@ -78,6 +78,25 @@ class WolfCodesRepairTests(unittest.TestCase):
fixed = wolf_codes.rebuild_text_preserving_source_codes(source, text) fixed = wolf_codes.rebuild_text_preserving_source_codes(source, text)
self.assertEqual(fixed, 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): def test_document_has_font_size_drift_for_db(self):
doc = { doc = {
"kind": "db", "kind": "db",

View file

@ -243,58 +243,87 @@ class TestManualFontAndWrap(unittest.TestCase):
class TestEventScopeWrap(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: with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "Map001.mps.json" root = Path(tmp)
long_a = ( long_a = (
"Celria\n"
"This is a very long line of dialogue that " "This is a very long line of dialogue that "
"should wrap at thirty four visible chars." "should wrap at thirty four visible chars."
) )
long_b = ( long_b = (
"Citizen\n"
"Another very long line of dialogue that also " "Another very long line of dialogue that also "
"should wrap at thirty four visible chars." "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 = { doc = {
"kind": "map", "kind": "common",
"file": "Map001.mps", "file": "CommonEvent.dat",
"scenes": [ "scenes": [
{ {
"event": 1, "event": 1,
"name": "Intro", "name": "Intro",
"lines": [ "lines": [
{"text": long_a}, {
{"text": "Short line."}, "speaker": "セルリア",
"speaker_src": "literal_line1_lowconf",
"text": long_a,
},
], ],
}, },
{ {
"event": 2, "event": 2,
"name": "Other", "name": "Town",
"lines": [{"text": long_b}], "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") path.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8")
hit = ws.WrapHit( hit = ws.WrapHit(
json_file="Map001.mps.json", json_file="CommonEvent.dat.json",
kind="map", kind="common",
sheet_name="Map001.mps", sheet_name="CommonEvent",
row=1, row=1,
field_name="UI", field_name="セルリア",
text="very long line", text="very long line",
max_line_len=80, max_line_len=80,
scene_index=0, scene_index=0,
line_index=0, line_index=0,
map_file="Map001.mps",
) )
stats = ws.scope_stats(doc, hit, 34) stats = ws.scope_stats(doc, hit, 34)
self.assertEqual(stats.total, 2) self.assertEqual(stats.total, 2)
self.assertEqual(stats.overflow, 1) self.assertEqual(stats.overflow, 2)
self.assertIn("event 1", stats.label) self.assertIn("spoken", stats.label)
self.assertIn("Intro", stats.label) changed = ws.wrap_overflow_in_scope(
self.assertEqual(ws.wrap_overflow_in_scope(path, doc, hit, 34), 1) path, doc, hit, 34, translated_dir=root
# Scene 2 must stay untouched. )
self.assertEqual(doc["scenes"][1]["lines"][0]["text"], long_b) self.assertEqual(changed, 2)
self.assertNotEqual(doc["scenes"][0]["lines"][0]["text"], long_a) 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): 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__": if __name__ == "__main__":
unittest.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 ``\\f[N]`` sizes are taken from *text* when present (manual wrap / names-wrap
shrink). Other control codes (``\\c``, ``\\^``, ) always come from *source*. shrink). Other control codes (``\\c``, ``\\^``, ) always come from *source*.
A leading body ``\\f[N]`` that *source* lacks is kept from *text*. 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): if not isinstance(source, str) or not isinstance(text, str):
return text return text
@ -272,6 +276,13 @@ def rebuild_text_preserving_source_codes(source: str, text: str) -> str:
if not src_parts: if not src_parts:
return out_prefix + txt_rest 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. # Rebuild using source code skeleton + translated literal slots.
# Font sizes prefer the translation (intentional shrink / body \\f). # Font sizes prefer the translation (intentional shrink / body \\f).
rebuilt: list[str] = [] rebuilt: list[str] = []
@ -297,6 +308,12 @@ def rebuild_text_preserving_source_codes(source: str, text: str) -> str:
rebuilt.append(val) rebuilt.append(val)
else: else:
if lit_i < len(txt_lit): if lit_i < len(txt_lit):
# 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]) rebuilt.append(txt_lit[lit_i])
lit_i += 1 lit_i += 1
else: else:

View file

@ -1,8 +1,15 @@
"""Search translated WolfDawn JSON for text to fix wrapping. """Search translated WolfDawn JSON for text to fix wrapping.
Paste in-game text, open the matching line, then wrap that line or every 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 / overflowing line in the same group:
CommonEvent file, or Game.dat).
* 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 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" WRAP_PROFILE_NAME = "wrap_profile.json"
DEFAULT_WRAP_WIDTH = 36 DEFAULT_WRAP_WIDTH = 36
# Legacy single-bucket key (migrated to spoken format on read).
DIALOGUE_PROFILE_KEY = "__dialogue__"
_APOSTROPHE_NORMALIZE = str.maketrans( _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 @dataclass
class WrapHit: class WrapHit:
"""One searchable line in translated JSON.""" """One searchable line in translated JSON."""
@ -174,6 +206,191 @@ def get_sheet_max_lines(
return default 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: def _load_json(path: Path) -> dict[str, Any] | None:
try: try:
return json.loads(path.read_text(encoding="utf-8-sig")) return json.loads(path.read_text(encoding="utf-8-sig"))
@ -346,7 +563,13 @@ def search_translated_text(
return hits 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.""" """Return wrapped text and metrics for the Step 7 live preview."""
if not isinstance(text, str) or not text.strip() or width <= 0: if not isinstance(text, str) or not text.strip() or width <= 0:
return { return {
@ -357,9 +580,21 @@ def wrap_preview_info(text: str, width: int) -> dict[str, Any]:
"output_line_count": 0, "output_line_count": 0,
"line_stats": [], "line_stats": [],
} }
wrapped = wrap_line_text(text, width) wrapped = wrap_preserving_nameplate(
norm_in = text.replace("\r\n", "\n").replace("\r", "\n") text, width, speaker_src=speaker_src, speaker=speaker
norm_out = wrapped.replace("\r\n", "\n").replace("\r", "\n") )
_, 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 [""] in_lines = norm_in.split("\n") if norm_in else [""]
out_lines = norm_out.split("\n") if norm_out else [""] out_lines = norm_out.split("\n") if norm_out else [""]
line_stats: list[dict[str, Any]] = [] 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), "input_line_count": len(in_lines),
"output_line_count": len(out_lines), "output_line_count": len(out_lines),
"line_stats": line_stats, "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.""" """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(): if not isinstance(text, str) or not text.strip():
return "" return ""
longest = int(info["longest"]) longest = int(info["longest"])
plate = f" (nameplate kept)" if info.get("nameplate") else ""
if not info["needs_wrap"]: if not info["needs_wrap"]:
return f"Fits at width {width} (longest line {longest} visible chars)."
return ( return (
f"Will wrap to {info['output_line_count']} line(s) at width {width} " f"Fits at width {width} (longest body line {longest} visible chars)"
f"(longest input line {longest} visible chars)." f"{plate}."
)
return (
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, width: int,
*, *,
body_font: int | None = None, body_font: int | None = None,
speaker_src: str = "",
speaker: str = "",
) -> str: ) -> str:
"""Rich HTML wrap preview: line gutters + simulated ``\\c`` / ``\\f`` styling. """Rich HTML wrap preview: line gutters + simulated ``\\c`` / ``\\f`` styling.
When *body_font* is set, proportionally scale every ``\\f[N]`` (and ensure a 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. 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 from util.wolfdawn import codes as wolf_codes
preview_text = text if isinstance(text, str) else "" preview_text = text if isinstance(text, str) else ""
if body_font is not None and int(body_font) > 0 and preview_text.strip(): plate_kw = {"speaker_src": speaker_src, "speaker": speaker}
preview_text, _ = wolf_codes.scale_font_sizes(preview_text, int(body_font)) 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 "" wrapped = info.get("wrapped") or ""
if not wrapped: if not wrapped:
return "" return ""
body = ( default_px = (
int(body_font) int(body_font)
if body_font is not None and int(body_font) > 0 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") lines = wrapped.replace("\r\n", "\n").replace("\r", "\n").split("\n")
rows: list[str] = [] rows: list[str] = []
for i, line in enumerate(lines): for i, line in enumerate(lines):
is_nameplate = bool(nameplate) and i == 0 and line == nameplate
vis = dazedwrap.max_line_visible_length(line) vis = dazedwrap.max_line_visible_length(line)
overflow = vis > width > 0 # Nameplate sits outside the message box - never flag as overflow.
gutter_color = "#ce9178" if overflow else "#858585" overflow = (not is_nameplate) and vis > width > 0
marker = "" if overflow else "&nbsp;" gutter_color = "#ce9178" if overflow else ("#c8b89a" if is_nameplate else "#858585")
marker = "" if overflow else ("" if is_nameplate else "&nbsp;")
gutter = ( gutter = (
f'<span style="color:{gutter_color};font-family:monospace;font-size:11px">' f'<span style="color:{gutter_color};font-family:monospace;font-size:11px">'
f"{marker} {i + 1:2d} ({vis:2d})&nbsp;&nbsp;</span>" f"{marker} {i + 1:2d} ({vis:2d})&nbsp;&nbsp;</span>"
) )
body_html = render_wolf_text_html( 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( rows.append(
f'<div style="white-space:pre-wrap;line-height:1.35;margin:0 0 2px 0">' 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") text = line.get("text")
if not isinstance(text, str) or not text.strip() or width <= 0: if not isinstance(text, str) or not text.strip() or width <= 0:
return False 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: if new_text == text:
return False return False
line["text"] = new_text line["text"] = new_text
@ -641,6 +905,19 @@ def count_soft_lines(text: str) -> int:
return max(1, len(norm.split("\n"))) 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( def fit_text_to_box(
text: str, text: str,
width: int, width: int,
@ -648,6 +925,8 @@ def fit_text_to_box(
max_lines: int | None = None, max_lines: int | None = None,
min_font: int = 8, min_font: int = 8,
box_font: int | None = None, box_font: int | None = None,
speaker_src: str = "",
speaker: str = "",
) -> tuple[str, bool]: ) -> tuple[str, bool]:
"""Reflow *text* to *width*, then shrink ``\\f[N]`` until it fits *max_lines*. """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`` / ``round(width * box_font / current_font)`` (same idea as ``wolf relayout`` /
``desc-relayout``). *box_font* defaults to the text's current body size ``desc-relayout``). *box_font* defaults to the text's current body size
(or 18 when there is no ``\\f`` yet). (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 from util.wolfdawn import codes as wolf_codes
if not isinstance(text, str) or not text.strip() or width <= 0: if not isinstance(text, str) or not text.strip() or width <= 0:
return text if isinstance(text, str) else "", False 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 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: if box_font is not None and int(box_font) > 0:
native = int(box_font) native = int(box_font)
elif sizes: elif sizes:
native = wolf_codes.infer_base_font_size(text) native = wolf_codes.infer_base_font_size(work)
else: else:
native = 18 native = 18
native = max(1, native) native = max(1, native)
@ -680,28 +990,37 @@ def fit_text_to_box(
cur = max(1, int(current_font)) cur = max(1, int(current_font))
return max(1, int(round(width * native / cur))) return max(1, int(round(width * native / cur)))
current = wolf_codes.infer_base_font_size(text, default=native) if sizes else native current = wolf_codes.infer_base_font_size(work, default=native) if sizes else native
new_text = wrap_line_text(text, _effective_width(current) if sizes else width) 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: 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 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_body) > limit:
if not sizes and count_soft_lines(new_text) > limit: new_body, _ = wolf_codes.scale_font_sizes(new_body, native)
new_text, _ = wolf_codes.scale_font_sizes(new_text, native)
current = 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 guard = 0
while count_soft_lines(new_text) > limit and guard < 64: while count_soft_lines(new_body) > limit and guard < 64:
guard += 1 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: if current <= min_font:
break break
target = current - 1 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 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 return new_text, new_text != text
@ -730,6 +1049,8 @@ def apply_relayout_to_line_dict(
max_lines=max_lines, max_lines=max_lines,
min_font=min_font, min_font=min_font,
box_font=native, box_font=native,
speaker_src=str(line.get("speaker_src") or ""),
speaker=str(line.get("speaker") or ""),
) )
if not changed: if not changed:
return False return False
@ -744,12 +1065,15 @@ def apply_manual_font_and_wrap(
font: int | None = None, font: int | None = None,
old_base: int | None = None, old_base: int | None = None,
only_overflow_or_fonts: bool = False, only_overflow_or_fonts: bool = False,
speaker_src: str = "",
speaker: str = "",
) -> tuple[str, bool]: ) -> tuple[str, bool]:
"""Scale body ``\\f`` (keeping emphasis ratios), then reflow to *width*. """Scale body ``\\f`` (keeping emphasis ratios), then reflow to *width*.
Always reflows when the wrap layout would differ from the current text 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 (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. 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 When *only_overflow_or_fonts* is True (group wrap), skip short plain lines
that already match the target layout and have no ``\\f[N]``. 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(): if not isinstance(text, str) or not text.strip():
return text, False return text, False
has_f = bool(wolf_codes.detect_font_sizes(text)) plate_kw = {"speaker_src": speaker_src, "speaker": speaker}
overflows = width > 0 and line_needs_wrap(text, width) prefix, nameplate, body = split_nameplate_body(text, **plate_kw)
wrapped_as_is = wrap_line_text(text, width) if width > 0 else text 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 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: if only_overflow_or_fonts and not has_f and not overflows and not layout_differs:
return text, False return text, False
new_text = text new_work = work
apply_font = ( apply_font = (
font is not None font is not None
and int(font) > 0 and int(font) > 0
and (has_f or overflows or layout_differs or not only_overflow_or_fonts) and (has_f or overflows or layout_differs or not only_overflow_or_fonts)
) )
if apply_font: if apply_font:
new_text, _ = wolf_codes.scale_font_sizes( new_work, _ = wolf_codes.scale_font_sizes(
new_text, int(font), old_base=old_base new_work, int(font), old_base=old_base
) )
if width > 0: if width > 0:
wrapped = wrap_line_text(new_text, width) wrapped = wrap_line_text(new_work, width)
if wrapped != new_text: if wrapped != new_work:
new_text = wrapped 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 return new_text, new_text != text
@ -803,7 +1142,13 @@ def wrap_hit_manual(
text = line.get("text") text = line.get("text")
if not isinstance(text, str): if not isinstance(text, str):
return False 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: if not changed:
return False return False
line["text"] = new_text line["text"] = new_text
@ -818,6 +1163,7 @@ def wrap_overflow_manual_in_scope(
width: int, width: int,
*, *,
font: int | None = None, font: int | None = None,
translated_dir: Path | None = None,
) -> int: ) -> int:
"""Manual wrap (+ optional proportional font) for every line in *hit*'s group.""" """Manual wrap (+ optional proportional font) for every line in *hit*'s group."""
kind, sheet_name = _hit_kind_and_sheet(hit, doc) kind, sheet_name = _hit_kind_and_sheet(hit, doc)
@ -827,8 +1173,13 @@ def wrap_overflow_manual_in_scope(
if kind == "db": if kind == "db":
return _wrap_manual_in_db_sheet(path, doc, sheet_name, width, font=font) return _wrap_manual_in_db_sheet(path, doc, sheet_name, width, font=font)
if kind in ("map", "common"): if kind in ("map", "common"):
return _wrap_manual_in_event_scene( speaker_src = _hit_speaker_src(hit, doc)
path, doc, _hit_scene_index(hit), width, font=font 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": if kind == "gamedat":
return _wrap_manual_in_gamedat(path, doc, width, font=font) 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(): if not isinstance(text, str) or not text.strip():
return False return False
new_text, changed = apply_manual_font_and_wrap( 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: if not changed:
return False return False
@ -898,22 +1254,23 @@ def _wrap_manual_in_db_sheet(
return changed return changed
def _wrap_manual_in_event_scene( def _wrap_manual_in_event_format(
path: Path, path: Path,
doc: dict[str, Any], doc: dict[str, Any],
scene_index: int | None, speaker_src: str,
width: int, width: int,
*, *,
font: int | None = None, font: int | None = None,
) -> int: ) -> 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"): if doc.get("kind") not in ("map", "common"):
return 0 return 0
scenes = doc.get("scenes") or [] want = wrap_format_key(speaker_src)
if scene_index is None or not (0 <= scene_index < len(scenes)):
return 0
changed = 0 changed = 0
for line in scenes[scene_index].get("lines") or []: 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): if _apply_manual_to_line_dict(line, width, font=font):
changed += 1 changed += 1
if changed: if changed:
@ -921,6 +1278,28 @@ def _wrap_manual_in_event_scene(
return changed 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( def _wrap_manual_in_gamedat(
path: Path, path: Path,
doc: dict[str, Any], doc: dict[str, Any],
@ -968,8 +1347,24 @@ def _hit_scene_index(hit: WrapHit | dict[str, Any]) -> int | None:
return 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: 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) kind, sheet = _hit_kind_and_sheet(hit, doc)
si = _hit_scene_index(hit) si = _hit_scene_index(hit)
scene: dict[str, Any] | None = None 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": if kind == "gamedat":
return "Game.dat" return "Game.dat"
if kind in ("map", "common"): if kind in ("map", "common"):
return _scene_group_label(doc, hit) return wrap_format_label(_hit_speaker_src(hit, doc))
return sheet or jf return sheet or jf
@ -1056,20 +1451,21 @@ def scope_stats(
if line_needs_wrap(text, width): if line_needs_wrap(text, width):
overflow += 1 overflow += 1
elif kind in ("map", "common"): elif kind in ("map", "common"):
si = _hit_scene_index(hit) want = wrap_format_key(_hit_speaker_src(hit, doc))
scenes = doc.get("scenes") or [] for scene in doc.get("scenes") or []:
if si is not None and 0 <= si < len(scenes): for line in scene.get("lines") or []:
scene_iter = [scenes[si]] if wrap_format_key(str(line.get("speaker_src") or "")) != want:
else: continue
# 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 []):
text = line.get("text") text = line.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
continue continue
total += 1 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 overflow += 1
elif kind == "gamedat": elif kind == "gamedat":
for line in doc.get("lines") or []: for line in doc.get("lines") or []:
@ -1085,37 +1481,52 @@ def scope_stats(
return ScopeStats(label=scope_label(hit, doc), 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*.""" """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 return True
limit = int(max_lines) if max_lines is not None else 0 limit = int(max_lines) if max_lines is not None else 0
if limit <= 0: if limit <= 0:
return False return False
wrapped = wrap_line_text(text, width) wrapped = wrap_preserving_nameplate(text, width, **plate_kw)
return count_soft_lines(wrapped) > limit or wrapped != text 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, path: Path,
doc: dict[str, Any], doc: dict[str, Any],
scene_index: int | None, speaker_src: str,
width: int, width: int,
*, *,
max_lines: int | None = None, max_lines: int | None = None,
) -> int: ) -> 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"): if doc.get("kind") not in ("map", "common"):
return 0 return 0
scenes = doc.get("scenes") or [] want = wrap_format_key(speaker_src)
if scene_index is None or not (0 <= scene_index < len(scenes)):
return 0
changed = 0 changed = 0
for line in scenes[scene_index].get("lines") or []: 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") text = line.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
continue 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 continue
if apply_relayout_to_line_dict(line, width, max_lines=max_lines): if apply_relayout_to_line_dict(line, width, max_lines=max_lines):
changed += 1 changed += 1
@ -1124,6 +1535,28 @@ def _wrap_overflow_in_event_scene(
return changed 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( def _wrap_overflow_in_gamedat(
path: Path, path: Path,
doc: dict[str, Any], doc: dict[str, Any],
@ -1156,18 +1589,17 @@ def wrap_overflow_in_scope(
width: int, width: int,
*, *,
max_lines: int | None = None, max_lines: int | None = None,
translated_dir: Path | None = None,
) -> int: ) -> 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 For DB / gamedat, *max_lines* also shrinks ``\\f[N]`` until the soft line
the soft line count fits (Relayout). Names categories ignore *max_lines* 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. here - use ``wolf names-wrap`` via the GUI instead.
""" """
if isinstance(hit, WrapHit): kind, sheet_name = _hit_kind_and_sheet(hit, doc)
kind, sheet_name = hit.kind, hit.sheet_name
else:
kind = str(hit.get("kind") or doc.get("kind") or "")
sheet_name = str(hit.get("sheet_name") or "")
if kind == "names": if kind == "names":
return _wrap_overflow_in_names_category(path, doc, sheet_name, width) 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 path, doc, sheet_name, width, kind="db", max_lines=max_lines
) )
if kind in ("map", "common"): if kind in ("map", "common"):
return _wrap_overflow_in_event_scene( speaker_src = _hit_speaker_src(hit, doc)
path, doc, _hit_scene_index(hit), width, max_lines=max_lines 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": if kind == "gamedat":
return _wrap_overflow_in_gamedat(path, doc, width, max_lines=max_lines) return _wrap_overflow_in_gamedat(path, doc, width, max_lines=max_lines)