"""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). """ 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 _APOSTROPHE_NORMALIZE = str.maketrans( { "\u2019": "'", "\u2018": "'", "\u02bc": "'", "`": "'", } ) @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 _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) -> 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_line_text(text, width) norm_in = text.replace("\r\n", "\n").replace("\r", "\n") norm_out = wrapped.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, } def wrap_preview_summary(text: str, width: int) -> str: """One-line status for the preview header.""" info = wrap_preview_info(text, width) if not isinstance(text, str) or not text.strip(): return "" longest = int(info["longest"]) if not info["needs_wrap"]: return f"Fits at width {width} (longest line {longest} visible chars)." return ( f"Will wrap to {info['output_line_count']} line(s) at width {width} " f"(longest input line {longest} visible chars)." ) # 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, ) -> 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. """ 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)) info = wrap_preview_info(preview_text, width) wrapped = info.get("wrapped") or "" if not wrapped: return "" body = ( 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) ) lines = wrapped.replace("\r\n", "\n").replace("\r", "\n").split("\n") rows: list[str] = [] for i, line in enumerate(lines): vis = dazedwrap.max_line_visible_length(line) overflow = vis > width > 0 gutter_color = "#ce9178" if overflow else "#858585" marker = "⚠" if overflow else " " gutter = ( f'' f"{marker} {i + 1:2d} ({vis:2d}) " ) body_html = render_wolf_text_html( line, default_color="#d4d4d4", default_font_px=body ) rows.append( f'