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 = ( msg = (
f"Manual wrap at width {width}, body \\f[{font}]." f"Manual wrap at width {width}, body \\f[{font}]."
if changed if changed
else "Line already fits (manual)." else "Already matches Manual wrap (width + font)."
) )
self._wrap_status_label.setText( self._wrap_status_label.setText(
msg + " Inject all from Step 7 when ready." 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): class WolfFontScaleTests(unittest.TestCase):
def test_scale_keeps_emphasis_ratio_and_leads_with_body(self): def test_scale_keeps_emphasis_ratio_and_leads_with_body(self):
# Cafe-style mid-line emphasis: body must lead so Wolf sets line height. # 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): def test_strings_alone_use_pristine_original_base(self):
string_bases: list[str] = [] 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_bases.append(str(base))
string_drift.append(bool(kwargs.get("allow_code_drift")))
return mock.Mock( return mock.Mock(
ok=True, ok=True,
returncode=0, returncode=0,
@ -152,6 +154,72 @@ class InjectOrderTests(unittest.TestCase):
) )
self.assertTrue(report.ok) self.assertTrue(report.ok)
self.assertEqual(string_bases, [str(map_orig)]) 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): def test_names_result_mentions_safety_skips(self):
res = mock.Mock( res = mock.Mock(

View file

@ -172,6 +172,25 @@ class TestManualFontAndWrap(unittest.TestCase):
self.assertFalse(skipped) self.assertFalse(skipped)
self.assertEqual(short, "Short.") 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): def test_wrap_overflow_manual_in_names_category(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "names.json" 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 backslash control codes as its ``source`` (only the human-readable words may
change). Models often break codes (e.g. ``\\^`` becomes ``\\ ^``). These change). Models often break codes (e.g. ``\\^`` becomes ``\\ ^``). These
helpers protect codes during translation and repair them before inject. 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 from __future__ import annotations
@ -191,6 +195,25 @@ def names_doc_has_font_size_drift(doc: dict[str, Any]) -> bool:
return False 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]]: def _tokenize(rest: str) -> list[tuple[str, str]]:
"""Split *rest* into ``('lit', ...)`` and ``('code', ...)`` tokens.""" """Split *rest* into ``('lit', ...)`` and ``('code', ...)`` tokens."""
if not rest: 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: def rebuild_text_preserving_source_codes(source: str, text: str) -> str:
"""Return *text* with *source*'s inline code tokens and translated literals. """Return *text* with *source*'s inline code tokens and translated literals.
Keeps any leading ``@<option>\\n`` window prefix from the translated line when Keeps any leading ``@<option>\\n`` window prefix from the translated line when
present; otherwise preserves the source prefix byte-for-byte. 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): if not isinstance(source, str) or not isinstance(text, str):
return text return text
@ -240,16 +267,34 @@ def rebuild_text_preserving_source_codes(source: str, text: str) -> str:
txt_parts = _tokenize(txt_rest) txt_parts = _tokenize(txt_rest)
src_lit = [p[1] for p in src_parts if p[0] == "lit"] 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_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: if not src_parts:
return out_prefix + txt_rest return out_prefix + txt_rest
# Rebuild using source code skeleton + translated literal slots. # Rebuild using source code skeleton + translated literal slots.
# Font sizes prefer the translation (intentional shrink / body \\f).
rebuilt: list[str] = [] rebuilt: list[str] = []
lit_i = 0 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: for kind, val in src_parts:
if kind == "code": 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: else:
if lit_i < len(txt_lit): if lit_i < len(txt_lit):
rebuilt.append(txt_lit[lit_i]) 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) 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]]: def protect_wolf_codes(text: str) -> tuple[str, dict[str, str]]:
"""Replace inline WOLF codes with placeholders before sending text to the model.""" """Replace inline WOLF codes with placeholders before sending text to the model."""
if not text or not isinstance(text, str): if not text or not isinstance(text, str):

View file

@ -105,8 +105,13 @@ def restore_live_from_originals(
return warnings return warnings
def repair_inject_json(src: Path) -> Path: def repair_inject_json(src: Path) -> tuple[Path, bool]:
"""Auto-repair WOLF inline codes in a translated JSON before inject.""" """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 = json.loads(src.read_text(encoding="utf-8-sig"))
data, repairs = wolf_codes.repair_document(data) data, repairs = wolf_codes.repair_document(data)
if repairs: if repairs:
@ -114,7 +119,7 @@ def repair_inject_json(src: Path) -> Path:
json.dumps(data, ensure_ascii=False, indent=4) + "\n", json.dumps(data, ensure_ascii=False, indent=4) + "\n",
encoding="utf-8", 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: 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: for json_name in strings_todo:
entry = by_json[json_name] 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}") emit(f"Injecting {json_name}")
# After names-inject, live binaries already hold EN name-only fields. # After names-inject, live binaries already hold EN name-only fields.
# Rebuilding from pristine JP originals would wipe those (rumor boards, etc.). # Rebuilding from pristine JP originals would wipe those (rumor boards, etc.).
@ -495,7 +506,7 @@ def inject_selected(
inject_src, inject_src,
data_dir, data_dir,
originals_dir, originals_dir,
allow_code_drift=allow_code_drift, allow_code_drift=strings_drift,
en_punct=en_punct, en_punct=en_punct,
base_path=live_base, base_path=live_base,
) )

View file

@ -336,14 +336,20 @@ def precheck_selected(
continue continue
emit(f"Precheck {json_name}") 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( fp = _precheck_strings_file(
json_name, json_name,
entry, entry,
inject_src, inject_src,
data_dir, data_dir,
originals_dir, originals_dir,
allow_code_drift=allow_code_drift, allow_code_drift=strings_drift,
en_punct=en_punct, en_punct=en_punct,
) )
if fp.error: if fp.error:

View file

@ -641,10 +641,14 @@ def apply_manual_font_and_wrap(
old_base: int | None = None, old_base: int | None = None,
only_overflow_or_fonts: bool = False, only_overflow_or_fonts: bool = False,
) -> tuple[str, bool]: ) -> 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 Always reflows when the wrap layout would differ from the current text
fit and have no ``\\f[N]`` so short UI labels are not given a forced font. (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)``. Returns ``(new_text, changed)``.
""" """
@ -652,18 +656,31 @@ def apply_manual_font_and_wrap(
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
return text, False return text, False
new_text = text
has_f = bool(wolf_codes.detect_font_sizes(new_text)) has_f = bool(wolf_codes.detect_font_sizes(text))
needs_wrap = width > 0 and line_needs_wrap(new_text, width) overflows = width > 0 and line_needs_wrap(text, width)
if only_overflow_or_fonts and not has_f and not needs_wrap: 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 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, _ = wolf_codes.scale_font_sizes(
new_text, int(font), old_base=old_base new_text, int(font), old_base=old_base
) )
needs_wrap = width > 0 and line_needs_wrap(new_text, width)
if needs_wrap: if width > 0:
new_text = wrap_line_text(new_text, width) wrapped = wrap_line_text(new_text, width)
if wrapped != new_text:
new_text = wrapped
return new_text, new_text != text return new_text, new_text != text