"""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 / 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 import json import re from dataclasses import dataclass from pathlib import Path from typing import Any, Sequence import util.dazedwrap as dazedwrap 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( { "\u2019": "'", "\u2018": "'", "\u02bc": "'", "`": "'", } ) 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.""" json_file: str kind: str sheet_name: str row: int | None field_name: str text: str max_line_len: int scene_index: int | None = None line_index: int | None = None map_file: str | None = None @property def hit_id(self) -> dict[str, Any]: """Stable locator stored in the UI.""" loc: dict[str, Any] = { "json_file": self.json_file, "kind": self.kind, "sheet_name": self.sheet_name, "field_name": self.field_name, } if self.row is not None: loc["row"] = self.row if self.kind == "names" and self.row is not None: loc["name_index"] = self.row if self.scene_index is not None: loc["scene_index"] = self.scene_index if self.line_index is not None: loc["line_index"] = self.line_index return loc def summary(self, width: int = 0) -> str: overflow = "" if width > 0 and self.max_line_len > width: overflow = " · overflow" elif self.max_line_len: overflow = f" · {self.max_line_len} chars" preview = self.text.replace("\n", " ").replace("\r", " ").strip() if len(preview) > 120: preview = preview[:120] + "…" if self.kind == "db": loc = f"row {self.row} · {self.field_name}" elif self.kind == "names": loc = f"entry #{self.row}" elif self.kind == "gamedat": loc = self.field_name or f"line {self.line_index}" else: loc = self.field_name or self.map_file or self.json_file # Translated text first so it stays visible even if the row is short; # metadata is secondary. return ( f"{preview}\n" f"{self.sheet_name} · {self.json_file} · {loc}{overflow}" ) def wrap_profile_path(work_dir: str | Path) -> Path: return Path(work_dir) / WRAP_PROFILE_NAME def load_wrap_profile(work_dir: str | Path) -> dict[str, Any]: path = wrap_profile_path(work_dir) if not path.is_file(): return {"default_width": DEFAULT_WRAP_WIDTH, "sheets": {}} try: data = json.loads(path.read_text(encoding="utf-8")) if not isinstance(data, dict): return {"default_width": DEFAULT_WRAP_WIDTH, "sheets": {}} data.setdefault("default_width", DEFAULT_WRAP_WIDTH) data.setdefault("sheets", {}) return data except Exception: return {"default_width": DEFAULT_WRAP_WIDTH, "sheets": {}} def save_wrap_profile(work_dir: str | Path, profile: dict[str, Any]) -> None: path = wrap_profile_path(work_dir) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(profile, ensure_ascii=False, indent=4) + "\n", encoding="utf-8") def get_sheet_width( profile: dict[str, Any], sheet_name: str, *, default: int | None = None, ) -> int: sheets = profile.get("sheets") or {} entry = sheets.get(sheet_name) if isinstance(entry, dict) and entry.get("width"): try: return int(entry["width"]) except (TypeError, ValueError): pass if default is not None: return default try: return int(profile.get("default_width") or DEFAULT_WRAP_WIDTH) except (TypeError, ValueError): return DEFAULT_WRAP_WIDTH def set_sheet_width( work_dir: str | Path, sheet_name: str, width: int, *, json_file: str, max_lines: int | None = None, ) -> None: profile = load_wrap_profile(work_dir) sheets = profile.setdefault("sheets", {}) entry = sheets.get(sheet_name) if not isinstance(entry, dict): entry = {} sheets[sheet_name] = entry entry["width"] = int(width) entry["json_file"] = json_file if max_lines is not None and int(max_lines) > 0: entry["max_lines"] = int(max_lines) elif "max_lines" in entry and max_lines == 0: entry.pop("max_lines", None) profile["default_width"] = profile.get("default_width", DEFAULT_WRAP_WIDTH) save_wrap_profile(work_dir, profile) def get_sheet_max_lines( profile: dict[str, Any], sheet_name: str, *, default: int = 0, ) -> int: sheets = profile.get("sheets") or {} entry = sheets.get(sheet_name) if isinstance(entry, dict) and entry.get("max_lines") is not None: try: return int(entry["max_lines"]) except (TypeError, ValueError): pass 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")) except Exception: return None def _normalize_query(query: str) -> str: return " ".join(query.lower().translate(_APOSTROPHE_NORMALIZE).split()) def _normalize_match_text(text: str) -> str: return text.lower().translate(_APOSTROPHE_NORMALIZE) def _text_matches(text: str, query_norm: str) -> bool: if not query_norm or not isinstance(text, str): return False hay = _normalize_match_text(text) if query_norm in hay: return True # In-game UI often hides line breaks; allow matching across newlines. collapsed = " ".join(hay.split()) return query_norm in collapsed def _iter_search_dirs( translated_dir: Path, files_dir: Path | None, extra_dirs: Sequence[str | Path] | None = None, ) -> list[Path]: dirs: list[Path] = [] for candidate in (translated_dir, files_dir, *(extra_dirs or ())): if candidate is None: continue p = Path(candidate) if p.is_dir() and p not in dirs: dirs.append(p) return dirs def search_translated_text( query: str, translated_dir: str | Path, *, files_dir: str | Path | None = None, extra_dirs: Sequence[str | Path] | None = None, limit: int = 50, ) -> list[WrapHit]: """Find lines containing *query* in translated, files/, and optional extra dirs.""" if not query.strip(): return [] q = _normalize_query(query) hits: list[WrapHit] = [] tdir = Path(translated_dir) fdir = Path(files_dir) if files_dir else tdir.parent / "files" seen: set[tuple] = set() for base in _iter_search_dirs(tdir, fdir, extra_dirs): for path in sorted(base.glob("*.json")): doc = _load_json(path) if not doc: continue kind = doc.get("kind") jf = path.name if kind == "db": for group in doc.get("groups") or []: sheet = str(group.get("typeName") or "") for line in group.get("lines") or []: text = line.get("text") if not _text_matches(text, q): continue row = line.get("row") field = str(line.get("fieldName") or "") key = (jf, sheet, row, field) if key in seen: continue seen.add(key) hits.append( WrapHit( json_file=jf, kind="db", sheet_name=sheet, row=int(row) if row is not None else None, field_name=field, text=str(text), max_line_len=dazedwrap.max_line_visible_length(str(text)), ) ) elif kind == "names": for idx, entry in enumerate(doc.get("names") or []): if not isinstance(entry, dict): continue text = entry.get("text") source = entry.get("source") # Match translated text (preferred) or JP source so either side finds the row. if not (_text_matches(text, q) or _text_matches(source, q)): continue note = str(entry.get("note") or "names.json") display = str(text) if isinstance(text, str) and text.strip() else str(source or "") key = (jf, "names", idx) if key in seen: continue seen.add(key) hits.append( WrapHit( json_file=jf, kind="names", sheet_name=note, row=idx, field_name=f"entry {idx}", text=display, max_line_len=dazedwrap.max_line_visible_length(display), ) ) elif kind == "gamedat": for li, line in enumerate(doc.get("lines") or []): if not isinstance(line, dict): continue text = line.get("text") if not _text_matches(text, q): continue key = (jf, "gamedat", li) if key in seen: continue seen.add(key) field = str(line.get("key") or line.get("fieldName") or f"line {li}") hits.append( WrapHit( json_file=jf, kind="gamedat", sheet_name="Game.dat", row=li, field_name=field, text=str(text), max_line_len=dazedwrap.max_line_visible_length(str(text)), line_index=li, ) ) elif kind in ("map", "common"): label = doc.get("file") or jf sheet = "CommonEvent" if kind == "common" else str(label) for si, scene in enumerate(doc.get("scenes") or []): for li, line in enumerate(scene.get("lines") or []): text = line.get("text") if not _text_matches(text, q): continue key = (jf, si, li) if key in seen: continue seen.add(key) speaker = str(line.get("speaker") or "") hits.append( WrapHit( json_file=jf, kind=kind, sheet_name=sheet, row=scene.get("event"), field_name=speaker or f"scene {si} line {li}", text=str(text), max_line_len=dazedwrap.max_line_visible_length(str(text)), scene_index=si, line_index=li, map_file=str(label) if kind == "map" else None, ) ) if len(hits) >= limit: return hits return hits 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 { "wrapped": text if isinstance(text, str) else "", "needs_wrap": False, "longest": 0, "input_line_count": 0, "output_line_count": 0, "line_stats": [], } 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]] = [] for i, line in enumerate(in_lines): vis = dazedwrap.max_line_visible_length(line) line_stats.append( { "line": i + 1, "visible": vis, "overflow": max(0, vis - width), "needs_wrap": vis > width, } ) longest = max((s["visible"] for s in line_stats), default=0) needs_wrap = wrapped != text or any(s["needs_wrap"] for s in line_stats) return { "wrapped": wrapped, "needs_wrap": needs_wrap, "longest": longest, "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, *, speaker_src: str = "", speaker: str = "", ) -> str: """One-line status for the preview header.""" 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 body 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}." ) # Approximate System DB Type 12 colours for preview only (games-specific in real Wolf). _WOLF_PREVIEW_COLORS: dict[int, str] = { 0: "#d4d4d4", 1: "#ff6b6b", 2: "#4ecdc4", 3: "#ffe66d", 4: "#95e1a3", 5: "#a78bfa", 6: "#f9a8d4", 7: "#67e8f9", 8: "#fdba74", 9: "#86efac", 10: "#f87171", 13: "#c4b5fd", # common ruby colour slot 16: "#fbbf24", 18: "#f472b6", 19: "#c8b89a", # body / restore (cafe-style games) 20: "#93c5fd", 21: "#f0c040", # emphasis / place names } _WOLF_PREVIEW_TOKEN_RE = re.compile( r"\\c\[(\d+)\]|" r"\\f\[(\d+)\]|" r"\\r\[[^\]]*\]|" r"\\[A-Za-z]+\[[^\]]*\]|" r"\\." ) def wolf_preview_color(index: int, *, default: str = "#d4d4d4") -> str: """Return a CSS hex colour for Wolf ``\\c[index]`` (preview approximation).""" try: idx = int(index) except (TypeError, ValueError): return default if idx in _WOLF_PREVIEW_COLORS: return _WOLF_PREVIEW_COLORS[idx] # Stable distinct hue for unknown System DB slots. hue = (idx * 47) % 360 return f"hsl({hue}, 55%, 68%)" def _html_escape(text: str) -> str: return ( text.replace("&", "&") .replace("<", "<") .replace(">", ">") .replace('"', """) ) def render_wolf_text_html( text: str, *, default_color: str = "#d4d4d4", default_font_px: int = 18, ) -> str: """Render Wolf text as HTML, applying ``\\c`` / ``\\f`` and hiding other codes. Used by the Step 7 wrap preview so emphasis like ``\\c[21]\\f[20]cafe\\c[19]\\f[18]`` shows as larger coloured glyphs instead of raw escape sequences. Colours are approximate (System DB Type 12 is per-game). """ if not isinstance(text, str) or not text: return "" color = default_color font_px = max(8, int(default_font_px)) parts: list[str] = [] last = 0 def _flush(literal: str) -> None: if not literal: return parts.append( f'' f"{_html_escape(literal)}" ) for m in _WOLF_PREVIEW_TOKEN_RE.finditer(text): if m.start() > last: _flush(text[last : m.start()]) raw = m.group(0) if raw.startswith(r"\c[") and m.group(1) is not None: color = wolf_preview_color(int(m.group(1)), default=default_color) elif raw.startswith(r"\f[") and m.group(2) is not None: font_px = max(8, int(m.group(2))) # Other codes (\\r, \\^, \\cdb, …) are omitted from the visual preview. last = m.end() if last < len(text): _flush(text[last:]) return "".join(parts) def format_wrap_preview_html( text: str, 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 "" 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, **plate_kw) wrapped = info.get("wrapped") or "" if not wrapped: return "" default_px = ( int(body_font) if body_font is not None and int(body_font) > 0 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) # 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=default_px ) rows.append( f'
' f"{gutter}{body_html}
" ) return ( '
' + "".join(rows) + "
" ) def locate_line(doc: dict[str, Any], hit_id: dict[str, Any]) -> dict[str, Any] | None: """Return the live line dict for *hit_id* inside *doc*.""" kind = hit_id.get("kind") if kind == "db": sheet = hit_id.get("sheet_name") row = hit_id.get("row") field = hit_id.get("field_name") for group in doc.get("groups") or []: if str(group.get("typeName") or "") != sheet: continue for line in group.get("lines") or []: if line.get("row") == row and str(line.get("fieldName") or "") == field: return line return None if kind in ("map", "common"): si = hit_id.get("scene_index") li = hit_id.get("line_index") scenes = doc.get("scenes") or [] if si is None or li is None or si >= len(scenes): return None lines = scenes[si].get("lines") or [] if li >= len(lines): return None return lines[li] if kind == "names": idx = hit_id.get("name_index", hit_id.get("row")) names = doc.get("names") or [] if idx is None or not isinstance(idx, int) or idx < 0 or idx >= len(names): return None entry = names[idx] return entry if isinstance(entry, dict) else None if kind == "gamedat": li = hit_id.get("line_index", hit_id.get("row")) lines = doc.get("lines") or [] if li is None or not isinstance(li, int) or li < 0 or li >= len(lines): return None line = lines[li] return line if isinstance(line, dict) else None return None def load_hit_from_id( translated_dir: str | Path, hit_id: dict[str, Any], *, files_dir: str | Path | None = None, extra_dirs: Sequence[str | Path] | None = None, ) -> tuple[Path | None, dict[str, Any] | None, dict[str, Any] | None]: """Load JSON path, document, and line dict for *hit_id*.""" jf = hit_id.get("json_file") if not jf: return None, None, None tdir = Path(translated_dir) fdir = Path(files_dir) if files_dir else None for base in _iter_search_dirs(tdir, fdir, extra_dirs): path = base / str(jf) if not path.is_file(): continue doc = _load_json(path) if not doc: continue line = locate_line(doc, hit_id) if line is not None: return path, doc, line return None, None, None def save_document(path: Path, doc: dict[str, Any]) -> None: path.write_text( json.dumps(doc, ensure_ascii=False, indent=4) + "\n", encoding="utf-8", ) def wrap_line_in_doc(line: dict[str, Any], width: int) -> bool: """Wrap one line's ``text``; return True if changed.""" text = line.get("text") if not isinstance(text, str) or not text.strip() or width <= 0: return False 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 return True def count_soft_lines(text: str) -> int: """Number of soft-wrapped lines in *text* (at least 1 for non-empty).""" if not isinstance(text, str) or not text: return 0 norm = text.replace("\r\n", "\n").replace("\r", "\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( 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]: """Reflow *text* to *width*, then shrink ``\\f[N]`` until it fits *max_lines*. Used by Relayout for DB / dialogue sheets. ``max_lines`` ``None``/``0`` means wrap only (no font shrink). Wolf message boxes are a fixed pixel width. Shrinking ``\\f[N]`` lets more halfwidth cells fit on a line, so each shrink pass reflows at ``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(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(work) else: native = 18 native = max(1, native) min_font = max(1, int(min_font)) def _effective_width(current_font: int) -> int: cur = max(1, int(current_font)) return max(1, int(round(width * native / cur))) 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 if not sizes and count_soft_lines(new_body) > limit: new_body, _ = wolf_codes.scale_font_sizes(new_body, native) current = native new_body = wrap_line_text(new_body, _effective_width(current)) guard = 0 while count_soft_lines(new_body) > limit and guard < 64: guard += 1 current = wolf_codes.infer_base_font_size(new_body, default=current) if current <= min_font: break target = current - 1 new_body, _ = wolf_codes.scale_font_sizes(new_body, target) current = target 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 def apply_relayout_to_line_dict( line: dict[str, Any], width: int, *, max_lines: int | None = None, min_font: int = 8, box_font: int | None = None, ) -> bool: """Apply Relayout fit to one ``{text}`` leaf. Returns True if changed.""" from util.wolfdawn import codes as wolf_codes text = line.get("text") if not isinstance(text, str) or not text.strip() or width <= 0: return False native = box_font if native is None: source = line.get("source") if isinstance(source, str) and wolf_codes.detect_font_sizes(source): native = wolf_codes.infer_base_font_size(source) new_text, changed = fit_text_to_box( text, width, 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 line["text"] = new_text return True def apply_manual_font_and_wrap( text: str, width: int, *, 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, skip short plain lines that already match the target layout and have no ``\\f[N]``. Manual group wrap passes False whenever a body font is set so short spoken lines still get ``\\f``. Returns ``(new_text, changed)``. """ from util.wolfdawn import codes as wolf_codes if not isinstance(text, str) or not text.strip(): return text, 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(): 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_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_work, _ = wolf_codes.scale_font_sizes( new_work, int(font), old_base=old_base ) if width > 0: 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 def wrap_hit_manual( path: Path, doc: dict[str, Any], hit_id: dict[str, Any], width: int, *, font: int | None = None, ) -> bool: """Apply manual font scale + wrap to one hit line.""" line = locate_line(doc, hit_id) if line is None: return False text = line.get("text") if not isinstance(text, str): return False 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 save_document(path, doc) return True def wrap_overflow_manual_in_scope( path: Path, doc: dict[str, Any], hit: WrapHit | dict[str, Any], 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) if kind == "names": return _wrap_manual_in_names_category(path, doc, sheet_name, width, font=font) if kind == "db": return _wrap_manual_in_db_sheet(path, doc, sheet_name, width, font=font) if kind in ("map", "common"): speaker_src = _hit_speaker_src(hit, doc) if translated_dir is not None: return wrap_overflow_manual_in_dialogue_format( translated_dir, speaker_src, width, font=font ) return _wrap_manual_in_event_format( path, doc, speaker_src, width, font=font ) if kind == "gamedat": return _wrap_manual_in_gamedat(path, doc, width, font=font) return 0 def _apply_manual_to_line_dict( line: dict[str, Any], width: int, *, font: int | None, ) -> bool: text = line.get("text") if not isinstance(text, str) or not text.strip(): return False # Font set: apply to every line in the group (short spoken lines included). # Font 0 / None: only reflow lines that overflow or already have \\f[N]. only_overflow = font is None or int(font) <= 0 new_text, changed = apply_manual_font_and_wrap( text, width, font=font, only_overflow_or_fonts=only_overflow, speaker_src=str(line.get("speaker_src") or ""), speaker=str(line.get("speaker") or ""), ) if not changed: return False line["text"] = new_text return True def _wrap_manual_in_names_category( path: Path, doc: dict[str, Any], category: str, width: int, *, font: int | None = None, ) -> int: if doc.get("kind") != "names": return 0 changed = 0 for entry in doc.get("names") or []: if not isinstance(entry, dict): continue if str(entry.get("note") or "") != category: continue if _apply_manual_to_line_dict(entry, width, font=font): changed += 1 if changed: save_document(path, doc) return changed def _wrap_manual_in_db_sheet( path: Path, doc: dict[str, Any], sheet_name: str, width: int, *, font: int | None = None, ) -> int: if doc.get("kind") != "db": return 0 changed = 0 for group in doc.get("groups") or []: if str(group.get("typeName") or "") != sheet_name: continue for line in group.get("lines") or []: if _apply_manual_to_line_dict(line, width, font=font): changed += 1 if changed: save_document(path, doc) return changed def _wrap_manual_in_event_format( path: Path, doc: dict[str, Any], speaker_src: str, width: int, *, font: int | None = None, ) -> int: """Manual-wrap overflowing lines in one file that share *speaker_src*'s format.""" if doc.get("kind") not in ("map", "common"): return 0 want = wrap_format_key(speaker_src) changed = 0 for scene in doc.get("scenes") or []: for line in scene.get("lines") or []: if wrap_format_key(str(line.get("speaker_src") or "")) != want: continue if _apply_manual_to_line_dict(line, width, font=font): changed += 1 if changed: 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], width: int, *, font: int | None = None, ) -> int: if doc.get("kind") != "gamedat": return 0 changed = 0 for line in doc.get("lines") or []: if _apply_manual_to_line_dict(line, width, font=font): changed += 1 if changed: save_document(path, doc) return changed @dataclass class ScopeStats: """Line counts for the wrap group containing one search hit.""" label: str total: int overflow: int def _hit_kind_and_sheet(hit: WrapHit | dict[str, Any], doc: dict[str, Any] | None = None) -> tuple[str, str]: if isinstance(hit, WrapHit): return hit.kind, hit.sheet_name kind = str(hit.get("kind") or (doc or {}).get("kind") or "") sheet = str(hit.get("sheet_name") or "") return kind, sheet def _hit_scene_index(hit: WrapHit | dict[str, Any]) -> int | None: if isinstance(hit, WrapHit): return hit.scene_index raw = hit.get("scene_index") if raw is None: return None try: return int(raw) except (TypeError, ValueError): return None def _hit_speaker_src( hit: WrapHit | dict[str, Any], doc: dict[str, Any] | None = None, ) -> str: """Best-effort ``speaker_src`` for a wrap hit (from live line when possible).""" if doc is not None: hit_id = hit.hit_id if isinstance(hit, WrapHit) else hit if isinstance(hit_id, dict): line = locate_line(doc, hit_id) if isinstance(line, dict) and line.get("speaker_src") is not None: return str(line.get("speaker_src") or "") if isinstance(hit, dict) and hit.get("speaker_src") is not None: return str(hit.get("speaker_src") or "") return "" def _scene_group_label(doc: dict[str, Any] | None, hit: WrapHit | dict[str, Any]) -> str: """Deprecated scene label; prefer format label for map/common groups.""" kind, sheet = _hit_kind_and_sheet(hit, doc) si = _hit_scene_index(hit) scene: dict[str, Any] | None = None if doc is not None and si is not None: scenes = doc.get("scenes") or [] if 0 <= si < len(scenes) and isinstance(scenes[si], dict): scene = scenes[si] event_id = None name = "" if scene is not None: event_id = scene.get("event") name = str(scene.get("name") or "").strip() elif isinstance(hit, WrapHit) and hit.row is not None: event_id = hit.row elif isinstance(hit, dict) and hit.get("row") is not None: event_id = hit.get("row") parts: list[str] = [] if kind == "common": parts.append("CommonEvent") elif sheet: parts.append(sheet) if event_id is not None: parts.append(f"event {event_id}") if name: parts.append(name) elif si is not None and event_id is None: parts.append(f"scene {si}") return " · ".join(parts) if parts else (sheet or "event") def scope_label(hit: WrapHit | dict[str, Any], doc: dict[str, Any] | None = None) -> str: """Human-readable group name for status messages.""" kind, sheet = _hit_kind_and_sheet(hit, doc) jf = "" if isinstance(hit, WrapHit): jf = hit.json_file else: jf = str(hit.get("json_file") or "") if kind == "db": return f"sheet {sheet}" if kind == "names": return f"category {sheet}" if kind == "gamedat": return "Game.dat" if kind in ("map", "common"): return wrap_format_label(_hit_speaker_src(hit, doc)) return sheet or jf def scope_stats( doc: dict[str, Any], hit: WrapHit | dict[str, Any], width: int, ) -> ScopeStats: """Count lines and overflows in the same group as *hit*.""" kind, sheet_name = _hit_kind_and_sheet(hit, doc) total = 0 overflow = 0 if kind == "db": for group in doc.get("groups") or []: if str(group.get("typeName") or "") != sheet_name: continue for line in group.get("lines") or []: text = line.get("text") if not isinstance(text, str) or not text.strip(): continue total += 1 if line_needs_wrap(text, width): overflow += 1 break elif kind == "names": for entry in doc.get("names") or []: if not isinstance(entry, dict): continue if str(entry.get("note") or "") != sheet_name: continue text = entry.get("text") if not isinstance(text, str) or not text.strip(): continue total += 1 if line_needs_wrap(text, width): overflow += 1 elif kind in ("map", "common"): want = wrap_format_key(_hit_speaker_src(hit, doc)) for scene in doc.get("scenes") or []: for line in scene.get("lines") or []: if wrap_format_key(str(line.get("speaker_src") or "")) != want: continue text = line.get("text") if not isinstance(text, str) or not text.strip(): continue total += 1 if line_needs_wrap_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 []: if not isinstance(line, dict): continue text = line.get("text") if not isinstance(text, str) or not text.strip(): continue total += 1 if line_needs_wrap(text, width): overflow += 1 return ScopeStats(label=scope_label(hit, doc), total=total, overflow=overflow) 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*.""" 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_preserving_nameplate(text, width, **plate_kw) return ( count_body_soft_lines(wrapped, **plate_kw) > limit or wrapped != text ) def _wrap_overflow_in_event_format( path: Path, doc: dict[str, Any], speaker_src: str, width: int, *, max_lines: int | None = None, ) -> int: """Wrap overflowing lines in one file that share *speaker_src*'s format.""" if doc.get("kind") not in ("map", "common"): return 0 want = wrap_format_key(speaker_src) changed = 0 for scene in doc.get("scenes") or []: for line in scene.get("lines") or []: if wrap_format_key(str(line.get("speaker_src") or "")) != want: continue text = line.get("text") if not isinstance(text, str) or not text.strip(): continue 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], width: int, *, max_lines: int | None = None, ) -> int: if doc.get("kind") != "gamedat": return 0 changed = 0 for line in doc.get("lines") or []: if not isinstance(line, dict): continue 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 if changed: save_document(path, doc) return changed def wrap_overflow_in_scope( path: Path, doc: dict[str, Any], hit: WrapHit | dict[str, Any], width: int, *, max_lines: int | None = None, translated_dir: Path | None = None, ) -> int: """Wrap overflowing lines in the same group as *hit*. 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. """ kind, sheet_name = _hit_kind_and_sheet(hit, doc) if kind == "names": return _wrap_overflow_in_names_category(path, doc, sheet_name, width) if kind == "db": return wrap_overflow_in_sheet( path, doc, sheet_name, width, kind="db", max_lines=max_lines ) if kind in ("map", "common"): speaker_src = _hit_speaker_src(hit, doc) if translated_dir is not None: return wrap_overflow_in_dialogue_format( translated_dir, speaker_src, width, max_lines=max_lines ) return _wrap_overflow_in_event_format( path, doc, speaker_src, width, max_lines=max_lines ) if kind == "gamedat": return _wrap_overflow_in_gamedat(path, doc, width, max_lines=max_lines) return 0 def wrap_hit_in_file( path: Path, doc: dict[str, Any], hit_id: dict[str, Any], width: int, *, max_lines: int | None = None, ) -> bool: """Relayout one hit line (wrap + optional max-lines font shrink).""" line = locate_line(doc, hit_id) if line is None: return False if not apply_relayout_to_line_dict(line, width, max_lines=max_lines): return False save_document(path, doc) return True def wrap_overflow_in_sheet( path: Path, doc: dict[str, Any], sheet_name: str, width: int, *, kind: str | None = None, max_lines: int | None = None, ) -> int: """Wrap every overflowing line in one DB sheet or names.json category.""" doc_kind = kind or doc.get("kind") if doc_kind == "names": return _wrap_overflow_in_names_category(path, doc, sheet_name, width) if doc.get("kind") != "db": return 0 changed = 0 for group in doc.get("groups") or []: if str(group.get("typeName") or "") != sheet_name: continue for line in group.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 if changed: save_document(path, doc) return changed def _wrap_overflow_in_names_category( path: Path, doc: dict[str, Any], category: str, width: int, ) -> int: """Wrap overflowing ``names[]`` entries sharing one ``note`` (category).""" if doc.get("kind") != "names": return 0 changed = 0 for entry in doc.get("names") or []: if not isinstance(entry, dict): continue if str(entry.get("note") or "") != category: continue text = entry.get("text") if not isinstance(text, str) or not text.strip(): continue if not line_needs_wrap(text, width): continue if wrap_line_in_doc(entry, width): changed += 1 if changed: save_document(path, doc) return changed