diff --git a/gui/wolf_workflow_tab.py b/gui/wolf_workflow_tab.py index fd14ab2..66710b8 100644 --- a/gui/wolf_workflow_tab.py +++ b/gui/wolf_workflow_tab.py @@ -3236,7 +3236,7 @@ class WolfWorkflowTab(QWidget): msg = ( f"Manual wrap at width {width}, body \\f[{font}]." if changed - else "Line already fits (manual)." + else "Already matches Manual wrap (width + font)." ) self._wrap_status_label.setText( msg + " Inject all from Step 7 when ready." diff --git a/tests/test_wolf_codes.py b/tests/test_wolf_codes.py index 3766c78..d0d98db 100644 --- a/tests/test_wolf_codes.py +++ b/tests/test_wolf_codes.py @@ -60,6 +60,55 @@ class WolfCodesRepairTests(unittest.TestCase): ) + def test_rebuild_preserves_translated_font_sizes(self): + """Fix-wrap Manual shrink must survive repair before inject.""" + source = r"「\c[21]\f[20]富裕層\c[19]\f[18]の人手」" + text = r'\f[12]"\c[21]\f[13]Wealthy\c[19]\f[12] staff"' + fixed = wolf_codes.rebuild_text_preserving_source_codes(source, text) + self.assertTrue(fixed.startswith(r"\f[12]")) + self.assertIn(r"\f[13]", fixed) + self.assertIn(r"\c[21]", fixed) + self.assertIn(r"\c[19]", fixed) + self.assertNotIn(r"\f[20]", fixed) + self.assertNotIn(r"\f[18]", fixed) + + def test_rebuild_keeps_leading_body_font_absent_from_source(self): + source = "これは説明文です。" + text = r"\f[14]This is a description." + fixed = wolf_codes.rebuild_text_preserving_source_codes(source, text) + self.assertEqual(fixed, text) + + def test_document_has_font_size_drift_for_db(self): + doc = { + "kind": "db", + "groups": [ + { + "typeName": "噂", + "lines": [ + { + "source": r"\c[21]\f[20]娼館\c[19]\f[18]", + "text": r"\f[14]\c[21]\f[16]Brothel\c[19]\f[14]", + } + ], + } + ], + } + self.assertTrue(wolf_codes.document_has_font_size_drift(doc)) + self.assertFalse( + wolf_codes.document_has_font_size_drift( + { + "kind": "db", + "groups": [ + { + "typeName": "x", + "lines": [{"source": "あ", "text": "A"}], + } + ], + } + ) + ) + + class WolfFontScaleTests(unittest.TestCase): def test_scale_keeps_emphasis_ratio_and_leads_with_body(self): # Cafe-style mid-line emphasis: body must lead so Wolf sets line height. diff --git a/tests/test_wolf_inject_batch.py b/tests/test_wolf_inject_batch.py index 336ab5c..6c63d42 100644 --- a/tests/test_wolf_inject_batch.py +++ b/tests/test_wolf_inject_batch.py @@ -113,9 +113,11 @@ class InjectOrderTests(unittest.TestCase): def test_strings_alone_use_pristine_original_base(self): string_bases: list[str] = [] + string_drift: list[bool] = [] - def fake_strings(edited_json, base, output, **_k): + def fake_strings(edited_json, base, output, **kwargs): string_bases.append(str(base)) + string_drift.append(bool(kwargs.get("allow_code_drift"))) return mock.Mock( ok=True, returncode=0, @@ -152,6 +154,72 @@ class InjectOrderTests(unittest.TestCase): ) self.assertTrue(report.ok) self.assertEqual(string_bases, [str(map_orig)]) + self.assertEqual(string_drift, [False]) + + def test_strings_auto_allow_code_drift_for_font_size(self): + string_drift: list[bool] = [] + + def fake_strings(edited_json, base, output, **kwargs): + string_drift.append(bool(kwargs.get("allow_code_drift"))) + return mock.Mock( + ok=True, + returncode=0, + stdout="applied 1 translation(s) (0 drifted)", + stderr="", + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + translated = root / "translated" + originals = root / "originals" + data = root / "data" + translated.mkdir() + originals.mkdir() + data.mkdir() + doc = { + "kind": "db", + "groups": [ + { + "typeName": "噂", + "lines": [ + { + "source": r"\c[21]\f[20]娼館\c[19]\f[18]", + "text": r"\f[14]\c[21]\f[16]Brothel\c[19]\f[14]", + } + ], + } + ], + } + import json + + (translated / "DataBase.project.json").write_text( + json.dumps(doc, ensure_ascii=False), encoding="utf-8" + ) + proj_live = data / "DataBase.project" + proj_orig = originals / "DataBase.project" + proj_live.write_bytes(b"live") + proj_orig.write_bytes(b"orig") + (data / "DataBase.dat").write_bytes(b"live-dat") + (originals / "DataBase.dat").write_bytes(b"orig-dat") + entries = [ + { + "json": "DataBase.project.json", + "kind": "db", + "base": str(proj_live), + }, + ] + with mock.patch.object(wi.wolfdawn, "strings_inject", side_effect=fake_strings): + report = wi.inject_selected( + ["DataBase.project.json"], + manifest_entries=entries, + data_dir=data, + originals_dir=originals, + translated_dir=translated, + game_root=data.parent, + allow_code_drift=False, + ) + self.assertTrue(report.ok) + self.assertEqual(string_drift, [True]) def test_names_result_mentions_safety_skips(self): res = mock.Mock( diff --git a/tests/test_wolf_wrap_search.py b/tests/test_wolf_wrap_search.py index 6b536e6..f2ba2bf 100644 --- a/tests/test_wolf_wrap_search.py +++ b/tests/test_wolf_wrap_search.py @@ -172,6 +172,25 @@ class TestManualFontAndWrap(unittest.TestCase): self.assertFalse(skipped) self.assertEqual(short, "Short.") + def test_manual_reflows_soft_breaks_that_already_fit(self): + """Hard \\n lines can each fit width while the preview still reflows.""" + text = ( + '\\f[13]"I heard there\'s a super hot female\n' + "adventurer who came to town recently!?\n" + "Maybe I'll catch a glimpse of her at the\n" + r'\c[21]\f[14]tavern\c[19]\f[13]!"' + ) + # Per-line lengths are under 55, so the old overflow check skipped wrap. + self.assertFalse(ws.line_needs_wrap(text, 55)) + new_text, changed = ws.apply_manual_font_and_wrap(text, 55, font=13) + self.assertTrue(changed) + self.assertEqual(new_text, ws.wrap_line_text(text, 55)) + self.assertIn("female adventurer who came", new_text.replace("\n", " ")) + # Already at the target layout → no-op. + again, changed_again = ws.apply_manual_font_and_wrap(new_text, 55, font=13) + self.assertFalse(changed_again) + self.assertEqual(again, new_text) + def test_wrap_overflow_manual_in_names_category(self): with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "names.json" diff --git a/util/wolfdawn/codes.py b/util/wolfdawn/codes.py index 194c58f..5687641 100644 --- a/util/wolfdawn/codes.py +++ b/util/wolfdawn/codes.py @@ -4,6 +4,10 @@ WolfDawn ``strings-inject`` requires each ``text`` line to carry the same backslash control codes as its ``source`` (only the human-readable words may change). Models often break codes (e.g. ``\\^`` becomes ``\\ ^``). These helpers protect codes during translation and repair them before inject. + +Font sizes (``\\f[N]``) are an exception: Fix-wrap Manual mode and +``names-wrap`` intentionally shrink them. Repair keeps those sizes from +``text`` while restoring every other code from ``source``. """ from __future__ import annotations @@ -191,6 +195,25 @@ def names_doc_has_font_size_drift(doc: dict[str, Any]) -> bool: return False +def document_has_font_size_drift(doc: dict[str, Any]) -> bool: + """True when any leaf in a WolfDawn document has font-size-only ``\\f`` drift. + + Covers ``names``, ``db``, maps/events, and other extract kinds so inject can + auto-pass ``--allow-code-drift`` after Fix-wrap Manual font shrink. + """ + if not isinstance(doc, dict): + return False + if doc.get("kind") == "names": + return names_doc_has_font_size_drift(doc) + for entry in _walk_entries(doc): + if not isinstance(entry, dict): + continue + src, txt = entry.get("source"), entry.get("text") + if isinstance(src, str) and isinstance(txt, str) and font_size_codes_differ(src, txt): + return True + return False + + def _tokenize(rest: str) -> list[tuple[str, str]]: """Split *rest* into ``('lit', ...)`` and ``('code', ...)`` tokens.""" if not rest: @@ -222,8 +245,12 @@ def _fix_spurious_spaces_in_codes(source: str, text: str) -> str: def rebuild_text_preserving_source_codes(source: str, text: str) -> str: """Return *text* with *source*'s inline code tokens and translated literals. - Keeps any leading ``@