More inject fixes

This commit is contained in:
DazedAnon 2026-07-09 13:49:47 -05:00
parent e214a28d5d
commit dc7b541f75
8 changed files with 251 additions and 23 deletions

View file

@ -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."

View file

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

View file

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

View file

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

View file

@ -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 ``@<option>\\n`` window prefix from the translated line when
present; otherwise preserves the source prefix byte-for-byte.
Keeps any leading ``@<option>\\n`` window prefix from the translated line when
present; otherwise preserves the source prefix byte-for-byte.
``\\f[N]`` sizes are taken from *text* when present (manual wrap / names-wrap
shrink). Other control codes (``\\c``, ``\\^``, ) always come from *source*.
A leading body ``\\f[N]`` that *source* lacks is kept from *text*.
"""
if not isinstance(source, str) or not isinstance(text, str):
return text
@ -240,16 +267,34 @@ def rebuild_text_preserving_source_codes(source: str, text: str) -> str:
txt_parts = _tokenize(txt_rest)
src_lit = [p[1] for p in src_parts if p[0] == "lit"]
txt_lit = [p[1] for p in txt_parts if p[0] == "lit"]
txt_font_sizes = _font_sizes_in_order(txt_rest)
if not src_parts:
return out_prefix + txt_rest
# Rebuild using source code skeleton + translated literal slots.
# Font sizes prefer the translation (intentional shrink / body \\f).
rebuilt: list[str] = []
lit_i = 0
font_i = 0
src_starts_with_f = bool(
src_parts
and src_parts[0][0] == "code"
and WOLF_FONT_SIZE_RE.fullmatch(src_parts[0][1])
)
if txt_font_sizes and not src_starts_with_f:
# Manual wrap / names-wrap often prepend a body \\f[N] source never had.
rebuilt.append(rf"\f[{txt_font_sizes[0]}]")
font_i = 1
for kind, val in src_parts:
if kind == "code":
rebuilt.append(val)
if WOLF_FONT_SIZE_RE.fullmatch(val) and font_i < len(txt_font_sizes):
rebuilt.append(rf"\f[{txt_font_sizes[font_i]}]")
font_i += 1
else:
rebuilt.append(val)
else:
if lit_i < len(txt_lit):
rebuilt.append(txt_lit[lit_i])
@ -259,6 +304,19 @@ def rebuild_text_preserving_source_codes(source: str, text: str) -> str:
return out_prefix + "".join(rebuilt)
def _font_sizes_in_order(text: str) -> list[int]:
"""Return every ``\\f[N]`` size in *text* in left-to-right order."""
if not isinstance(text, str) or not text:
return []
out: list[int] = []
for match in WOLF_FONT_SIZE_RE.finditer(text):
try:
out.append(int(match.group(1)))
except (TypeError, ValueError):
continue
return out
def protect_wolf_codes(text: str) -> tuple[str, dict[str, str]]:
"""Replace inline WOLF codes with placeholders before sending text to the model."""
if not text or not isinstance(text, str):

View file

@ -105,8 +105,13 @@ def restore_live_from_originals(
return warnings
def repair_inject_json(src: Path) -> Path:
"""Auto-repair WOLF inline codes in a translated JSON before inject."""
def repair_inject_json(src: Path) -> tuple[Path, bool]:
"""Auto-repair WOLF inline codes in a translated JSON before inject.
Returns ``(path, has_font_size_drift)``. Font-size drift is detected after
repair so intentional ``\\f[N]`` shrinks from Fix-wrap survive and inject
can auto-pass ``--allow-code-drift``.
"""
data = json.loads(src.read_text(encoding="utf-8-sig"))
data, repairs = wolf_codes.repair_document(data)
if repairs:
@ -114,7 +119,7 @@ def repair_inject_json(src: Path) -> Path:
json.dumps(data, ensure_ascii=False, indent=4) + "\n",
encoding="utf-8",
)
return src
return src, wolf_codes.document_has_font_size_drift(data)
def _wolf_output_snippet(stdout: str, stderr: str, *, limit: int = 400) -> str:
@ -484,7 +489,13 @@ def inject_selected(
for json_name in strings_todo:
entry = by_json[json_name]
inject_src = repair_inject_json(translated_dir / json_name)
inject_src, font_drift = repair_inject_json(translated_dir / json_name)
strings_drift = allow_code_drift or font_drift
if font_drift and not allow_code_drift:
emit(
f" {json_name}: Fix-wrap / \\f[N] size changes — "
"passing --allow-code-drift for strings-inject"
)
emit(f"Injecting {json_name}")
# After names-inject, live binaries already hold EN name-only fields.
# Rebuilding from pristine JP originals would wipe those (rumor boards, etc.).
@ -495,7 +506,7 @@ def inject_selected(
inject_src,
data_dir,
originals_dir,
allow_code_drift=allow_code_drift,
allow_code_drift=strings_drift,
en_punct=en_punct,
base_path=live_base,
)

View file

@ -336,14 +336,20 @@ def precheck_selected(
continue
emit(f"Precheck {json_name}")
inject_src = repair_inject_json(src)
inject_src, font_drift = repair_inject_json(src)
strings_drift = allow_code_drift or font_drift
if font_drift and not allow_code_drift:
emit(
f" {json_name}: Fix-wrap / \\f[N] size changes — "
"passing --allow-code-drift for dry-run"
)
fp = _precheck_strings_file(
json_name,
entry,
inject_src,
data_dir,
originals_dir,
allow_code_drift=allow_code_drift,
allow_code_drift=strings_drift,
en_punct=en_punct,
)
if fp.error:

View file

@ -641,10 +641,14 @@ def apply_manual_font_and_wrap(
old_base: int | None = None,
only_overflow_or_fonts: bool = False,
) -> tuple[str, bool]:
"""Scale body ``\\f`` (keeping emphasis ratios), then wrap to *width*.
"""Scale body ``\\f`` (keeping emphasis ratios), then reflow to *width*.
When *only_overflow_or_fonts* is True (group wrap), skip lines that already
fit and have no ``\\f[N]`` so short UI labels are not given a forced font.
Always reflows when the wrap layout would differ from the current text
(same as the live preview). Soft ``\\n`` breaks that already "fit" per line
are collapsed and rebuilt so Manual Wrap matches what the preview shows.
When *only_overflow_or_fonts* is True (group wrap), skip short plain lines
that already match the target layout and have no ``\\f[N]``.
Returns ``(new_text, changed)``.
"""
@ -652,18 +656,31 @@ def apply_manual_font_and_wrap(
if not isinstance(text, str) or not text.strip():
return text, False
new_text = text
has_f = bool(wolf_codes.detect_font_sizes(new_text))
needs_wrap = width > 0 and line_needs_wrap(new_text, width)
if only_overflow_or_fonts and not has_f and not needs_wrap:
has_f = bool(wolf_codes.detect_font_sizes(text))
overflows = width > 0 and line_needs_wrap(text, width)
wrapped_as_is = wrap_line_text(text, width) if width > 0 else text
layout_differs = width > 0 and wrapped_as_is != text
if only_overflow_or_fonts and not has_f and not overflows and not layout_differs:
return text, False
if font is not None and int(font) > 0 and (has_f or needs_wrap or not only_overflow_or_fonts):
new_text = text
apply_font = (
font is not None
and int(font) > 0
and (has_f or overflows or layout_differs or not only_overflow_or_fonts)
)
if apply_font:
new_text, _ = wolf_codes.scale_font_sizes(
new_text, int(font), old_base=old_base
)
needs_wrap = width > 0 and line_needs_wrap(new_text, width)
if needs_wrap:
new_text = wrap_line_text(new_text, width)
if width > 0:
wrapped = wrap_line_text(new_text, width)
if wrapped != new_text:
new_text = wrapped
return new_text, new_text != text