diff --git a/gui/wolf_workflow_tab.py b/gui/wolf_workflow_tab.py index f68543c..cdaf6e6 100644 --- a/gui/wolf_workflow_tab.py +++ b/gui/wolf_workflow_tab.py @@ -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: diff --git a/tests/test_wolf_codes.py b/tests/test_wolf_codes.py index d0d98db..29df15f 100644 --- a/tests/test_wolf_codes.py +++ b/tests/test_wolf_codes.py @@ -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", diff --git a/tests/test_wolf_wrap_search.py b/tests/test_wolf_wrap_search.py index 931f7c2..d28ea49 100644 --- a/tests/test_wolf_wrap_search.py +++ b/tests/test_wolf_wrap_search.py @@ -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() diff --git a/util/wolfdawn/codes.py b/util/wolfdawn/codes.py index 5687641..536e3b3 100644 --- a/util/wolfdawn/codes.py +++ b/util/wolfdawn/codes.py @@ -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) diff --git a/util/wolfdawn/wrap_search.py b/util/wolfdawn/wrap_search.py index 877af81..460c323 100644 --- a/util/wolfdawn/wrap_search.py +++ b/util/wolfdawn/wrap_search.py @@ -1,8 +1,15 @@ """Search translated WolfDawn JSON for text to fix wrapping. Paste in-game text, open the matching line, then wrap that line or every -overflowing line in the same group (database sheet, names category, map / -CommonEvent file, or Game.dat). +overflowing line in the same group: + +* database sheet / names category / Game.dat file +* map / CommonEvent: group = ``speaker_src`` format (spoken / ui / …) across + all map + CommonEvent JSON; shared width/font remembered per format + +Dialogue lines with ``speaker_src`` ``literal_line1`` / ``literal_line1_lowconf`` +keep the nameplate on its own first line (``Celria\\n…``); only the body is +reflowed. """ from __future__ import annotations @@ -18,6 +25,8 @@ from util.wolfdawn.selective_wrap import line_needs_wrap, wrap_line_text WRAP_PROFILE_NAME = "wrap_profile.json" DEFAULT_WRAP_WIDTH = 36 +# Legacy single-bucket key (migrated to spoken format on read). +DIALOGUE_PROFILE_KEY = "__dialogue__" _APOSTROPHE_NORMALIZE = str.maketrans( { @@ -29,6 +38,29 @@ _APOSTROPHE_NORMALIZE = str.maketrans( ) +def wrap_format_key(speaker_src: str | None) -> str: + """Profile key for shared wrap geometry by WolfDawn ``speaker_src``. + + Same detection class ≈ same on-screen box across maps/CommonEvent. + ``literal_line1`` and ``literal_line1_lowconf`` share one spoken-dialogue + bucket; other tags (``ui``, ``narration``, ``choice``, …) each get their own. + """ + src = str(speaker_src or "").strip().lower() or "default" + if src in ("literal_line1", "literal_line1_lowconf"): + return "__format:spoken__" + safe = re.sub(r"[^a-z0-9_]+", "_", src).strip("_") or "default" + return f"__format:{safe}__" + + +def wrap_format_label(speaker_src: str | None) -> str: + """Short UI label for the format bucket of *speaker_src*.""" + key = wrap_format_key(speaker_src) + if key == "__format:spoken__": + return "spoken dialogue (Name\\nbody)" + tag = key.removeprefix("__format:").removesuffix("__") + return f"format:{tag}" + + @dataclass class WrapHit: """One searchable line in translated JSON.""" @@ -174,6 +206,191 @@ def get_sheet_max_lines( return default +def get_format_geometry( + profile: dict[str, Any], + speaker_src: str | None, +) -> dict[str, int]: + """Return shared wrap settings for this ``speaker_src`` format bucket.""" + sheets = profile.get("sheets") or {} + key = wrap_format_key(speaker_src) + entry = sheets.get(key) + # Migrate legacy single dialogue bucket → spoken format only. + if not isinstance(entry, dict) and key == "__format:spoken__": + entry = sheets.get(DIALOGUE_PROFILE_KEY) + out: dict[str, int] = {} + if not isinstance(entry, dict): + return out + for field in ("width", "max_lines", "font"): + if entry.get(field) is None: + continue + try: + out[field] = int(entry[field]) + except (TypeError, ValueError): + continue + return out + + +def set_format_geometry( + work_dir: str | Path, + speaker_src: str | None, + *, + width: int | None = None, + max_lines: int | None = None, + font: int | None = None, +) -> None: + """Persist wrap settings for one ``speaker_src`` format (map + CommonEvent).""" + profile = load_wrap_profile(work_dir) + sheets = profile.setdefault("sheets", {}) + key = wrap_format_key(speaker_src) + entry = sheets.get(key) + if not isinstance(entry, dict): + entry = {"json_file": key, "speaker_src": str(speaker_src or "")} + sheets[key] = entry + if width is not None and int(width) > 0: + entry["width"] = int(width) + if max_lines is not None: + if int(max_lines) > 0: + entry["max_lines"] = int(max_lines) + else: + entry.pop("max_lines", None) + if font is not None: + if int(font) > 0: + entry["font"] = int(font) + else: + entry.pop("font", None) + entry["speaker_src"] = str(speaker_src or "") + save_wrap_profile(work_dir, profile) + + +# Back-compat aliases (spoken / legacy __dialogue__). +def get_dialogue_geometry(profile: dict[str, Any]) -> dict[str, int]: + return get_format_geometry(profile, "literal_line1") + + +def set_dialogue_geometry( + work_dir: str | Path, + *, + width: int | None = None, + max_lines: int | None = None, + font: int | None = None, +) -> None: + set_format_geometry( + work_dir, + "literal_line1", + width=width, + max_lines=max_lines, + font=font, + ) + + +def split_nameplate_body( + text: str, + *, + speaker_src: str = "", + speaker: str = "", +) -> tuple[str, str, str]: + """Split ``Name\\nbody`` dialogue into ``(prefix, nameplate, body)``. + + Uses WolfDawn ``speaker_src`` (``literal_line1`` / ``literal_line1_lowconf``) + when enabled. Falls back to matching the ``speaker`` field to line 1. + When there is no nameplate, returns ``("", "", text)``. + """ + from util import speakers as wolf_speakers + + if not isinstance(text, str) or not text: + return "", "", text if isinstance(text, str) else "" + + # Normalize newlines so ``\\r\\n`` nameplates still split cleanly. + text = text.replace("\r\n", "\n").replace("\r", "\n") + + src = str(speaker_src or "") + if wolf_speakers.is_firstline_enabled(src): + parts = wolf_speakers.split_source(text, src) + if parts is not None: + prefix, line1, body = parts + return prefix, line1, body + + name = str(speaker or "").strip() + if name and "\n" in text: + prefix, rest = wolf_speakers.split_window_prefix(text) + line1, body = rest.split("\n", 1) + # Match plain name or ``\\f[N]Name`` nameplate to the speaker field. + plate_core = re.sub(r"^(\\f\[\d+\])+", "", line1).strip() + if plate_core == name or line1.strip() == name: + return prefix, line1, body + return "", "", text + + +def join_nameplate_body(prefix: str, nameplate: str, body: str) -> str: + """Reassemble a nameplate dialogue line.""" + if nameplate: + return f"{prefix}{nameplate}\n{body}" + return f"{prefix}{body}" + + +def _reject_empty_body_result(original: str, new_text: str) -> str: + """Never allow wrap/font passes to drop a previously non-empty body. + + A past group-wrap bug left thousands of spoken lines as ``\\f[N]Name\\n`` with + an empty body. Refuse that class of result and keep *original*. + """ + if not isinstance(original, str) or not isinstance(new_text, str): + return original if isinstance(original, str) else new_text + if "\n" not in original: + return new_text + old_body = original.split("\n", 1)[1] + if not old_body.strip(): + return new_text + if "\n" not in new_text: + return original + new_body = new_text.split("\n", 1)[1] + if not new_body.strip(): + return original + return new_text + + +def wrap_preserving_nameplate( + text: str, + width: int, + *, + speaker_src: str = "", + speaker: str = "", +) -> str: + """Word-wrap *text*, keeping a leading speaker nameplate on its own line.""" + if not isinstance(text, str) or not text.strip() or width <= 0: + return text if isinstance(text, str) else "" + prefix, nameplate, body = split_nameplate_body( + text, speaker_src=speaker_src, speaker=speaker + ) + if not nameplate: + return wrap_line_text(text, width) + if not body.strip(): + return text + wrapped_body = wrap_line_text(body, width) + if not wrapped_body.strip() and body.strip(): + return text + return _reject_empty_body_result( + text, join_nameplate_body(prefix, nameplate, wrapped_body) + ) + + +def line_needs_wrap_preserving_nameplate( + text: str, + width: int, + *, + speaker_src: str = "", + speaker: str = "", +) -> bool: + """Overflow check that ignores the nameplate line (body only when present).""" + if not isinstance(text, str) or not text.strip() or width <= 0: + return False + prefix, nameplate, body = split_nameplate_body( + text, speaker_src=speaker_src, speaker=speaker + ) + check = body if nameplate else text + return line_needs_wrap(check, width) + + def _load_json(path: Path) -> dict[str, Any] | None: try: return json.loads(path.read_text(encoding="utf-8-sig")) @@ -346,7 +563,13 @@ def search_translated_text( return hits -def wrap_preview_info(text: str, width: int) -> dict[str, Any]: +def wrap_preview_info( + text: str, + width: int, + *, + speaker_src: str = "", + speaker: str = "", +) -> dict[str, Any]: """Return wrapped text and metrics for the Step 7 live preview.""" if not isinstance(text, str) or not text.strip() or width <= 0: return { @@ -357,9 +580,21 @@ def wrap_preview_info(text: str, width: int) -> dict[str, Any]: "output_line_count": 0, "line_stats": [], } - wrapped = wrap_line_text(text, width) - norm_in = text.replace("\r\n", "\n").replace("\r", "\n") - norm_out = wrapped.replace("\r\n", "\n").replace("\r", "\n") + wrapped = wrap_preserving_nameplate( + text, width, speaker_src=speaker_src, speaker=speaker + ) + _, nameplate, body = split_nameplate_body( + text, speaker_src=speaker_src, speaker=speaker + ) + # Metrics / overflow are about the dialogue body (nameplate is outside the box). + measure_in = body if nameplate else text + measure_out = ( + split_nameplate_body(wrapped, speaker_src=speaker_src, speaker=speaker)[2] + if nameplate + else wrapped + ) + norm_in = measure_in.replace("\r\n", "\n").replace("\r", "\n") + norm_out = measure_out.replace("\r\n", "\n").replace("\r", "\n") in_lines = norm_in.split("\n") if norm_in else [""] out_lines = norm_out.split("\n") if norm_out else [""] line_stats: list[dict[str, Any]] = [] @@ -382,20 +617,33 @@ def wrap_preview_info(text: str, width: int) -> dict[str, Any]: "input_line_count": len(in_lines), "output_line_count": len(out_lines), "line_stats": line_stats, + "nameplate": nameplate, } -def wrap_preview_summary(text: str, width: int) -> str: +def wrap_preview_summary( + text: str, + width: int, + *, + speaker_src: str = "", + speaker: str = "", +) -> str: """One-line status for the preview header.""" - info = wrap_preview_info(text, width) + info = wrap_preview_info( + text, width, speaker_src=speaker_src, speaker=speaker + ) if not isinstance(text, str) or not text.strip(): return "" longest = int(info["longest"]) + plate = f" (nameplate kept)" if info.get("nameplate") else "" if not info["needs_wrap"]: - return f"Fits at width {width} (longest line {longest} visible chars)." + return ( + f"Fits at width {width} (longest body line {longest} visible chars)" + f"{plate}." + ) return ( - f"Will wrap to {info['output_line_count']} line(s) at width {width} " - f"(longest input line {longest} visible chars)." + f"Will wrap body to {info['output_line_count']} line(s) at width {width} " + f"(longest body line {longest} visible chars){plate}." ) @@ -499,41 +747,52 @@ def format_wrap_preview_html( width: int, *, body_font: int | None = None, + speaker_src: str = "", + speaker: str = "", ) -> str: """Rich HTML wrap preview: line gutters + simulated ``\\c`` / ``\\f`` styling. When *body_font* is set, proportionally scale every ``\\f[N]`` (and ensure a leading body size) before wrapping/rendering - same as Manual wrap would do. + Nameplate lines stay on their own first row in the preview. """ from util.wolfdawn import codes as wolf_codes preview_text = text if isinstance(text, str) else "" - if body_font is not None and int(body_font) > 0 and preview_text.strip(): - preview_text, _ = wolf_codes.scale_font_sizes(preview_text, int(body_font)) + plate_kw = {"speaker_src": speaker_src, "speaker": speaker} + prefix, nameplate, body = split_nameplate_body(preview_text, **plate_kw) + work = body if nameplate else preview_text + if body_font is not None and int(body_font) > 0 and work.strip(): + work, _ = wolf_codes.scale_font_sizes(work, int(body_font)) + preview_text = ( + join_nameplate_body(prefix, nameplate, work) if nameplate else work + ) - info = wrap_preview_info(preview_text, width) + info = wrap_preview_info(preview_text, width, **plate_kw) wrapped = info.get("wrapped") or "" if not wrapped: return "" - body = ( + default_px = ( int(body_font) if body_font is not None and int(body_font) > 0 - else wolf_codes.infer_base_font_size(preview_text, default=18) + else wolf_codes.infer_base_font_size(work if nameplate else preview_text, default=18) ) lines = wrapped.replace("\r\n", "\n").replace("\r", "\n").split("\n") rows: list[str] = [] for i, line in enumerate(lines): + is_nameplate = bool(nameplate) and i == 0 and line == nameplate vis = dazedwrap.max_line_visible_length(line) - overflow = vis > width > 0 - gutter_color = "#ce9178" if overflow else "#858585" - marker = "⚠" if overflow else " " + # Nameplate sits outside the message box - never flag as overflow. + overflow = (not is_nameplate) and vis > width > 0 + gutter_color = "#ce9178" if overflow else ("#c8b89a" if is_nameplate else "#858585") + marker = "⚠" if overflow else ("◆" if is_nameplate else " ") gutter = ( f'' f"{marker} {i + 1:2d} ({vis:2d}) " ) 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'