File size: 4,442 Bytes
6967bb5
 
 
 
 
 
 
 
4f1858c
 
 
6967bb5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6de14d3
6967bb5
 
 
 
 
6de14d3
 
 
6967bb5
 
 
 
 
 
 
6de14d3
6967bb5
 
6de14d3
 
 
 
 
 
 
6967bb5
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
"""Write translations back into the project's JSON, in place and in shape.

Two invariants matter more than translation quality here:

1. A message box keeps the number of command lines it started with. Adding or
   removing entries in an event list shifts every index after it, which breaks
   conditional branches and jump targets. So a translation is re-wrapped into
   exactly the original line count, never more, never fewer.
2. A string is written exactly as the model returned it. Hy-MT2 carries
   control codes through on its own, so there is no post-hoc validation step
   holding translations back.
"""

from __future__ import annotations

import json
from pathlib import Path


def rewrap(text: str, line_count: int) -> list[str]:
    """Fit `text` into exactly `line_count` lines.

    The model is given the box's lines joined by newlines and usually returns
    the same number back, in which case its own breaks are kept. When it does
    not, the text is re-flowed greedily into equal-ish lines so the box still
    looks like a box.
    """
    if line_count <= 1:
        return [text.replace("\n", " ").strip()]

    lines = text.split("\n")
    if len(lines) == line_count:
        return lines

    words = text.replace("\n", " ").split()
    if not words:
        return [""] * line_count

    # Aim for even line lengths rather than filling the first line to the brim.
    target = max(1, len(" ".join(words)) // line_count)
    wrapped: list[str] = []
    current: list[str] = []
    for word in words:
        candidate = " ".join(current + [word])
        if current and len(candidate) > target and len(wrapped) < line_count - 1:
            wrapped.append(" ".join(current))
            current = [word]
        else:
            current.append(word)
    wrapped.append(" ".join(current))

    while len(wrapped) < line_count:
        wrapped.append("")
    if len(wrapped) > line_count:
        # Never drop text: fold the overflow into the last kept line.
        head = wrapped[: line_count - 1]
        head.append(" ".join(wrapped[line_count - 1 :]))
        wrapped = head
    return wrapped


def _assign(root, path: list, value: str) -> bool:
    """Follow a path of keys/indices and set the final element."""
    node = root
    for step in path[:-1]:
        try:
            node = node[step]
        except (KeyError, IndexError, TypeError):
            return False
    last = path[-1]
    try:
        node[last] = value
    except (KeyError, IndexError, TypeError):
        return False
    return True


def apply(
    data_dir: Path,
    units: list,
    translations: dict[str, str],
) -> tuple[int, int]:
    """Write every validated translation into the JSON files under `data_dir`.

    Groups writes by file so each file is parsed and serialised once, however
    many strings land in it. Returns (written_slots, skipped_slots).
    """
    slots_by_file: dict[str, list[tuple[list[list], str, bool]]] = {}
    for unit in units:
        translated = translations.get(unit.source)
        if translated is None:
            continue
        for slot in unit.slots:
            slots_by_file.setdefault(slot.file, []).append(
                (slot.paths, translated, slot.raw)
            )

    written = skipped = 0
    for file_name, entries in slots_by_file.items():
        json_path = data_dir / file_name
        try:
            data = json.loads(json_path.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError):
            skipped += sum(len(paths) for paths, _, _ in entries)
            continue

        for paths, translated, raw in entries:
            # A raw slot is one whole field (an item description, a menu term),
            # so it keeps whatever line breaks the model produced. Everything
            # else is a message box and has to come back with the line count it
            # went in with.
            lines = [translated] if raw else rewrap(translated, len(paths))
            for line, path in zip(lines, paths):
                if _assign(data, path, line):
                    written += 1
                else:
                    skipped += 1

        # ensure_ascii=False keeps Turkish characters readable in the file, and
        # matches how RPG Maker itself writes these (UTF-8, not escaped).
        json_path.write_text(
            json.dumps(data, ensure_ascii=False), encoding="utf-8"
        )

    return written, skipped