More inject fixes
This commit is contained in:
parent
e214a28d5d
commit
dc7b541f75
8 changed files with 251 additions and 23 deletions
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue