From 2b4a0e257f616dba633f74915c69498ebf01be29 Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Wed, 8 Jul 2026 18:25:01 -0500 Subject: [PATCH] font and width --- gui/wolf_workflow_tab.py | 125 ++++++++++++++++++++++++++++----- tests/test_wolf_codes.py | 14 ++++ tests/test_wolf_wrap_search.py | 49 +++++++++++++ util/wolfdawn/codes.py | 34 +++++++++ util/wolfdawn/wrap_search.py | 102 +++++++++++++++++++++++---- 5 files changed, 294 insertions(+), 30 deletions(-) diff --git a/gui/wolf_workflow_tab.py b/gui/wolf_workflow_tab.py index 78d7a24..9513b13 100644 --- a/gui/wolf_workflow_tab.py +++ b/gui/wolf_workflow_tab.py @@ -86,6 +86,7 @@ from gui.workflow_tab import ( _make_section_label, ) 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 selective_wrap as wolf_selwrap from util.wolfdawn import wrap_search as wolf_ws @@ -2663,6 +2664,25 @@ class WolfWorkflowTab(QWidget): width_row.addStretch() layout.addLayout(width_row) + font_row = QHBoxLayout() + font_lbl = QLabel("Font size \\f[N]:") + font_lbl.setStyleSheet("color:#cccccc;font-size:12px;background:transparent;") + self._wrap_font_spin = QSpinBox() + self._wrap_font_spin.setRange(8, 32) + self._wrap_font_spin.setValue(18) + self._wrap_font_spin.setFixedWidth(70) + self._wrap_font_detect_label = QLabel("") + self._wrap_font_detect_label.setStyleSheet( + "color:#858585;font-size:11px;background:transparent;" + ) + apply_font_btn = _make_btn("Apply \\f to text", "#3a3a3a") + apply_font_btn.clicked.connect(self._apply_wrap_font_size) + font_row.addWidget(font_lbl) + font_row.addWidget(self._wrap_font_spin) + font_row.addWidget(apply_font_btn) + font_row.addWidget(self._wrap_font_detect_label, 1) + layout.addLayout(font_row) + self._wrap_preview_label = QLabel("Wrap preview") self._wrap_preview_label.setStyleSheet( "color:#858585;font-size:11px;background:transparent;" @@ -2705,7 +2725,7 @@ class WolfWorkflowTab(QWidget): layout.addWidget(self._wrap_status_label) layout.addWidget(_make_hr()) - sheets_toggle = QPushButton("▸ Browse database sheets (overflow counts)") + sheets_toggle = QPushButton("▸ Browse sheets & name categories (overflow counts)") sheets_toggle.setCheckable(True) sheets_toggle.setChecked(False) sheets_toggle.setStyleSheet( @@ -2721,8 +2741,8 @@ class WolfWorkflowTab(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)." + "Database sheets and names.json categories (``note``) with lines longer than " + "the wrap width above. Double-click to open the first overflowing entry." )) self._wrap_sheets_list = QListWidget() self._wrap_sheets_list.setMaximumHeight(140) @@ -3094,8 +3114,47 @@ class WolfWorkflowTab(QWidget): f"{hit.field_name} · longest line {hit.max_line_len} chars" ) self._wrap_detail_label.setText(detail) + self._sync_wrap_font_controls(text) self._update_wrap_preview() + def _sync_wrap_font_controls(self, text: str): + if not hasattr(self, "_wrap_font_spin"): + return + sizes = wolf_codes.detect_font_sizes(text) + if sizes: + self._wrap_font_spin.blockSignals(True) + self._wrap_font_spin.setValue(sizes[-1]) + self._wrap_font_spin.blockSignals(False) + shown = ", ".join(f"\\f[{s}]" for s in sizes[:4]) + if len(sizes) > 4: + shown += ", …" + self._wrap_font_detect_label.setText(f"In text: {shown}") + else: + self._wrap_font_detect_label.setText("No \\f[N] in text — Apply adds \\f at start") + + def _apply_wrap_font_size(self): + if not self._wrap_text_editor.isEnabled(): + QMessageBox.information(self, "Fix wrapping", "Select a search result first.") + return + text = self._wrap_text_editor.toPlainText() + size = self._wrap_font_spin.value() + new_text, count = wolf_codes.apply_font_size(text, size) + if new_text == text: + self._wrap_status_label.setText("No font size change applied.") + return + self._wrap_text_editor.setPlainText(new_text) + if self._current_wrap_hit_id and self._current_wrap_path and self._current_wrap_doc: + line = wolf_ws.locate_line(self._current_wrap_doc, self._current_wrap_hit_id) + if line is not None: + line["text"] = new_text + wolf_ws.save_document(self._current_wrap_path, self._current_wrap_doc) + self._sync_wrap_font_controls(new_text) + self._update_wrap_preview() + self._wrap_status_label.setText( + f"Set \\f[{size}] on {count} code(s). Save or inject when ready." + ) + self._log(f"✅ Applied \\f[{size}] to wrap editor text ({count} change(s)).") + 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.") @@ -3144,10 +3203,10 @@ class WolfWorkflowTab(QWidget): def _wrap_current_sheet(self): hit = self._current_wrap_hit - if not hit or hit.kind != "db": + if not hit or hit.kind not in ("db", "names"): QMessageBox.information( self, "Fix wrapping", - "Select a database sheet result first (not map dialogue).", + "Select a database sheet or names.json category result first.", ) return tdir, fdir = self._translated_and_files_dirs() @@ -3159,10 +3218,13 @@ class WolfWorkflowTab(QWidget): 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) + count = wolf_ws.wrap_overflow_in_sheet( + path, doc, hit.sheet_name, width, kind=hit.kind, + ) 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}." + label = "category" if hit.kind == "names" else "sheet" + msg = f"Wrapped {count} overflowing line(s) in {label} {hit.sheet_name} at width {width}." self._wrap_status_label.setText(msg + f" Inject {hit.json_file}.") self._log(f"✅ {msg}") @@ -3183,22 +3245,28 @@ class WolfWorkflowTab(QWidget): 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 = QListWidgetItem("No database or names.json data found.") 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)): + for s in sorted( + summaries, + key=lambda x: (x.kind != "names", -x.overflow_count, x.sheet_name), + ): + kind_tag = "names" if s.kind == "names" else "DB" 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}" + f"[{kind_tag}] {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) + item.setData(Qt.UserRole + 2, s.kind) 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) + kind = item.data(Qt.UserRole + 2) or "db" if not sheet or not jf: return tdir, fdir = self._translated_and_files_dirs() @@ -3209,6 +3277,26 @@ class WolfWorkflowTab(QWidget): if not path.is_file(): return doc = json.loads(path.read_text(encoding="utf-8-sig")) + if kind == "names": + for idx, entry in enumerate(doc.get("names") or []): + if not isinstance(entry, dict): + continue + if str(entry.get("note") or "") != sheet: + continue + text = entry.get("text") + if isinstance(text, str) and wolf_selwrap.line_needs_wrap(text, width): + hit = wolf_ws.WrapHit( + json_file=str(jf), + kind="names", + sheet_name=str(sheet), + row=idx, + field_name=str(entry.get("source") or "")[:80] or f"entry {idx}", + text=str(text), + max_line_len=dazedwrap.max_line_visible_length(str(text)), + ) + self._show_wrap_hit_in_editor(hit, width) + return + return for group in doc.get("groups") or []: if str(group.get("typeName") or "") != sheet: continue @@ -3224,15 +3312,18 @@ class WolfWorkflowTab(QWidget): 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) + self._show_wrap_hit_in_editor(hit, width) return + def _show_wrap_hit_in_editor(self, hit: wolf_ws.WrapHit, width: int): + self._wrap_search_edit.setText(str(hit.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) + 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 diff --git a/tests/test_wolf_codes.py b/tests/test_wolf_codes.py index 5640303..870d5c8 100644 --- a/tests/test_wolf_codes.py +++ b/tests/test_wolf_codes.py @@ -59,6 +59,20 @@ class WolfCodesRepairTests(unittest.TestCase): "Fortune-teller\nHa!\\^", ) + def test_apply_font_size_replaces_existing_codes(self): + text = r"Go to the \c[21]\f[20]tavern\c[19]\f[18]!" + new_text, count = wolf_codes.apply_font_size(text, 16) + self.assertEqual(count, 2) + self.assertIn(r"\f[16]", new_text) + self.assertNotIn(r"\f[20]", new_text) + self.assertNotIn(r"\f[18]", new_text) + + def test_apply_font_size_prepends_when_missing(self): + text = "Plain text without font codes" + new_text, count = wolf_codes.apply_font_size(text, 18) + self.assertEqual(count, 1) + self.assertTrue(new_text.startswith(r"\f[18]")) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_wolf_wrap_search.py b/tests/test_wolf_wrap_search.py index d359d7d..fa84d2b 100644 --- a/tests/test_wolf_wrap_search.py +++ b/tests/test_wolf_wrap_search.py @@ -214,5 +214,54 @@ class TestWrapPreview(unittest.TestCase): self.assertLessEqual(info["longest"], 30 + 10) +class TestNamesCategoryWrap(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.translated = Path(self.tmp.name) / "translated" + self.translated.mkdir() + self.category = "├■街の噂(MOB)" + fixture = { + "kind": "names", + "count": 2, + "names": [ + { + "source": "短い", + "text": "Short line.", + "note": self.category, + }, + { + "source": "長い", + "text": ( + "This is a very long English rumor that should definitely " + "wrap when the width is set to thirty-four characters." + ), + "note": self.category, + }, + ], + } + self.path = self.translated / "names.json" + self.path.write_text(json.dumps(fixture, ensure_ascii=False, indent=4), encoding="utf-8") + + def tearDown(self): + self.tmp.cleanup() + + def test_names_categories_in_sheet_summaries(self): + summaries = ws.sheet_overflow_summaries(self.translated, width=34) + names_rows = [s for s in summaries if s.kind == "names" and s.sheet_name == self.category] + self.assertEqual(len(names_rows), 1) + self.assertEqual(names_rows[0].line_count, 2) + self.assertEqual(names_rows[0].overflow_count, 1) + + def test_wrap_all_in_names_category(self): + doc = json.loads(self.path.read_text(encoding="utf-8")) + changed = ws.wrap_overflow_in_sheet( + self.path, doc, self.category, 34, kind="names", + ) + self.assertEqual(changed, 1) + doc2 = json.loads(self.path.read_text(encoding="utf-8")) + long_entry = doc2["names"][1]["text"] + self.assertIn("\n", long_entry) + + if __name__ == "__main__": unittest.main() diff --git a/util/wolfdawn/codes.py b/util/wolfdawn/codes.py index 1609a40..8c44478 100644 --- a/util/wolfdawn/codes.py +++ b/util/wolfdawn/codes.py @@ -29,6 +29,40 @@ WOLF_INLINE_CODE_RE = re.compile( # Placeholder transport (same convention as util.translation.protect_script_codes). _PLACEHOLDER_PREFIX = "__WOLF_CODE_" +# Wolf font-size codes: ``\f[18]``, ``\f[20]``, etc. +WOLF_FONT_SIZE_RE = re.compile(r"\\f\[(\d+)\]") + + +def detect_font_sizes(text: str) -> list[int]: + """Return sorted unique ``\\f[N]`` sizes in *text*.""" + if not isinstance(text, str) or not text: + return [] + sizes: set[int] = set() + for match in WOLF_FONT_SIZE_RE.finditer(text): + try: + sizes.add(int(match.group(1))) + except (TypeError, ValueError): + continue + return sorted(sizes) + + +def apply_font_size(text: str, size: int) -> tuple[str, int]: + """Replace every ``\\f[N]`` in *text* with ``\\f[size]``. + + Returns ``(new_text, replacements)``. When no ``\\f[N]`` codes exist, prepends + ``\\f[size]`` at the start of the string (after an optional opening quote). + """ + if not isinstance(text, str) or not text.strip() or size <= 0: + return text, 0 + size = int(size) + matches = list(WOLF_FONT_SIZE_RE.finditer(text)) + if matches: + new_text = WOLF_FONT_SIZE_RE.sub(rf"\\f[{size}]", text) + return new_text, len(matches) + if text.startswith('"'): + return rf'\f[{size}]' + text, 1 + return rf"\f[{size}]{text}", 1 + def _tokenize(rest: str) -> list[tuple[str, str]]: """Split *rest* into ``('lit', ...)`` and ``('code', ...)`` tokens.""" diff --git a/util/wolfdawn/wrap_search.py b/util/wolfdawn/wrap_search.py index 360a630..e374001 100644 --- a/util/wolfdawn/wrap_search.py +++ b/util/wolfdawn/wrap_search.py @@ -92,9 +92,12 @@ class SheetOverflowSummary: line_count: int overflow_count: int tier: str = "" + kind: str = "db" @property def key(self) -> str: + if self.kind == "names": + return f"{self.json_file}|names|{self.sheet_name}" return group_key(self.json_file, self.sheet_name) @@ -510,8 +513,13 @@ def wrap_overflow_in_sheet( doc: dict[str, Any], sheet_name: str, width: int, + *, + kind: str | None = None, ) -> int: - """Wrap every overflowing line in one DB sheet; return count changed.""" + """Wrap every overflowing line in one DB sheet or names.json category.""" + doc_kind = kind or doc.get("kind") + if doc_kind == "names": + return _wrap_overflow_in_names_category(path, doc, sheet_name, width) if doc.get("kind") != "db": return 0 changed = 0 @@ -531,6 +539,62 @@ def wrap_overflow_in_sheet( return changed +def _wrap_overflow_in_names_category( + path: Path, + doc: dict[str, Any], + category: str, + width: int, +) -> int: + """Wrap overflowing ``names[]`` entries sharing one ``note`` (category).""" + if doc.get("kind") != "names": + return 0 + changed = 0 + for entry in doc.get("names") or []: + if not isinstance(entry, dict): + continue + if str(entry.get("note") or "") != category: + continue + text = entry.get("text") + if not isinstance(text, str) or not text.strip(): + continue + if not line_needs_wrap(text, width): + continue + if wrap_line_in_doc(entry, width): + changed += 1 + if changed: + save_document(path, doc) + return changed + + +def _names_category_summaries( + doc: dict[str, Any], + *, + json_file: str, + width: int, +) -> list[SheetOverflowSummary]: + counts: dict[str, int] = {} + overflow: dict[str, int] = {} + for entry in doc.get("names") or []: + if not isinstance(entry, dict): + continue + note = str(entry.get("note") or "names.json") + counts[note] = counts.get(note, 0) + 1 + text = entry.get("text") + if isinstance(text, str) and line_needs_wrap(text, width): + overflow[note] = overflow.get(note, 0) + 1 + return [ + SheetOverflowSummary( + json_file=json_file, + sheet_name=note, + line_count=counts[note], + overflow_count=overflow.get(note, 0), + tier="names", + kind="names", + ) + for note in sorted(counts) + ] + + def sheet_overflow_summaries( translated_dir: str | Path, width: int, @@ -569,15 +633,27 @@ def sheet_overflow_summaries( 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 - ] + if not summaries: + summaries = [ + 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 + ] + + seen_names: set[Path] = set() + for search_base in _iter_search_dirs(base, Path(files_dir) if files_dir else None): + names_path = search_base / "names.json" + if not names_path.is_file() or names_path in seen_names: + continue + seen_names.add(names_path) + doc = _load_json(names_path) + if doc and doc.get("kind") == "names": + summaries.extend( + _names_category_summaries(doc, json_file="names.json", width=width) + ) + return summaries