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