From 47b0936a75a2a28fb68d78776be55f9136607392 Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Thu, 9 Jul 2026 14:15:33 -0500 Subject: [PATCH] Fix DB Relayout --- gui/wolf_workflow_tab.py | 91 +++++++++++++++--- tests/test_wolf_wrap_search.py | 92 ++++++++++++++++++ util/wolfdawn/wrap_search.py | 166 ++++++++++++++++++++++++++++++--- 3 files changed, 325 insertions(+), 24 deletions(-) diff --git a/gui/wolf_workflow_tab.py b/gui/wolf_workflow_tab.py index 5114ee1..d763027 100644 --- a/gui/wolf_workflow_tab.py +++ b/gui/wolf_workflow_tab.py @@ -94,6 +94,7 @@ from util.wolfdawn import names as wolf_names from util.wolfdawn import codes as wolf_codes from util.wolfdawn import db_classify as wolf_db 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 ( @@ -2667,7 +2668,8 @@ class WolfWorkflowTab(QWidget): idx = self._wrap_mode_combo.findData(saved_mode) self._wrap_mode_combo.setCurrentIndex(idx if idx >= 0 else 0) self._wrap_mode_combo.setToolTip( - "Relayout: wolf names-wrap / JP slot geometry (cell width + max lines).\n" + "Relayout: names use wolf names-wrap; DB/dialogue wrap to cell width " + "and shrink \\f[N] to fit Max lines.\n" "Manual: force wrap width and body font; emphasis \\f[N] scales with the body." ) self._wrap_mode_combo.currentIndexChanged.connect(self._on_wrap_mode_changed) @@ -2706,8 +2708,10 @@ class WolfWorkflowTab(QWidget): self._wrap_max_lines_spin.setValue(0) self._wrap_max_lines_spin.setFixedWidth(70) self._wrap_max_lines_spin.setToolTip( - "wolf names-wrap --lines (0 = each entry's JP line count). " - "Saved as maxLines in wolfdawn-roles.json." + "Max soft lines after Relayout. For names: wolf names-wrap --lines " + "(0 = each entry's JP line count). For DB/dialogue: wrap to width, " + "then shrink \\f[N] until the line count fits. " + "Saved per sheet in wolfdawn-roles.json." ) self._wrap_max_lines_spin.valueChanged.connect(self._on_wrap_max_lines_changed) relayout_row.addWidget(w_lbl) @@ -3081,19 +3085,60 @@ class WolfWorkflowTab(QWidget): text = self._current_wrap_hit.text width = self._active_wrap_width() body_font = self._wrap_font_value() if self._wrap_mode() == "manual" else None - # Manual mode: preview the scaled fonts Wrap would write (document unchanged). + # Manual: preview scaled fonts. Relayout + max lines: preview fit-to-box. preview_src = text + preview_width = width if body_font is not None and preview_src.strip(): preview_src, _ = wolf_codes.scale_font_sizes(preview_src, body_font) - summary = wolf_ws.wrap_preview_summary(preview_src, width) + elif self._wrap_mode() == "relayout" and width > 0: + max_lines = self._wrap_max_lines_value() + if max_lines > 0 and preview_src.strip(): + box_font = None + line = None + if self._current_wrap_doc and self._current_wrap_hit_id: + line = wolf_ws.locate_line( + self._current_wrap_doc, self._current_wrap_hit_id + ) + if isinstance(line, dict): + source = line.get("source") + if isinstance(source, str) and wolf_codes.detect_font_sizes(source): + box_font = wolf_codes.infer_base_font_size(source) + preview_src, _ = wolf_ws.fit_text_to_box( + preview_src, width, max_lines=max_lines, box_font=box_font + ) + # Soft breaks are already final; don't reflow at the narrow cell width. + preview_width = max( + width, + dazedwrap.max_line_visible_length(preview_src), + ) + summary = wolf_ws.wrap_preview_summary(preview_src, preview_width) preview_html = wolf_ws.format_wrap_preview_html( - text, width, body_font=body_font + preview_src if self._wrap_mode() == "relayout" else text, + preview_width, + body_font=body_font, ) - info = wolf_ws.wrap_preview_info(preview_src, width) + info = wolf_ws.wrap_preview_info(preview_src, preview_width) if hasattr(self, "_wrap_preview_label"): if summary: + soft = wolf_ws.count_soft_lines(preview_src) + max_lines = self._wrap_max_lines_value() + if ( + self._wrap_mode() == "relayout" + and max_lines > 0 + and soft > 0 + ): + summary = f"{summary} · {soft}/{max_lines} soft lines" self._wrap_preview_label.setText(summary) - color = "#ce9178" if info.get("needs_wrap") else "#8fbc8f" + over_lines = ( + self._wrap_mode() == "relayout" + and max_lines > 0 + and soft > max_lines + ) + color = ( + "#ce9178" + if info.get("needs_wrap") or over_lines + else "#8fbc8f" + ) self._wrap_preview_label.setStyleSheet( f"color:{color};font-size:11px;background:transparent;" ) @@ -3103,7 +3148,11 @@ class WolfWorkflowTab(QWidget): "color:#858585;font-size:11px;background:transparent;" ) self._wrap_preview.setHtml(preview_html) - border = "#ce9178" if info.get("needs_wrap") else "#007acc" + over_lines = False + if self._wrap_mode() == "relayout": + max_lines = self._wrap_max_lines_value() + over_lines = max_lines > 0 and wolf_ws.count_soft_lines(preview_src) > max_lines + border = "#ce9178" if info.get("needs_wrap") or over_lines else "#007acc" self._wrap_preview.setStyleSheet( "QTextEdit{background-color:#1e1e1e;color:#d4d4d4;border:1px solid #3c3c3c;" f"border-left:3px solid {border};padding:6px;}}" @@ -3284,6 +3333,7 @@ class WolfWorkflowTab(QWidget): self._current_wrap_doc, self._current_wrap_hit_id, width, + max_lines=max_lines or None, ) if self._current_wrap_hit: self._remember_sheet_width( @@ -3302,7 +3352,19 @@ class WolfWorkflowTab(QWidget): self._current_wrap_doc = doc if line is not None: self._current_wrap_text = str(line.get("text") or "") - msg = f"Wrapped line at width {width}." if changed else "Line already fits at this width." + if changed: + if max_lines > 0: + msg = ( + f"Relayout at width {width}, max {max_lines} line(s) " + f"(shrink \\f if needed)." + ) + else: + msg = f"Wrapped line at width {width}." + else: + msg = ( + f"Already fits width {width}" + + (f" / {max_lines} line(s)." if max_lines > 0 else ".") + ) self._wrap_status_label.setText(msg + " Inject all from Step 7 when ready.") self._log(f"{'✅' if changed else 'ℹ'} {msg}") self._update_wrap_preview() @@ -3374,12 +3436,19 @@ class WolfWorkflowTab(QWidget): self._current_wrap_doc, hit, width, + max_lines=max_lines or None, ) self._remember_sheet_width( hit.sheet_name, width, hit.json_file, max_lines=max_lines or None ) scope = wolf_ws.scope_label(hit) - msg = f"Wrapped {count} line(s) in {scope} at width {width}." + if max_lines > 0: + msg = ( + f"Relayout {count} line(s) in {scope} " + f"(width {width}, max {max_lines} lines)." + ) + else: + msg = f"Wrapped {count} line(s) in {scope} at width {width}." self._wrap_status_label.setText(msg + " Inject all from Step 7 when ready.") self._log(f"✅ {msg}") self._reload_wrap_hit_editor(hit, width) diff --git a/tests/test_wolf_wrap_search.py b/tests/test_wolf_wrap_search.py index 92afbe3..beb7100 100644 --- a/tests/test_wolf_wrap_search.py +++ b/tests/test_wolf_wrap_search.py @@ -281,5 +281,97 @@ class TestEventScopeWrap(unittest.TestCase): self.assertEqual(ws.wrap_overflow_in_scope(path, doc, hit, 34), 1) +class TestFitTextToBox(unittest.TestCase): + """DB Relayout: wrap + font shrink until soft line count fits.""" + + RUMOR = ( + '\\f[14]"I heard there\'s a super hot female adventurer who\n' + "came to town recently!? Maybe I'll catch a glimpse\n" + 'of her at the \\c[21]\\f[15]tavern\\c[19]\\f[14]!"' + ) + + def test_count_soft_lines(self): + self.assertEqual(ws.count_soft_lines("a\nb\nc"), 3) + self.assertEqual(ws.count_soft_lines("one line"), 1) + self.assertEqual(ws.count_soft_lines(""), 0) + + def test_max_lines_zero_wraps_only(self): + fitted, changed = ws.fit_text_to_box(self.RUMOR, 44, max_lines=0) + self.assertTrue(changed) + self.assertEqual(ws.count_soft_lines(fitted), 3) + self.assertIn(r"\f[14]", fitted) + + def test_max_lines_shrinks_font_and_fits(self): + fitted, changed = ws.fit_text_to_box(self.RUMOR, 44, max_lines=2) + self.assertTrue(changed) + self.assertLessEqual(ws.count_soft_lines(fitted), 2) + from util.wolfdawn import codes as wolf_codes + + self.assertLess(wolf_codes.infer_base_font_size(fitted), 14) + self.assertIn("tavern", fitted) + + def test_box_font_from_jp_source_scales_width(self): + """Native box px from JP source lets more cells fit when \\f shrinks.""" + from util.wolfdawn import codes as wolf_codes + + fitted, changed = ws.fit_text_to_box( + self.RUMOR, 44, max_lines=2, box_font=18 + ) + self.assertTrue(changed) + self.assertLessEqual(ws.count_soft_lines(fitted), 2) + # With box_font=18, body can stay larger than when native=14. + self.assertGreaterEqual(wolf_codes.infer_base_font_size(fitted), 12) + fitted2, changed2 = ws.fit_text_to_box( + fitted, 44, max_lines=2, box_font=18 + ) + self.assertFalse(changed2) + self.assertEqual(fitted2, fitted) + + def test_wrap_hit_in_file_honors_max_lines(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "DataBase.project.json" + source = ( + "「最近町に来た女冒険者が激マブらしいぞ!?\n" + " \\c[21]\\f[20]酒場\\c[19]\\f[18]に行ったら会えるかな!」" + ) + doc = { + "kind": "db", + "file": "DataBase.project", + "groups": [ + { + "typeName": RUMOR_SHEET, + "lines": [ + { + "row": 21, + "field": 25, + "fieldName": GATEKEEPER_FIELD, + "source": source, + "text": self.RUMOR, + } + ], + } + ], + } + path.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8") + hit_id = { + "kind": "db", + "sheet_name": RUMOR_SHEET, + "row": 21, + "field_name": GATEKEEPER_FIELD, + } + self.assertTrue( + ws.wrap_hit_in_file(path, doc, hit_id, 44, max_lines=2) + ) + line = ws.locate_line(doc, hit_id) + self.assertIsNotNone(line) + self.assertLessEqual(ws.count_soft_lines(str(line["text"])), 2) + from util.wolfdawn import codes as wolf_codes + + # JP body is \\f[18]; shrink should land near 12, not floor at 8. + self.assertGreaterEqual( + wolf_codes.infer_base_font_size(str(line["text"])), 12 + ) + + if __name__ == "__main__": unittest.main() diff --git a/util/wolfdawn/wrap_search.py b/util/wolfdawn/wrap_search.py index d5a671f..ce91e7c 100644 --- a/util/wolfdawn/wrap_search.py +++ b/util/wolfdawn/wrap_search.py @@ -633,6 +633,110 @@ def wrap_line_in_doc(line: dict[str, Any], width: int) -> bool: 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 fit_text_to_box( + text: str, + width: int, + *, + max_lines: int | None = None, + min_font: int = 8, + box_font: int | None = None, +) -> 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). + """ + 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 + + limit = int(max_lines) if max_lines is not None else 0 + sizes = wolf_codes.detect_font_sizes(text) + if box_font is not None and int(box_font) > 0: + native = int(box_font) + elif sizes: + native = wolf_codes.infer_base_font_size(text) + 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(text, default=native) if sizes else native + new_text = wrap_line_text(text, _effective_width(current) if sizes else width) + if limit <= 0: + return new_text, new_text != text + + # No \\f yet and already over budget: introduce the native body size first. + if not sizes and count_soft_lines(new_text) > limit: + new_text, _ = wolf_codes.scale_font_sizes(new_text, native) + current = native + new_text = wrap_line_text(new_text, _effective_width(current)) + + guard = 0 + while count_soft_lines(new_text) > limit and guard < 64: + guard += 1 + current = wolf_codes.infer_base_font_size(new_text, default=current) + if current <= min_font: + break + target = current - 1 + new_text, _ = wolf_codes.scale_font_sizes(new_text, target) + current = target + new_text = wrap_line_text(new_text, _effective_width(current)) + + 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, + ) + if not changed: + return False + line["text"] = new_text + return True + + def apply_manual_font_and_wrap( text: str, width: int, @@ -925,7 +1029,24 @@ def scope_stats( return ScopeStats(label=scope_label(hit), total=total, overflow=overflow) -def _wrap_overflow_in_event_file(path: Path, doc: dict[str, Any], width: int) -> int: +def _line_needs_relayout(text: str, width: int, max_lines: int | None) -> bool: + """True if width wrap or max-lines fit would change *text*.""" + if line_needs_wrap(text, width): + return True + limit = int(max_lines) if max_lines is not None else 0 + if limit <= 0: + return False + wrapped = wrap_line_text(text, width) + return count_soft_lines(wrapped) > limit or wrapped != text + + +def _wrap_overflow_in_event_file( + path: Path, + doc: dict[str, Any], + width: int, + *, + max_lines: int | None = None, +) -> int: """Wrap overflowing dialogue lines in one map or CommonEvent JSON file.""" if doc.get("kind") not in ("map", "common"): return 0 @@ -935,16 +1056,22 @@ def _wrap_overflow_in_event_file(path: Path, doc: dict[str, Any], width: int) -> text = line.get("text") if not isinstance(text, str) or not text.strip(): continue - if not line_needs_wrap(text, width): + if not _line_needs_relayout(text, width, max_lines): continue - if wrap_line_in_doc(line, width): + 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_gamedat(path: Path, doc: dict[str, Any], width: int) -> int: +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 @@ -954,9 +1081,9 @@ def _wrap_overflow_in_gamedat(path: Path, doc: dict[str, Any], width: int) -> in text = line.get("text") if not isinstance(text, str) or not text.strip(): continue - if not line_needs_wrap(text, width): + if not _line_needs_relayout(text, width, max_lines): continue - if wrap_line_in_doc(line, width): + if apply_relayout_to_line_dict(line, width, max_lines=max_lines): changed += 1 if changed: save_document(path, doc) @@ -968,8 +1095,15 @@ def wrap_overflow_in_scope( doc: dict[str, Any], hit: WrapHit | dict[str, Any], width: int, + *, + max_lines: int | None = None, ) -> int: - """Wrap every overflowing line in the same group as *hit*.""" + """Wrap overflowing lines in the same group/file as *hit*. + + For DB / map / common / gamedat, *max_lines* also shrinks ``\\f[N]`` until + the soft line count fits (Relayout). Names categories ignore *max_lines* + here - use ``wolf names-wrap`` via the GUI instead. + """ if isinstance(hit, WrapHit): kind, sheet_name = hit.kind, hit.sheet_name else: @@ -979,11 +1113,13 @@ def wrap_overflow_in_scope( 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") + return wrap_overflow_in_sheet( + path, doc, sheet_name, width, kind="db", max_lines=max_lines + ) if kind in ("map", "common"): - return _wrap_overflow_in_event_file(path, doc, width) + return _wrap_overflow_in_event_file(path, doc, width, max_lines=max_lines) if kind == "gamedat": - return _wrap_overflow_in_gamedat(path, doc, width) + return _wrap_overflow_in_gamedat(path, doc, width, max_lines=max_lines) return 0 @@ -992,11 +1128,14 @@ def wrap_hit_in_file( 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 wrap_line_in_doc(line, width): + if not apply_relayout_to_line_dict(line, width, max_lines=max_lines): return False save_document(path, doc) return True @@ -1009,6 +1148,7 @@ def wrap_overflow_in_sheet( 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") @@ -1024,9 +1164,9 @@ def wrap_overflow_in_sheet( text = line.get("text") if not isinstance(text, str) or not text.strip(): continue - if not line_needs_wrap(text, width): + if not _line_needs_relayout(text, width, max_lines): continue - if wrap_line_in_doc(line, width): + if apply_relayout_to_line_dict(line, width, max_lines=max_lines): changed += 1 if changed: save_document(path, doc)