Fix DB Relayout

This commit is contained in:
DazedAnon 2026-07-09 14:15:33 -05:00
parent 195a11ec08
commit 47b0936a75
3 changed files with 325 additions and 24 deletions

View file

@ -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)

View file

@ -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()

View file

@ -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)