From 42cda41458fd5b9b21b573a708f5c6d72919de91 Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Wed, 8 Jul 2026 17:14:06 -0500 Subject: [PATCH] fix wrap tab --- README.md | 7 +- gui/wolf_workflow_tab.py | 629 ++++++++++++++++++++++++++++-- tests/test_wolf_selective_wrap.py | 127 ++++++ tests/test_wolf_wrap_search.py | 218 +++++++++++ util/dazedwrap.py | 87 ++++- util/wolfdawn/selective_wrap.py | 218 +++++++++++ util/wolfdawn/wrap_search.py | 583 +++++++++++++++++++++++++++ 7 files changed, 1813 insertions(+), 56 deletions(-) create mode 100644 tests/test_wolf_selective_wrap.py create mode 100644 tests/test_wolf_wrap_search.py create mode 100644 util/wolfdawn/selective_wrap.py create mode 100644 util/wolfdawn/wrap_search.py diff --git a/README.md b/README.md index a75fd58..39c6bd4 100644 --- a/README.md +++ b/README.md @@ -330,9 +330,10 @@ Open the **Workflow** tab and choose **Wolf RPG (WolfDawn)** from the engine sel | **3 Names** | Translate `names.json` (item/skill/enemy/map value names). WolfDawn tags each name with a per-entry **safety** badge (`safe`, `refs`, or `verify`). Phase 0 translates only `safe` and `refs` entries; `verify` names stay Japanese so inject skips them. Review the category breakdown for this game, pick **Translation mode** (Normal or Batch), and run **Translate safe names (Phase 0)**. | | **4 Database** | Review the **discovery summary** to see where this game's text lives (standard RPG sheets vs custom dialogue tables). Database sheets are classified as foundation (items, skills, descriptions — translate first) or narrative (custom event/profile sheets — translate after foundation). Use **Translate foundation DB** then **Translate narrative DB**, or tick specific sheets and run **Translate checked sheets only**. Optional: copy the **DB structure prompt** for an AI audit and import the returned JSON into `wolf_json/db_profile.json`. | | **5 Maps/Events** | Translate map scripts (`.mps`), common events (`CommonEvent.dat`), `Game.dat`, and Evtext. Run after Steps 3–4 so vocab and database terms are consistent. Configure speaker handling for low-confidence nameplate guesses. Batch mode is recommended for large `CommonEvent.dat` files. | -| **6 Inject** | Write translations back into the game's `Data/` binaries and refresh git-tracked `wolf_json/`. Uses pristine originals from `wolf_json/originals/`. Quick inject or inject all; optional relayout reflows message boxes for English text length. | -| **7 Package** | Run from loose `Data/` (backs up `Data.wolf` → `.bak`) or repack `Data.wolf`. | -| **8 Saves** | *(Optional)* Update existing `.sav` files for the translated build. | +| **6 Inject** | Write translations back into the game's `Data/` binaries and refresh git-tracked `wolf_json/`. Uses pristine originals from `wolf_json/originals/`. Quick inject or inject all. | +| **7 Fix wrap** | Paste overflowing in-game text to **search** `translated/` JSON and jump to the database sheet and row (sheet names match Step 4 and `names.json` notes, e.g. `├■街の噂(MOB)`). Edit the line, set wrap width, **Wrap this row** or **Wrap all overflowing rows in this sheet**, save, then **Inject this file** (usually `DataBase.project.json`). Per-sheet widths are remembered in `wolf_json/wrap_profile.json`. **Advanced:** **wolf relayout** for event message boxes (maps/CommonEvent only — not bulletin-style DB UI) and **wolf desc-relayout** for standard 説明 description fields. | +| **8 Package** | Run from loose `Data/` (backs up `Data.wolf` → `.bak`) or repack `Data.wolf`. | +| **9 Saves** | *(Optional)* Update existing `.sav` files for the translated build. | ### Recommended order by game layout diff --git a/gui/wolf_workflow_tab.py b/gui/wolf_workflow_tab.py index 77a9805..ca1611f 100644 --- a/gui/wolf_workflow_tab.py +++ b/gui/wolf_workflow_tab.py @@ -18,10 +18,10 @@ Mirrors the RPGMaker WorkflowTab, driven by the vendored WolfDawn ``wolf`` CLI and refresh the git-tracked wolf_json/ with the translated JSON; quick-inject picks just a few translated files for fast in-game / git iteration, or inject everything at once. - After a successful inject, optional WolfDawn relayout reflows - Message text (and optionally DB descriptions) to the message box. - Step 7 Package - run from a loose Data/ folder, or repack Data.wolf - Step 8 Saves - fix baked strings in existing .sav files (optional) + Step 7 Fix wrap - search translated JSON by in-game text; wrap DB sheet rows; + re-inject (Advanced: wolf relayout for event messages) + Step 8 Package - run from a loose Data/ folder, or repack Data.wolf + Step 9 Saves - fix baked strings in existing .sav files (optional) names.json is staged into files/ but is NOT translated in the bulk phases - WolfDawn tags each name safe / refs / verify and Phase 0 translates only safe+refs entries @@ -44,7 +44,7 @@ import tempfile from pathlib import Path from PyQt5.QtCore import Qt, QSettings, QThread, QTimer, pyqtSignal, QEvent -from PyQt5.QtGui import QColor, QFont +from PyQt5.QtGui import QColor, QFont, QTextCharFormat, QTextCursor from PyQt5.QtWidgets import ( QApplication, QAbstractItemView, @@ -87,6 +87,10 @@ from gui.workflow_tab import ( ) from util.wolfdawn import names as wolf_names from util.wolfdawn import db_classify as wolf_db +from util.wolfdawn import selective_wrap as wolf_selwrap +from util.wolfdawn import wrap_search as wolf_ws + +import util.dazedwrap as dazedwrap from util.paths import PROJECT_ROOT from util.project_scanner import ( detect_wolf_layout, @@ -581,8 +585,9 @@ class WolfWorkflowTab(QWidget): ("4 Database", self._build_step4_database), ("5 Maps/Events", self._build_step5_maps_events), ("6 Inject", self._build_step4_inject), - ("7 Package", self._build_step5_package), - ("8 Saves", self._build_step6_saves), + ("7 Fix wrap", self._build_step7_relayout), + ("8 Package", self._build_step5_package), + ("9 Saves", self._build_step6_saves), ] for tab_label, builder in _tab_defs: @@ -2595,26 +2600,184 @@ class WolfWorkflowTab(QWidget): layout.addWidget(self._subheading("Full inject")) layout.addWidget(self._desc( "Injects every extracted document and applies translated name values from names.json " - "across Data/. Use this once translation is complete before packaging in Step 7." + "across Data/. Use this once translation is complete before packaging in Step 8." )) inject_btn = self._register(_make_btn("Inject all translations", "#00a86b")) inject_btn.clicked.connect(lambda: self._inject()) layout.addWidget(inject_btn) - self._add_relayout_options(layout) - - def _add_relayout_options(self, layout: QVBoxLayout): - """Post-inject WolfDawn message-box / DB-description reflow.""" layout.addWidget(_make_hr()) - layout.addWidget(self._subheading("Relayout (after inject)")) layout.addWidget(self._desc( - "Runs WolfDawn on the live Data/ binaries after a successful inject: reflow Message " - "text to the message-box width (page-split overflow), and optionally refit DB " - "description fields. Layout is no longer done at translate time - leave dialogue " - "unwrapped in JSON and let this step fix the box." + "English text is often longer than Japanese. Step 7 (Fix wrapping): paste " + "overflowing in-game text to find the sheet, wrap, and re-inject." )) - self._relayout_after_cb = QCheckBox("Relayout after inject") + def _build_step7_relayout(self, layout: QVBoxLayout): + """Search-first fix wrapping: find sheet by in-game text, wrap, re-inject.""" + layout.addWidget(_make_section_label("Step 7 · Fix wrapping")) + layout.addWidget(self._desc( + "Paste text you see overflowing in the game. Search covers database sheets " + "(Step 4), names.json profile blurbs (Step 3), maps, and common events. " + "Wrap at the right width, then re-inject that file from Step 6 or below." + )) + + search_row = QHBoxLayout() + self._wrap_search_edit = QLineEdit() + self._wrap_search_edit.setPlaceholderText( + 'Paste in-game text, e.g. "there\'s a pure" or "gatekeeper just randomly"' + ) + search_btn = _make_btn("Search", "#007acc") + search_btn.clicked.connect(self._run_wrap_search) + self._wrap_search_edit.returnPressed.connect(self._run_wrap_search) + search_row.addWidget(self._wrap_search_edit, 1) + search_row.addWidget(search_btn) + layout.addLayout(search_row) + + self._wrap_results_list = QListWidget() + self._wrap_results_list.setMaximumHeight(160) + self._wrap_results_list.setStyleSheet( + "QListWidget{background-color:#252526;border:1px solid #3a3a3a;border-radius:4px;" + "color:#cccccc;font-size:12px;padding:2px;}" + "QListWidget::item{padding:4px 6px;}" + "QListWidget::item:selected{background:#264f78;color:#ffffff;}" + ) + self._wrap_results_list.currentItemChanged.connect(self._on_wrap_hit_selected) + layout.addWidget(self._wrap_results_list) + + self._wrap_detail_label = QLabel("Select a search result to edit.") + self._wrap_detail_label.setWordWrap(True) + self._wrap_detail_label.setStyleSheet( + "color:#9cdcfe;font-size:12px;background:transparent;" + ) + layout.addWidget(self._wrap_detail_label) + + self._wrap_text_editor = QTextEdit() + self._wrap_text_editor.setMaximumHeight(120) + self._wrap_text_editor.setStyleSheet( + "QTextEdit{background-color:#252526;color:#d4d4d4;border:1px solid #3c3c3c;" + "font-family:monospace;font-size:12px;padding:6px;}" + ) + self._wrap_text_editor.setEnabled(False) + self._wrap_text_editor.textChanged.connect(self._update_wrap_preview) + layout.addWidget(self._wrap_text_editor) + + width_row = QHBoxLayout() + w_lbl = QLabel("Wrap width (characters):") + w_lbl.setStyleSheet("color:#cccccc;font-size:12px;background:transparent;") + self._wrap_width_spin = QSpinBox() + self._wrap_width_spin.setRange(10, 120) + try: + self._wrap_width_spin.setValue(int(self._setting("wrap_fix_width", 36) or 36)) + except (TypeError, ValueError): + self._wrap_width_spin.setValue(36) + self._wrap_width_spin.setFixedWidth(70) + self._wrap_width_spin.valueChanged.connect(self._on_wrap_width_changed) + width_row.addWidget(w_lbl) + width_row.addWidget(self._wrap_width_spin) + width_row.addStretch() + layout.addLayout(width_row) + + self._wrap_preview_label = QLabel("Wrap preview") + self._wrap_preview_label.setStyleSheet( + "color:#858585;font-size:11px;background:transparent;" + ) + layout.addWidget(self._wrap_preview_label) + + self._wrap_preview = QTextEdit() + self._wrap_preview.setReadOnly(True) + self._wrap_preview.setMaximumHeight(110) + self._wrap_preview.setPlaceholderText( + "Wrapped lines appear here when you select a result and adjust width." + ) + self._wrap_preview.setStyleSheet( + "QTextEdit{background-color:#1e1e1e;color:#b5cea8;border:1px solid #3c3c3c;" + "border-left:3px solid #007acc;font-family:monospace;font-size:12px;padding:6px;}" + ) + layout.addWidget(self._wrap_preview) + + btn_row = QHBoxLayout() + wrap_row_btn = _make_btn("Wrap this row", "#00a86b") + wrap_row_btn.clicked.connect(self._wrap_current_row) + wrap_sheet_btn = _make_btn("Wrap all overflowing in sheet", "#00a86b") + wrap_sheet_btn.clicked.connect(self._wrap_current_sheet) + save_row_btn = _make_btn("Save text", "#3a3a3a") + save_row_btn.clicked.connect(self._save_wrap_row_text) + inject_btn = _make_btn("Inject this file", "#007acc") + inject_btn.clicked.connect(self._inject_current_wrap_file) + btn_row.addWidget(wrap_row_btn) + btn_row.addWidget(wrap_sheet_btn) + btn_row.addWidget(save_row_btn) + btn_row.addWidget(inject_btn) + btn_row.addStretch() + layout.addLayout(btn_row) + + self._wrap_status_label = QLabel("") + self._wrap_status_label.setWordWrap(True) + self._wrap_status_label.setStyleSheet( + "color:#8fbc8f;font-size:12px;background:transparent;" + ) + layout.addWidget(self._wrap_status_label) + + layout.addWidget(_make_hr()) + sheets_toggle = QPushButton("▸ Browse database sheets (overflow counts)") + sheets_toggle.setCheckable(True) + sheets_toggle.setChecked(False) + sheets_toggle.setStyleSheet( + "QPushButton{text-align:left;color:#cccccc;background:transparent;border:none;" + "font-size:12px;padding:4px 0;}" + "QPushButton:hover{color:#ffffff;}" + ) + self._wrap_sheets_toggle = sheets_toggle + sheets_toggle.toggled.connect(self._toggle_wrap_sheets_panel) + layout.addWidget(sheets_toggle) + + self._wrap_sheets_panel = QWidget() + sheets_panel_layout = QVBoxLayout(self._wrap_sheets_panel) + sheets_panel_layout.setContentsMargins(0, 0, 0, 0) + sheets_panel_layout.addWidget(self._desc( + "Sheets with lines longer than the wrap width above. Double-click to open in " + "the editor (first overflowing line)." + )) + self._wrap_sheets_list = QListWidget() + self._wrap_sheets_list.setMaximumHeight(140) + self._wrap_sheets_list.setStyleSheet( + "QListWidget{background-color:#252526;border:1px solid #3a3a3a;border-radius:4px;" + "color:#cccccc;font-size:12px;}" + ) + self._wrap_sheets_list.itemDoubleClicked.connect(self._on_wrap_sheet_double_clicked) + sheets_panel_layout.addWidget(self._wrap_sheets_list) + self._wrap_sheets_panel.setVisible(False) + layout.addWidget(self._wrap_sheets_panel) + + layout.addWidget(_make_hr()) + adv_toggle = QPushButton("▸ Advanced (event message windows & item descriptions)") + adv_toggle.setCheckable(True) + adv_toggle.setChecked(False) + adv_toggle.setStyleSheet(sheets_toggle.styleSheet()) + adv_toggle.toggled.connect(self._toggle_wrap_advanced_panel) + layout.addWidget(adv_toggle) + self._wrap_advanced_toggle = adv_toggle + + self._wrap_advanced_panel = QWidget() + adv_layout = QVBoxLayout(self._wrap_advanced_panel) + adv_layout.setContentsMargins(0, 0, 0, 0) + self._build_wrap_advanced_panel(adv_layout) + self._wrap_advanced_panel.setVisible(False) + layout.addWidget(self._wrap_advanced_panel) + + self._current_wrap_hit: wolf_ws.WrapHit | None = None + self._current_wrap_hit_id: dict | None = None + self._current_wrap_path: Path | None = None + self._current_wrap_doc: dict | None = None + + def _build_wrap_advanced_panel(self, layout: QVBoxLayout): + """wolf relayout / desc-relayout for non-DB-UI text.""" + layout.addWidget(self._desc( + "Use these only for standard event message windows (maps / CommonEvent) and " + "item description boxes (説明 fields). Bulletin boards and custom DB UI " + "use the search workflow above." + )) + self._relayout_after_cb = QCheckBox("Automatically relayout after Step 6 inject") self._relayout_after_cb.setChecked( self._setting("relayout_after_inject", "false") == "true" ) @@ -2626,20 +2789,8 @@ class WolfWorkflowTab(QWidget): ) layout.addWidget(self._relayout_after_cb) - self._relayout_desc_cb = QCheckBox("Also refit DB descriptions (desc-relayout)") - self._relayout_desc_cb.setChecked( - str(self._setting("relayout_desc", "true")).lower() != "false" - ) - self._relayout_desc_cb.stateChanged.connect( - lambda: self._save_setting( - "relayout_desc", - "true" if self._relayout_desc_cb.isChecked() else "false", - ) - ) - layout.addWidget(self._relayout_desc_cb) - msg_row = QHBoxLayout() - width_lbl = QLabel("Message width (cells):") + width_lbl = QLabel("Message width:") width_lbl.setStyleSheet("color:#cccccc;font-size:12px;background:transparent;") self._relayout_width_spin = QSpinBox() self._relayout_width_spin.setRange(20, 300) @@ -2651,7 +2802,7 @@ class WolfWorkflowTab(QWidget): self._relayout_width_spin.valueChanged.connect( lambda v: self._save_setting("relayout_width", v) ) - max_rows_lbl = QLabel("Max rows (0=default):") + max_rows_lbl = QLabel("Max rows (0=auto):") max_rows_lbl.setStyleSheet("color:#cccccc;font-size:12px;background:transparent;") self._relayout_max_rows_spin = QSpinBox() self._relayout_max_rows_spin.setRange(0, 20) @@ -2670,8 +2821,24 @@ class WolfWorkflowTab(QWidget): msg_row.addStretch() layout.addLayout(msg_row) + relayout_btn = _make_btn("Relayout event messages (wolf relayout)", "#3a3a3a") + relayout_btn.clicked.connect(lambda: self._run_relayout(manual=True)) + layout.addWidget(relayout_btn) + + self._relayout_desc_cb = QCheckBox("Also refit item description boxes (desc-relayout)") + self._relayout_desc_cb.setChecked( + str(self._setting("relayout_desc", "true")).lower() != "false" + ) + self._relayout_desc_cb.stateChanged.connect( + lambda: self._save_setting( + "relayout_desc", + "true" if self._relayout_desc_cb.isChecked() else "false", + ) + ) + layout.addWidget(self._relayout_desc_cb) + desc_row = QHBoxLayout() - desc_width_lbl = QLabel("Desc width (cells):") + desc_width_lbl = QLabel("Desc width:") desc_width_lbl.setStyleSheet("color:#cccccc;font-size:12px;background:transparent;") self._relayout_desc_width_spin = QSpinBox() self._relayout_desc_width_spin.setRange(20, 300) @@ -2706,9 +2873,387 @@ class WolfWorkflowTab(QWidget): desc_row.addStretch() layout.addLayout(desc_row) - now_btn = self._register(_make_btn("Relayout Data/ now", "#007acc")) - now_btn.clicked.connect(lambda: self._run_relayout(manual=True)) - layout.addWidget(now_btn) + wrap_env = self._read_env_values(["wolfWrap", "wolfWidth"]) + self._wolf_wrap_cb = QCheckBox("Wrap dialogue at translate time (wolfWrap, rare)") + self._wolf_wrap_cb.setChecked( + str(wrap_env.get("wolfWrap", "false")).strip().lower() == "true" + ) + self._wolf_wrap_cb.stateChanged.connect(self._apply_wolf_wrap_config) + layout.addWidget(self._wolf_wrap_cb) + tw_row = QHBoxLayout() + tw_lbl = QLabel("wolfWrap width:") + tw_lbl.setStyleSheet("color:#cccccc;font-size:12px;background:transparent;") + self._wolf_wrap_width_spin = QSpinBox() + self._wolf_wrap_width_spin.setRange(20, 300) + try: + self._wolf_wrap_width_spin.setValue(int(wrap_env.get("wolfWidth") or 55)) + except ValueError: + self._wolf_wrap_width_spin.setValue(55) + self._wolf_wrap_width_spin.setFixedWidth(70) + self._wolf_wrap_width_spin.valueChanged.connect(self._apply_wolf_wrap_config) + tw_row.addWidget(tw_lbl) + tw_row.addWidget(self._wolf_wrap_width_spin) + tw_row.addStretch() + layout.addLayout(tw_row) + + def _toggle_wrap_sheets_panel(self, expanded: bool): + if hasattr(self, "_wrap_sheets_panel"): + self._wrap_sheets_panel.setVisible(expanded) + btn = getattr(self, "_wrap_sheets_toggle", None) + if btn is not None: + label = "▾ Browse database sheets (overflow counts)" if expanded else ( + "▸ Browse database sheets (overflow counts)" + ) + btn.setText(label) + if expanded: + self._refresh_wrap_sheets_list() + + def _toggle_wrap_advanced_panel(self, expanded: bool): + if hasattr(self, "_wrap_advanced_panel"): + self._wrap_advanced_panel.setVisible(expanded) + btn = getattr(self, "_wrap_advanced_toggle", None) + if btn is not None: + label = ( + "▾ Advanced (event message windows & item descriptions)" + if expanded else + "▸ Advanced (event message windows & item descriptions)" + ) + btn.setText(label) + + def _translated_and_files_dirs(self) -> tuple[Path, Path]: + root = self._tool_root() + return root / "translated", root / "files" + + def _wrap_search_extra_dirs(self) -> list[Path]: + work = self._wolf_json_work_dir() + return [work] if work is not None else [] + + def _wrap_profile_width(self, sheet_name: str) -> int: + work = self._wolf_json_work_dir() + if work is not None: + profile = wolf_ws.load_wrap_profile(work) + return wolf_ws.get_sheet_width( + profile, + sheet_name, + default=int(self._setting("wrap_fix_width", 36) or 36), + ) + try: + return int(self._setting("wrap_fix_width", 36) or 36) + except (TypeError, ValueError): + return 36 + + def _remember_sheet_width(self, sheet_name: str, width: int, json_file: str): + work = self._wolf_json_work_dir() + if work is not None: + wolf_ws.set_sheet_width(work, sheet_name, width, json_file=json_file) + + def _on_wrap_width_changed(self, value: int): + self._save_setting("wrap_fix_width", value) + self._update_wrap_preview() + + def _update_wrap_preview(self): + if not hasattr(self, "_wrap_preview"): + return + editor = getattr(self, "_wrap_text_editor", None) + if editor is None or not editor.isEnabled(): + self._wrap_preview.clear() + if hasattr(self, "_wrap_preview_label"): + self._wrap_preview_label.setText("Wrap preview") + if editor is not None: + editor.setExtraSelections([]) + return + text = editor.toPlainText() + width = self._wrap_width_spin.value() + summary = wolf_ws.wrap_preview_summary(text, width) + preview = wolf_ws.format_wrap_preview(text, width) + info = wolf_ws.wrap_preview_info(text, width) + if hasattr(self, "_wrap_preview_label"): + if summary: + self._wrap_preview_label.setText(summary) + color = "#ce9178" if info.get("needs_wrap") else "#8fbc8f" + self._wrap_preview_label.setStyleSheet( + f"color:{color};font-size:11px;background:transparent;" + ) + else: + self._wrap_preview_label.setText("Wrap preview") + self._wrap_preview_label.setStyleSheet( + "color:#858585;font-size:11px;background:transparent;" + ) + self._wrap_preview.setPlainText(preview) + border = "#ce9178" if info.get("needs_wrap") else "#007acc" + self._wrap_preview.setStyleSheet( + "QTextEdit{background-color:#1e1e1e;color:#b5cea8;border:1px solid #3c3c3c;" + f"border-left:3px solid {border};font-family:monospace;font-size:12px;padding:6px;}}" + ) + self._apply_wrap_source_highlights(text, width, info) + + def _apply_wrap_source_highlights( + self, + text: str, + width: int, + info: dict, + ): + """Mark in-width vs overflow segments on the source editor.""" + editor = getattr(self, "_wrap_text_editor", None) + if editor is None or not editor.isEnabled() or not text.strip(): + editor and editor.setExtraSelections([]) + return + fmt_fit = QTextCharFormat() + fmt_fit.setBackground(QColor("#264f78")) + fmt_overflow = QTextCharFormat() + fmt_overflow.setBackground(QColor("#4a3728")) + selections = [] + norm = text.replace("\r\n", "\n").replace("\r", "\n") + offset = 0 + for line in norm.split("\n"): + line_len = len(line) + fit_end, _overflow = wolf_ws.split_line_at_visible_width(line, width) + if fit_end > 0: + sel = QTextEdit.ExtraSelection() + cursor = QTextCursor(editor.document()) + cursor.setPosition(offset) + cursor.setPosition(offset + fit_end, QTextCursor.KeepAnchor) + sel.cursor = cursor + sel.format = fmt_fit + selections.append(sel) + if fit_end < line_len: + sel = QTextEdit.ExtraSelection() + cursor = QTextCursor(editor.document()) + cursor.setPosition(offset + fit_end) + cursor.setPosition(offset + line_len, QTextCursor.KeepAnchor) + sel.cursor = cursor + sel.format = fmt_overflow + selections.append(sel) + offset += line_len + 1 + editor.setExtraSelections(selections) + + def _run_wrap_search(self): + query = self._wrap_search_edit.text().strip() + if not query: + return + tdir, fdir = self._translated_and_files_dirs() + extra = self._wrap_search_extra_dirs() + hits = wolf_ws.search_translated_text( + query, tdir, files_dir=fdir, extra_dirs=extra, + ) + self._wrap_results_list.clear() + self._current_wrap_hit = None + if not hits: + where = "translated/, files/" + if extra: + where += f", {WORK_DIR_NAME}/" + item = QListWidgetItem(f"No matches in {where}.") + item.setFlags(Qt.ItemIsEnabled) + self._wrap_results_list.addItem(item) + self._wrap_detail_label.setText("No matches.") + self._wrap_text_editor.clear() + self._wrap_text_editor.setEnabled(False) + self._update_wrap_preview() + return + width = self._wrap_width_spin.value() + for hit in hits: + item = QListWidgetItem(hit.summary(width)) + item.setData(Qt.UserRole, hit.hit_id) + item.setData(Qt.UserRole + 1, hit) + self._wrap_results_list.addItem(item) + self._wrap_results_list.setCurrentRow(0) + self._wrap_status_label.setText(f"Found {len(hits)} match(es).") + + def _on_wrap_hit_selected(self, current: QListWidgetItem | None, _previous): + if current is None: + return + hit = current.data(Qt.UserRole + 1) + if not isinstance(hit, wolf_ws.WrapHit): + return + self._current_wrap_hit = hit + self._current_wrap_hit_id = hit.hit_id + tdir, fdir = self._translated_and_files_dirs() + extra = self._wrap_search_extra_dirs() + path, doc, line = wolf_ws.load_hit_from_id( + tdir, hit.hit_id, files_dir=fdir, extra_dirs=extra, + ) + self._current_wrap_path = path + self._current_wrap_doc = doc + w = self._wrap_profile_width(hit.sheet_name) + self._wrap_width_spin.blockSignals(True) + self._wrap_width_spin.setValue(w) + self._wrap_width_spin.blockSignals(False) + if line is not None: + text = str(line.get("text") or hit.text) + else: + text = hit.text + self._wrap_text_editor.setEnabled(True) + self._wrap_text_editor.setPlainText(text) + kind_labels = { + "db": "Database sheet", + "names": "Name / profile text", + "gamedat": "Game.dat", + "map": "Event dialogue", + "common": "Event dialogue", + } + kind_lbl = kind_labels.get(hit.kind, hit.kind) + if hit.kind == "names": + detail = ( + f"{kind_lbl}: {hit.sheet_name} · {hit.json_file} · " + f"entry #{hit.row} · longest line {hit.max_line_len} chars" + ) + elif hit.kind == "db": + detail = ( + f"{kind_lbl}: {hit.sheet_name} · {hit.json_file} · " + f"row {hit.row} · {hit.field_name} · longest line {hit.max_line_len} chars" + ) + else: + detail = ( + f"{kind_lbl}: {hit.sheet_name} · {hit.json_file} · " + f"{hit.field_name} · longest line {hit.max_line_len} chars" + ) + self._wrap_detail_label.setText(detail) + self._update_wrap_preview() + + def _save_wrap_row_text(self): + if not self._current_wrap_hit_id or not self._current_wrap_path or not self._current_wrap_doc: + QMessageBox.information(self, "Fix wrapping", "Select a search result first.") + return + line = wolf_ws.locate_line(self._current_wrap_doc, self._current_wrap_hit_id) + if line is None: + QMessageBox.warning(self, "Fix wrapping", "Could not locate line in JSON.") + return + line["text"] = self._wrap_text_editor.toPlainText() + wolf_ws.save_document(self._current_wrap_path, self._current_wrap_doc) + self._wrap_status_label.setText(f"Saved {self._current_wrap_path.name}.") + self._log(f"✅ Saved wrap edit to {self._current_wrap_path.name}") + + def _wrap_current_row(self): + if not self._current_wrap_hit_id or not self._current_wrap_path or not self._current_wrap_doc: + QMessageBox.information(self, "Fix wrapping", "Select a search result first.") + return + width = self._wrap_width_spin.value() + line = wolf_ws.locate_line(self._current_wrap_doc, self._current_wrap_hit_id) + if line is not None: + line["text"] = self._wrap_text_editor.toPlainText() + changed = wolf_ws.wrap_hit_in_file( + self._current_wrap_path, + self._current_wrap_doc, + self._current_wrap_hit_id, + width, + ) + if self._current_wrap_hit: + self._remember_sheet_width( + self._current_wrap_hit.sheet_name, + width, + self._current_wrap_hit.json_file, + ) + path, doc, line = wolf_ws.load_hit_from_id( + self._translated_and_files_dirs()[0], + self._current_wrap_hit_id, + files_dir=self._translated_and_files_dirs()[1], + extra_dirs=self._wrap_search_extra_dirs(), + ) + if line is not None: + self._wrap_text_editor.setPlainText(str(line.get("text") or "")) + msg = f"Wrapped row at width {width}." if changed else "Row already fits at this width." + self._wrap_status_label.setText(msg + f" Inject {self._current_wrap_path.name} from Step 6.") + self._log(f"{'✅' if changed else 'ℹ'} {msg}") + self._update_wrap_preview() + + def _wrap_current_sheet(self): + hit = self._current_wrap_hit + if not hit or hit.kind != "db": + QMessageBox.information( + self, "Fix wrapping", + "Select a database sheet result first (not map dialogue).", + ) + return + tdir, fdir = self._translated_and_files_dirs() + path = tdir / hit.json_file + if not path.is_file(): + path = fdir / hit.json_file + if not path.is_file(): + QMessageBox.warning(self, "Fix wrapping", f"{hit.json_file} not found.") + return + doc = json.loads(path.read_text(encoding="utf-8-sig")) + width = self._wrap_width_spin.value() + count = wolf_ws.wrap_overflow_in_sheet(path, doc, hit.sheet_name, width) + self._remember_sheet_width(hit.sheet_name, width, hit.json_file) + self._refresh_wrap_sheets_list() + msg = f"Wrapped {count} overflowing line(s) in {hit.sheet_name} at width {width}." + self._wrap_status_label.setText(msg + f" Inject {hit.json_file}.") + self._log(f"✅ {msg}") + + def _inject_current_wrap_file(self): + hit = self._current_wrap_hit + if not hit: + QMessageBox.information(self, "Inject", "Select a search result first.") + return + if not self._require_manifest(): + return + self._inject(only_json={hit.json_file}) + + def _refresh_wrap_sheets_list(self): + if not hasattr(self, "_wrap_sheets_list"): + return + tdir, fdir = self._translated_and_files_dirs() + width = self._wrap_width_spin.value() if hasattr(self, "_wrap_width_spin") else 36 + summaries = wolf_ws.sheet_overflow_summaries(tdir, width, files_dir=fdir) + self._wrap_sheets_list.clear() + if not summaries: + item = QListWidgetItem("No database JSON — translate DB sheets first.") + item.setFlags(Qt.ItemIsEnabled) + self._wrap_sheets_list.addItem(item) + return + for s in sorted(summaries, key=lambda x: (-x.overflow_count, x.sheet_name)): + flag = f" ⚠ {s.overflow_count} overflow" if s.overflow_count else " ok" + item = QListWidgetItem( + f"{s.sheet_name} ({s.line_count} lines){flag} — {s.json_file}" + ) + item.setData(Qt.UserRole, s.sheet_name) + item.setData(Qt.UserRole + 1, s.json_file) + self._wrap_sheets_list.addItem(item) + + def _on_wrap_sheet_double_clicked(self, item: QListWidgetItem): + sheet = item.data(Qt.UserRole) + jf = item.data(Qt.UserRole + 1) + if not sheet or not jf: + return + tdir, fdir = self._translated_and_files_dirs() + width = self._wrap_width_spin.value() + path = tdir / str(jf) + if not path.is_file(): + path = fdir / str(jf) + if not path.is_file(): + return + doc = json.loads(path.read_text(encoding="utf-8-sig")) + for group in doc.get("groups") or []: + if str(group.get("typeName") or "") != sheet: + continue + for line in group.get("lines") or []: + text = line.get("text") + if isinstance(text, str) and wolf_selwrap.line_needs_wrap(text, width): + hit = wolf_ws.WrapHit( + json_file=str(jf), + kind="db", + sheet_name=str(sheet), + row=line.get("row"), + field_name=str(line.get("fieldName") or ""), + text=str(text), + max_line_len=dazedwrap.max_line_visible_length(str(text)), + ) + self._wrap_search_edit.setText(str(text)[:40]) + self._wrap_results_list.clear() + item_r = QListWidgetItem(hit.summary(width)) + item_r.setData(Qt.UserRole, hit.hit_id) + item_r.setData(Qt.UserRole + 1, hit) + self._wrap_results_list.addItem(item_r) + self._wrap_results_list.setCurrentRow(0) + return + + def _apply_wolf_wrap_config(self): + enabled = self._wolf_wrap_cb.isChecked() if hasattr(self, "_wolf_wrap_cb") else False + width = self._wolf_wrap_width_spin.value() if hasattr(self, "_wolf_wrap_width_spin") else 55 + self._write_env_values({ + "wolfWrap": "true" if enabled else "false", + "wolfWidth": str(width), + }) def _tool_root(self) -> Path: """DazedMTLTool project root (``files/``, ``translated/``, etc.).""" @@ -2876,6 +3421,10 @@ class WolfWorkflowTab(QWidget): # Index 6 == Inject: keep the quick-inject picker in sync with translated/. elif idx == 6: self._refresh_inject_list() + # Index 7 == Fix wrap: refresh sheet overflow list. + elif idx == 7: + if getattr(self, "_wrap_sheets_panel", None) and self._wrap_sheets_panel.isVisible(): + self._refresh_wrap_sheets_list() def _refresh_inject_list(self): if not hasattr(self, "inject_list"): @@ -3249,8 +3798,6 @@ class WolfWorkflowTab(QWidget): if do_desc and not failures: projects = self._find_db_projects(data_dir) - if not projects: - pass for proj in projects: with tempfile.TemporaryDirectory(prefix="wolfdawn-desc-") as tmp: tmp_path = Path(tmp) @@ -3283,7 +3830,7 @@ class WolfWorkflowTab(QWidget): if failures: return False, "Relayout failed — " + "; ".join(failures) return True, "Relayout complete." + ( - " Continue to Step 7 to package." if not manual else "" + " Continue to Step 8 to package." if not manual else "" ) self._run_task(task) @@ -3291,7 +3838,7 @@ class WolfWorkflowTab(QWidget): # ── Step 5: Package ──────────────────────────────────────────────────────── def _build_step5_package(self, layout: QVBoxLayout): - layout.addWidget(_make_section_label("Step 7 · Package the Translated Game")) + layout.addWidget(_make_section_label("Step 8 · Package the Translated Game")) layout.addWidget(self._desc( "Choose how the translated build runs. A loose Data/ folder is simplest for " "playtesting; repacking rebuilds a single Data.wolf archive for distribution." @@ -3369,7 +3916,7 @@ class WolfWorkflowTab(QWidget): # ── Step 6: Saves ────────────────────────────────────────────────────────── def _build_step6_saves(self, layout: QVBoxLayout): - layout.addWidget(_make_section_label("Step 8 · Update Existing Saves (optional)")) + layout.addWidget(_make_section_label("Step 9 · Update Existing Saves (optional)")) layout.addWidget(self._desc( "WOLF .sav files bake in the game title and some strings at save time. This rewrites " "them so old Japanese saves load cleanly in the translated build. It is only needed " diff --git a/tests/test_wolf_selective_wrap.py b/tests/test_wolf_selective_wrap.py new file mode 100644 index 0000000..6dd772b --- /dev/null +++ b/tests/test_wolf_selective_wrap.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""Unit tests for selective DB wrap in util/wolfdawn/selective_wrap.py.""" + +from __future__ import annotations + +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +os.chdir(ROOT) +sys.path.insert(0, str(ROOT)) + +from util.wolfdawn import selective_wrap as sw # noqa: E402 +import util.dazedwrap as dazedwrap # noqa: E402 + + +SAMPLE = { + "file": "DataBase.project", + "kind": "db", + "groups": [ + { + "type": 10, + "typeName": "■イベント(テスト)", + "lines": [ + { + "row": 0, + "field": 1, + "fieldName": "好きなもの_コメント", + "source": "短い", + "text": "Short text that fits", + }, + { + "row": 1, + "field": 1, + "fieldName": "好きなもの_コメント", + "source": "長い", + "text": ( + "This is a very long English comment that definitely " + "exceeds forty characters in a single line" + ), + }, + ], + }, + { + "type": 0, + "typeName": "Item · アイテム", + "lines": [ + { + "row": 0, + "field": 1, + "fieldName": "Description · 説明", + "source": "x", + "text": "Also a long item description that should wrap when selected", + }, + ], + }, + ], +} + + +class TestLineNeedsWrap(unittest.TestCase): + def test_short_line_skipped(self): + self.assertFalse(sw.line_needs_wrap("Short", 40, min_visible=20)) + + def test_long_line_needs_wrap(self): + text = "A" * 50 + self.assertTrue(sw.line_needs_wrap(text, 40)) + + def test_color_and_font_codes_do_not_count_toward_width(self): + text = r"Hello \c[21]\f[20]tavern\c[19] end" + self.assertFalse(sw.line_needs_wrap(text, 20)) + self.assertTrue(sw.line_needs_wrap(text, 10)) + + def test_wrap_preserves_inline_codes(self): + text = r"Go to the \c[21]\f[20]tavern\c[19]\f[18] tonight for fun" + wrapped = sw.wrap_line_text(text, 18) + self.assertIn(r"\c[21]", wrapped) + self.assertIn(r"\f[20]", wrapped) + for line in wrapped.split("\n"): + self.assertLessEqual(dazedwrap._get_visible_length(line), 18) + + +class TestWrapDbDocument(unittest.TestCase): + def test_only_checked_sheet_and_field(self): + keys = frozenset({"DataBase.project.json|■イベント(テスト)"}) + pattern = sw.FIELD_PRESET_COMMENTS + doc = json.loads(json.dumps(SAMPLE)) + wrapped, skipped = sw.wrap_db_document( + doc, + group_keys=keys, + field_pattern=pattern, + width=30, + min_visible=0, + ) + self.assertEqual(wrapped, 1) + long_line = doc["groups"][0]["lines"][1]["text"] + self.assertIn("\n", long_line) + short_line = doc["groups"][0]["lines"][0]["text"] + self.assertNotIn("\n", short_line) + + def test_group_type_indices(self): + keys = frozenset({"DataBase.project.json|■イベント(テスト)"}) + self.assertEqual(sw.group_type_indices(SAMPLE, keys), [10]) + + +class TestWrapTranslatedDir(unittest.TestCase): + def test_writes_touched_file(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "DataBase.project.json" + path.write_text(json.dumps(SAMPLE), encoding="utf-8") + result = sw.wrap_db_translated_dir( + tmp, + group_keys=frozenset({"DataBase.project.json|■イベント(テスト)"}), + field_presets=frozenset({"comments"}), + width=30, + min_visible=0, + ) + self.assertEqual(result.lines_wrapped, 1) + self.assertEqual(result.files_touched, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_wolf_wrap_search.py b/tests/test_wolf_wrap_search.py new file mode 100644 index 0000000..d359d7d --- /dev/null +++ b/tests/test_wolf_wrap_search.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Unit tests for search-first wrap workflow (util/wolfdawn/wrap_search.py).""" + +from __future__ import annotations + +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +os.chdir(ROOT) +sys.path.insert(0, str(ROOT)) + +import util.dazedwrap as dazedwrap # noqa: E402 +from util.wolfdawn import wrap_search as ws # noqa: E402 + +RUMOR_SHEET = "├■街の噂(MOB)" +GATEKEEPER_TEXT = ( + "That gatekeeper just randomly hits on any pretty woman he sees. " + "They say he once tried to pick up a horned rabbit." +) +GATEKEEPER_ROW = 26 +GATEKEEPER_FIELD = "ロ:町のウワサ0" + +RUMOR_FIXTURE = { + "file": "DataBase.project", + "kind": "db", + "groups": [ + { + "type": 29, + "typeName": RUMOR_SHEET, + "lines": [ + { + "row": 25, + "field": 1, + "fieldName": "ロ:町のウワサ0", + "source": "別の噂", + "text": "Short rumor that fits fine.", + }, + { + "row": GATEKEEPER_ROW, + "field": 1, + "fieldName": GATEKEEPER_FIELD, + "source": "門番", + "text": GATEKEEPER_TEXT, + }, + ], + } + ], +} + + +class TestWolfVisibleLength(unittest.TestCase): + def test_font_codes_do_not_count_toward_width(self): + plain = "Hello world" + coded = r"Hello \f[2]world" + self.assertEqual( + dazedwrap.max_line_visible_length(plain), + dazedwrap.max_line_visible_length(coded), + ) + + def test_gatekeeper_line_is_overflow_at_bulletin_width(self): + length = dazedwrap.max_line_visible_length(GATEKEEPER_TEXT) + self.assertGreater(length, 40) + self.assertGreater(length, 34) + + +class TestWrapSearch(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.translated = Path(self.tmp.name) / "translated" + self.translated.mkdir() + self.work = Path(self.tmp.name) / "wolf_json" + self.work.mkdir() + path = self.translated / "DataBase.project.json" + path.write_text(json.dumps(RUMOR_FIXTURE, ensure_ascii=False, indent=4), encoding="utf-8") + + def tearDown(self): + self.tmp.cleanup() + + def test_search_finds_gatekeeper_in_rumor_sheet(self): + hits = ws.search_translated_text("gatekeeper just randomly", self.translated) + self.assertEqual(len(hits), 1) + hit = hits[0] + self.assertEqual(hit.kind, "db") + self.assertEqual(hit.sheet_name, RUMOR_SHEET) + self.assertEqual(hit.row, GATEKEEPER_ROW) + self.assertEqual(hit.field_name, GATEKEEPER_FIELD) + self.assertEqual(hit.json_file, "DataBase.project.json") + self.assertIn("gatekeeper", hit.text.lower()) + + def test_search_finds_horned_rabbits_same_row(self): + hits = ws.search_translated_text("horned rabbit", self.translated) + self.assertEqual(len(hits), 1) + self.assertEqual(hits[0].row, GATEKEEPER_ROW) + + def test_wrap_overflow_in_sheet_changes_long_lines(self): + path = self.translated / "DataBase.project.json" + doc = json.loads(path.read_text(encoding="utf-8")) + changed = ws.wrap_overflow_in_sheet(path, doc, RUMOR_SHEET, width=34) + self.assertEqual(changed, 1) + doc2 = json.loads(path.read_text(encoding="utf-8")) + line = ws.locate_line( + doc2, + { + "kind": "db", + "sheet_name": RUMOR_SHEET, + "row": GATEKEEPER_ROW, + "field_name": GATEKEEPER_FIELD, + }, + ) + self.assertIsNotNone(line) + assert line is not None + self.assertIn("\n", line["text"]) + self.assertLessEqual(dazedwrap.max_line_visible_length(line["text"]), 34) + + def test_wrap_profile_remembers_sheet_width(self): + ws.set_sheet_width(self.work, RUMOR_SHEET, 34, json_file="DataBase.project.json") + profile = ws.load_wrap_profile(self.work) + self.assertEqual(ws.get_sheet_width(profile, RUMOR_SHEET), 34) + entry = profile["sheets"][RUMOR_SHEET] + self.assertEqual(entry["json_file"], "DataBase.project.json") + + def test_sheet_overflow_summary_counts_gatekeeper_row(self): + summaries = ws.sheet_overflow_summaries(self.translated, width=34) + rumor = [s for s in summaries if s.sheet_name == RUMOR_SHEET] + self.assertEqual(len(rumor), 1) + self.assertEqual(rumor[0].overflow_count, 1) + self.assertEqual(rumor[0].line_count, 2) + + +class TestNamesSearch(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.translated = Path(self.tmp.name) / "translated" + self.translated.mkdir() + fixture = { + "kind": "names", + "count": 1, + "names": [ + { + "source": "ブロthel scout line", + "text": ( + "Maybe I'll go scout out some newcomers at the brothel.\n" + "\u3000Hope there's a pure-looking girl with big boobs..." + ), + "note": "Profile · プロフィール", + "safety": "safe", + } + ], + } + (self.translated / "names.json").write_text( + json.dumps(fixture, ensure_ascii=False, indent=4), + encoding="utf-8", + ) + + def tearDown(self): + self.tmp.cleanup() + + def test_search_finds_names_json_profile_text(self): + hits = ws.search_translated_text("there's a pure", self.translated) + self.assertEqual(len(hits), 1) + hit = hits[0] + self.assertEqual(hit.kind, "names") + self.assertEqual(hit.json_file, "names.json") + self.assertIn("pure-looking", hit.text) + + def test_load_hit_from_names_index(self): + hits = ws.search_translated_text("pure-looking", self.translated) + self.assertEqual(len(hits), 1) + path, doc, line = ws.load_hit_from_id(self.translated, hits[0].hit_id) + self.assertIsNotNone(path) + assert path is not None and line is not None + self.assertEqual(path.name, "names.json") + self.assertIn("pure-looking", line["text"]) + + +class TestWrapPreview(unittest.TestCase): + SAMPLE = ( + '"Apparently there\'s a super hot female adventurer who just ' + 'came to town!? Maybe I can meet her if I head to the ' + r'\c[21]\f[20]tavern\c[19]\f[18]!"' + ) + + def test_preview_detects_wrap_needed(self): + info = ws.wrap_preview_info(self.SAMPLE, 40) + self.assertTrue(info["needs_wrap"]) + self.assertGreater(info["longest"], 40) + self.assertGreater(info["output_line_count"], 1) + + def test_format_preview_shows_line_counts(self): + preview = ws.format_wrap_preview(self.SAMPLE, 40) + self.assertIn("(40)", preview) + self.assertIn("Apparently", preview) + + def test_split_line_at_visible_width(self): + line = "Apparently there's a super hot female adventurer who just" + fit_end, line_len = ws.split_line_at_visible_width(line, 40) + self.assertEqual(line_len, len(line)) + self.assertGreater(fit_end, 0) + self.assertLess(fit_end, line_len) + self.assertLessEqual( + dazedwrap.max_line_visible_length(line[:fit_end]), + 40, + ) + + def test_codes_do_not_inflate_visible_counts(self): + text = r'See the \c[21]\f[20]tavern\c[19]\f[18] on Main Street' + info = ws.wrap_preview_info(text, 30) + self.assertLess(info["longest"], len(text)) + self.assertLessEqual(info["longest"], 30 + 10) + + +if __name__ == "__main__": + unittest.main() diff --git a/util/dazedwrap.py b/util/dazedwrap.py index 31cf5f7..01f0b0c 100644 --- a/util/dazedwrap.py +++ b/util/dazedwrap.py @@ -1,20 +1,83 @@ import re +# Wolf / RPG Maker inline codes that do not occupy visible box width. +_WOLF_INLINE_VISIBLE_STRIP = re.compile( + r"\\(?:" + r"r\[[^\]]*\]|" + r"c(?:self)?\[[^\]]*\]|" + r"f\[[^\]]*\]|" + r"[A-Za-z]+\[[^\]]*\]|" + r"\^|" + r"[ @<>\-]|" + r"[A-Za-z]" + r")" +) +_WOLF_CODE_PLACEHOLDER = re.compile(r"__WOLF_CODE_\d+__") + + def _get_visible_length(text: str) -> int: """ - Calculate the visible length of text, ignoring RPG Maker color codes. - - Args: - text (str): The text to measure - - Returns: - int: The length of the text excluding color codes + Calculate the visible length of text, ignoring inline control codes. + + Handles Wolf ``\\f[N]``, ``\\c[N]``, ruby ``\\r[...]``, and legacy RPG Maker + color codes so wrap-width checks match in-game bulletin / message boxes. """ - # Remove all color codes like \c[5] or \C[35], and also ignore \\! and \\. - cleaned_text = re.sub(r'[\\]+[cC]\[\d+\]', '', text) - # Remove \\! and \\. - cleaned_text = re.sub(r'[\\]+.', '', cleaned_text) - return len(cleaned_text) + if not text: + return 0 + cleaned = _WOLF_INLINE_VISIBLE_STRIP.sub("", text) + cleaned = _WOLF_CODE_PLACEHOLDER.sub("", cleaned) + cleaned = re.sub(r"[\\]+[cC]\[\d+\]", "", cleaned) + cleaned = re.sub(r"[\\]+[fF](?:\[\d+\])?", "", cleaned) + cleaned = re.sub(r"[\\]+.", "", cleaned) + cleaned = re.sub(r"\s+", " ", cleaned).strip() + return len(cleaned) + + +def visible_word_wrap_end_index(text: str, width: int) -> int: + """Return index into *text* after the last word that fits *width* visible chars. + + Uses the same word-wrapping rules as :func:`wrapText`. Wolf ``\\c[]`` / + ``\\f[]`` codes do not count toward *width*. + """ + if not text or width <= 0: + return len(text) + if _get_visible_length(text) <= width: + return len(text) + words = text.split() + if not words: + return len(text) + current_len = 0 + kept = 0 + pos = 0 + fit_end = 0 + for word in words: + wl = _get_visible_length(word) + gap = 1 if kept > 0 else 0 + if current_len + gap + wl <= width: + idx = text.find(word, pos) + if idx < 0: + break + pos = idx + len(word) + fit_end = pos + kept += 1 + current_len += gap + wl + else: + break + if kept == 0 and words: + idx = text.find(words[0], 0) + fit_end = (idx + len(words[0])) if idx >= 0 else len(text) + return fit_end + + +def max_line_visible_length(text: str) -> int: + """Longest visible line length in *text* (handles ``\\r`` / ``\\n``).""" + if not isinstance(text, str) or not text: + return 0 + normalized = text.replace("\r\n", "\n").replace("\r", "\n") + lines = normalized.split("\n") if normalized else [""] + if not lines: + return _get_visible_length(normalized) + return max(_get_visible_length(line) for line in lines) def wrapText(text: str, width: int) -> str: """ diff --git a/util/wolfdawn/selective_wrap.py b/util/wolfdawn/selective_wrap.py new file mode 100644 index 0000000..b1f74ce --- /dev/null +++ b/util/wolfdawn/selective_wrap.py @@ -0,0 +1,218 @@ +"""Selective word-wrap for WolfDawn translated JSON. + +Bulk ``wolf relayout`` / ``desc-relayout`` cannot target individual DB rows or +custom field types (e.g. ``セリフ_メッセージ``, ``好きなもの_コメント``). +This module wraps ``text`` fields in ``translated/*.project.json`` for checked +database sheets and field patterns, then the user re-injects only those files. + +``wolf relayout`` for maps already skips messages that fit; use it for dialogue +overflow. Use selective JSON wrap for custom DB columns and fine-grained control. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import util.dazedwrap as dazedwrap +from util.wolfdawn.db_classify import group_key, json_file_from_doc + +# Field-name presets for the Relayout tab (match ``fieldName`` substrings). +FIELD_PRESET_DESCRIPTIONS = re.compile(r"説明|Description", re.IGNORECASE) +FIELD_PRESET_COMMENTS = re.compile(r"コメント") +FIELD_PRESET_DIALOGUE = re.compile(r"セリフ|台詞|会話") +FIELD_PRESET_MESSAGES = re.compile(r"メッセージ") +FIELD_PRESET_ALL = re.compile(r".", re.DOTALL) + +FIELD_PRESETS: dict[str, re.Pattern[str]] = { + "descriptions": FIELD_PRESET_DESCRIPTIONS, + "comments": FIELD_PRESET_COMMENTS, + "dialogue": FIELD_PRESET_DIALOGUE, + "messages": FIELD_PRESET_MESSAGES, +} + + +@dataclass +class WrapResult: + files_touched: int = 0 + lines_wrapped: int = 0 + lines_skipped: int = 0 + touched_json: list[str] | None = None + + def __post_init__(self): + if self.touched_json is None: + self.touched_json = [] + + +def combine_field_patterns(enabled: frozenset[str]) -> re.Pattern[str] | None: + """Merge enabled preset keys into one regex, or None when nothing selected.""" + if not enabled: + return None + parts = [] + for key in enabled: + pat = FIELD_PRESETS.get(key) + if pat is not None and key != "all": + parts.append(f"(?:{pat.pattern})") + if "all" in enabled: + return FIELD_PRESET_ALL + if not parts: + return None + return re.compile("|".join(parts), re.IGNORECASE) + + +def _visible_length(text: str) -> int: + return dazedwrap._get_visible_length(text) + + +def line_needs_wrap(text: str, width: int, *, min_visible: int = 0) -> bool: + """True when *text* is long enough and exceeds *width* on at least one line.""" + if not isinstance(text, str) or not text.strip() or width <= 0: + return False + lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n") + if not lines: + visible = _visible_length(text) + if min_visible > 0 and visible < min_visible: + return False + return visible > width + total_visible = max((_visible_length(line) for line in lines), default=0) + if min_visible > 0 and total_visible < min_visible: + return False + return any(_visible_length(line) > width for line in lines) + + +def wrap_line_text(text: str, width: int) -> str: + """Word-wrap one ``text`` value, preserving WOLF inline codes. + + Width is measured with :func:`util.dazedwrap._get_visible_length`, so + ``\\c[N]``, ``\\f[N]``, and similar codes do not consume box width. + """ + if not isinstance(text, str) or not text.strip() or width <= 0: + return text + return dazedwrap.wrapText(text, width) + + +def group_type_indices(doc: dict[str, Any], group_keys: frozenset[str]) -> list[int]: + """Return sorted DB ``type`` indices for selected sheet keys.""" + jf = json_file_from_doc(doc) + indices: set[int] = set() + for group in doc.get("groups") or []: + type_name = str(group.get("typeName") or "") + if group_key(jf, type_name) not in group_keys: + continue + try: + idx = int(group.get("type", -1)) + except (TypeError, ValueError): + continue + if idx >= 0: + indices.add(idx) + return sorted(indices) + + +def wrap_db_document( + doc: dict[str, Any], + *, + group_keys: frozenset[str], + field_pattern: re.Pattern[str] | None, + width: int, + min_visible: int = 0, + only_if_overflow: bool = True, +) -> tuple[int, int]: + """Wrap matching lines in one ``kind: db`` document. Returns (wrapped, skipped).""" + if doc.get("kind") != "db" or not group_keys: + return 0, 0 + jf = json_file_from_doc(doc) + wrapped = skipped = 0 + for group in doc.get("groups") or []: + type_name = str(group.get("typeName") or "") + if group_key(jf, type_name) not in group_keys: + continue + for line in group.get("lines") or []: + field_name = str(line.get("fieldName") or "") + if field_pattern is not None and not field_pattern.search(field_name): + skipped += 1 + continue + text = line.get("text") + if not isinstance(text, str) or not text.strip(): + skipped += 1 + continue + if only_if_overflow and not line_needs_wrap( + text, width, min_visible=min_visible + ): + skipped += 1 + continue + new_text = wrap_line_text(text, width) + if new_text != text: + line["text"] = new_text + wrapped += 1 + else: + skipped += 1 + return wrapped, skipped + + +def wrap_db_translated_dir( + translated_dir: str | Path, + *, + group_keys: frozenset[str], + field_presets: frozenset[str], + width: int, + min_visible: int = 0, + only_if_overflow: bool = True, +) -> WrapResult: + """Apply selective wrap to all ``*.project.json`` under ``translated/``.""" + base = Path(translated_dir) + result = WrapResult() + if not base.is_dir() or not group_keys: + return result + field_pattern = combine_field_patterns(field_presets) + if field_pattern is None: + return result + + for path in sorted(base.glob("*.project.json")): + try: + doc = json.loads(path.read_text(encoding="utf-8-sig")) + except Exception: + continue + if doc.get("kind") != "db": + continue + w, s = wrap_db_document( + doc, + group_keys=group_keys, + field_pattern=field_pattern, + width=width, + min_visible=min_visible, + only_if_overflow=only_if_overflow, + ) + if w: + path.write_text( + json.dumps(doc, ensure_ascii=False, indent=4) + "\n", + encoding="utf-8", + ) + result.files_touched += 1 + result.lines_wrapped += w + result.touched_json.append(path.name) + result.lines_skipped += s + return result + + +def collect_field_names(translated_dir: str | Path) -> list[str]: + """Sorted unique ``fieldName`` values from staged/translated DB JSON.""" + base = Path(translated_dir) + names: set[str] = set() + if not base.is_dir(): + return [] + for path in base.glob("*.project.json"): + try: + doc = json.loads(path.read_text(encoding="utf-8-sig")) + except Exception: + continue + if doc.get("kind") != "db": + continue + for group in doc.get("groups") or []: + for line in group.get("lines") or []: + fn = str(line.get("fieldName") or "").strip() + if fn: + names.add(fn) + return sorted(names) diff --git a/util/wolfdawn/wrap_search.py b/util/wolfdawn/wrap_search.py new file mode 100644 index 0000000..360a630 --- /dev/null +++ b/util/wolfdawn/wrap_search.py @@ -0,0 +1,583 @@ +"""Search translated WolfDawn JSON for text to fix wrapping. + +Primary workflow: paste in-game text, find the database sheet and row, wrap at +the right width, re-inject. Sheet names match ``typeName`` in DB JSON and +``note`` in names.json (e.g. ``├■街の噂(MOB)``). +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Sequence + +import util.dazedwrap as dazedwrap +from util.wolfdawn.db_classify import group_key, json_file_from_doc +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 = f" longest line: {self.max_line_len} chars" + if width > 0 and self.max_line_len > width: + overflow += " (overflow)" + preview = self.text.replace("\n", " ")[:90] + if len(self.text) > 90: + preview += "…" + if self.kind == "db": + loc = f"row {self.row} · {self.field_name}" + elif self.kind == "names": + loc = f"entry #{self.row} · {self.field_name[:60]}" + 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 + return ( + f"Sheet: {self.sheet_name} · {self.json_file} · {loc}\n" + f" {preview}{overflow}" + ) + + +@dataclass +class SheetOverflowSummary: + json_file: str + sheet_name: str + line_count: int + overflow_count: int + tier: str = "" + + @property + def key(self) -> str: + return group_key(self.json_file, self.sheet_name) + + +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, +) -> None: + profile = load_wrap_profile(work_dir) + sheets = profile.setdefault("sheets", {}) + sheets[sheet_name] = {"width": int(width), "json_file": json_file} + profile["default_width"] = profile.get("default_width", DEFAULT_WRAP_WIDTH) + save_wrap_profile(work_dir, profile) + + +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") + if not _text_matches(text, q): + continue + note = str(entry.get("note") or "names.json") + source = str(entry.get("source") or "")[:80] + 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=source or f"entry {idx}", + text=str(text), + max_line_len=dazedwrap.max_line_visible_length(str(text)), + ) + ) + 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 format_wrap_preview(text: str, width: int) -> str: + """Multiline preview with per-line visible character counts.""" + info = wrap_preview_info(text, width) + if not info["wrapped"]: + return "" + lines = info["wrapped"].replace("\r\n", "\n").replace("\r", "\n").split("\n") + parts: list[str] = [] + for i, line in enumerate(lines): + vis = dazedwrap.max_line_visible_length(line) + marker = "⚠" if vis > width else " " + parts.append(f"{marker} {i + 1:2d} ({vis:2d}) {line}") + return "\n".join(parts) + + +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)." + ) + + +def split_line_at_visible_width(line: str, width: int) -> tuple[int, int]: + """Return ``(fit_end, line_len)`` char indices in *line* for UI highlighting.""" + line_len = len(line) + if not line or width <= 0: + return (line_len, line_len) + fit_end = dazedwrap.visible_word_wrap_end_index(line, width) + return (min(fit_end, line_len), line_len) + + +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_line_text(text, width) + if new_text == text: + return False + line["text"] = new_text + return True + + +def wrap_hit_in_file( + path: Path, + doc: dict[str, Any], + hit_id: dict[str, Any], + width: int, +) -> bool: + line = locate_line(doc, hit_id) + if line is None: + return False + if not wrap_line_in_doc(line, width): + return False + save_document(path, doc) + return True + + +def wrap_overflow_in_sheet( + path: Path, + doc: dict[str, Any], + sheet_name: str, + width: int, +) -> int: + """Wrap every overflowing line in one DB sheet; return count changed.""" + 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_wrap(text, width): + continue + if wrap_line_in_doc(line, width): + changed += 1 + if changed: + save_document(path, doc) + return changed + + +def sheet_overflow_summaries( + translated_dir: str | Path, + width: int, + *, + files_dir: str | Path | None = None, +) -> list[SheetOverflowSummary]: + """Per DB sheet overflow counts at *width*.""" + from util.wolfdawn.db_classify import analyze_content_distribution, classify_db_document + + base = Path(translated_dir) + if not base.is_dir(): + base = Path(files_dir) if files_dir else base + dist = analyze_content_distribution(base) + summaries: list[SheetOverflowSummary] = [] + + for path in sorted(base.glob("*.project.json")): + doc = _load_json(path) + if not doc or doc.get("kind") != "db": + continue + jf = path.name + for group in classify_db_document(doc, json_file=jf): + overflow = 0 + for g in doc.get("groups") or []: + if str(g.get("typeName") or "") != group.type_name: + continue + for line in g.get("lines") or []: + text = line.get("text") + if isinstance(text, str) and line_needs_wrap(text, width): + overflow += 1 + summaries.append( + SheetOverflowSummary( + json_file=jf, + sheet_name=group.type_name, + line_count=group.line_count, + overflow_count=overflow, + tier=group.tier, + ) + ) + if summaries: + return summaries + return [ + SheetOverflowSummary( + json_file=g.json_file, + sheet_name=g.type_name, + line_count=g.line_count, + overflow_count=0, + tier=g.tier, + ) + for g in dist.groups + ]