Cnass commited on
Commit
71edbba
·
verified ·
1 Parent(s): 63b2433

Upload 7 files

Browse files
app/renpy/extract.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Index the translatable text in an uploaded Ren'Py project.
2
+
3
+ Accepts either shape people actually have: a zip of nothing but scripts, or a
4
+ whole `game/` folder. Both are walked the same way and everything that is not
5
+ a script is ignored, so uploading the folder costs no more time than uploading
6
+ the scripts alone.
7
+
8
+ Two directories are always skipped:
9
+
10
+ tl/ - somebody else's finished translation. Re-translating a translation
11
+ is the classic way to end up with a game in two languages at once.
12
+ renpy/ - the engine's own source, shipped inside some distributions. Its
13
+ strings belong to Ren'Py, not to the game.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+
21
+ from . import rpyc, script
22
+
23
+ SCRIPT_SUFFIXES = (".rpy", ".rpyc")
24
+
25
+ # Directory names that never hold the game's own translatable script.
26
+ _SKIP_DIRS = {"tl", "renpy", "cache", "saves", "lib", "__pycache__"}
27
+
28
+
29
+ @dataclass
30
+ class Slot:
31
+ """One place a translation gets written back.
32
+
33
+ `start`/`end` bracket the characters between the quotes in the source
34
+ file. A slot from a compiled .rpyc has no writable span - the bytes cannot
35
+ be edited safely - so it is marked unwritable and reaches the game through
36
+ a generated translation file instead.
37
+ """
38
+
39
+ file: str
40
+ start: int = -1
41
+ end: int = -1
42
+ quote: str = '"'
43
+ writable: bool = True
44
+ kind: str = "dialogue"
45
+
46
+
47
+ @dataclass
48
+ class Unit:
49
+ """One source string and every place it occurs."""
50
+
51
+ source: str
52
+ slots: list[Slot] = field(default_factory=list)
53
+
54
+ @property
55
+ def occurrences(self) -> int:
56
+ return len(self.slots)
57
+
58
+
59
+ def is_script(path: Path) -> bool:
60
+ return path.suffix.lower() in SCRIPT_SUFFIXES
61
+
62
+
63
+ def is_skipped(relative: Path) -> bool:
64
+ """True when a path sits inside a directory we never read."""
65
+ return any(part.lower() in _SKIP_DIRS for part in relative.parts[:-1])
66
+
67
+
68
+ def iter_scripts(root: Path):
69
+ """Yield every script under `root`, in a stable order."""
70
+ for path in sorted(root.rglob("*")):
71
+ if not path.is_file() or not is_script(path):
72
+ continue
73
+ relative = path.relative_to(root)
74
+ if is_skipped(relative):
75
+ continue
76
+ yield path, relative
77
+
78
+
79
+ def has_scripts(root: Path) -> bool:
80
+ for _ in iter_scripts(root):
81
+ return True
82
+ return False
83
+
84
+
85
+ def extract(root: Path) -> tuple[list[Unit], list[str]]:
86
+ """Read every script under `root`. Returns (units, unreadable_files)."""
87
+ by_source: dict[str, Unit] = {}
88
+ broken: list[str] = []
89
+
90
+ def sink(source: str, slot: Slot) -> None:
91
+ unit = by_source.get(source)
92
+ if unit is None:
93
+ unit = Unit(source=source)
94
+ by_source[source] = unit
95
+ unit.slots.append(slot)
96
+
97
+ for path, relative in iter_scripts(root):
98
+ name = relative.as_posix()
99
+ if path.suffix.lower() == ".rpy":
100
+ try:
101
+ source = path.read_text(encoding="utf-8")
102
+ except (OSError, UnicodeDecodeError):
103
+ try:
104
+ source = path.read_text(encoding="utf-8", errors="replace")
105
+ except OSError:
106
+ broken.append(name)
107
+ continue
108
+ try:
109
+ for item in script.find_translatable(source):
110
+ sink(
111
+ item.text,
112
+ Slot(
113
+ file=name,
114
+ start=item.start,
115
+ end=item.end,
116
+ quote=item.quote,
117
+ writable=True,
118
+ kind=item.kind,
119
+ ),
120
+ )
121
+ except Exception: # noqa: BLE001 - one odd script must not stop the scan
122
+ broken.append(name)
123
+ continue
124
+
125
+ # Compiled script: readable, not writable.
126
+ try:
127
+ strings = rpyc.extract_strings(path)
128
+ except Exception: # noqa: BLE001 - unknown or newer .rpyc layout
129
+ broken.append(name)
130
+ continue
131
+ for text, kind in strings:
132
+ sink(text, Slot(file=name, writable=False, kind=kind))
133
+
134
+ return list(by_source.values()), broken
app/renpy/inject.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Write translations back into .rpy scripts.
2
+
3
+ A translation replaces exactly the characters between one pair of quotes and
4
+ nothing else. Spans are applied from the end of the file backwards so that
5
+ every offset still refers to the text it was measured against - rewriting
6
+ front-to-back would shift each following span by the length the last one
7
+ changed.
8
+
9
+ The script around the strings is never re-indented, re-wrapped or re-parsed, so
10
+ a file whose strings all translate to themselves comes back byte-identical.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+
17
+ from .text import escape
18
+
19
+
20
+ def apply_to_source(source: str, spans: list[tuple[int, int, str, str]]) -> str:
21
+ """Replace each (start, end, quote, translation) span in `source`."""
22
+ ordered = sorted(spans, key=lambda item: item[0], reverse=True)
23
+ out = source
24
+ last_start = len(source) + 1
25
+ for start, end, quote, translated in ordered:
26
+ if end > last_start:
27
+ # Overlapping spans would corrupt the file. The scanner cannot
28
+ # produce them, but a stale units.json against an edited source
29
+ # could, so refuse rather than write nonsense.
30
+ raise ValueError(f"overlapping span at {start}:{end}")
31
+ out = out[:start] + escape(translated, quote) + out[end:]
32
+ last_start = start
33
+ return out
34
+
35
+
36
+ def apply(project_dir: Path, units: list, translations: dict[str, str]) -> tuple[int, int]:
37
+ """Write every translated unit into the scripts under `project_dir`.
38
+
39
+ Groups by file so each script is read and written once. Returns
40
+ (written_spans, skipped_spans).
41
+ """
42
+ by_file: dict[str, list[tuple[int, int, str, str]]] = {}
43
+ for unit in units:
44
+ translated = translations.get(unit.source)
45
+ if translated is None:
46
+ continue
47
+ for slot in unit.slots:
48
+ if not slot.writable:
49
+ continue
50
+ by_file.setdefault(slot.file, []).append(
51
+ (slot.start, slot.end, slot.quote, translated)
52
+ )
53
+
54
+ written = skipped = 0
55
+ for name, spans in by_file.items():
56
+ path = project_dir / name
57
+ try:
58
+ source = path.read_text(encoding="utf-8")
59
+ except OSError:
60
+ skipped += len(spans)
61
+ continue
62
+ try:
63
+ updated = apply_to_source(source, spans)
64
+ except ValueError:
65
+ skipped += len(spans)
66
+ continue
67
+ path.write_text(updated, encoding="utf-8")
68
+ written += len(spans)
69
+
70
+ return written, skipped
app/renpy/rpyc.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Read the dialogue out of a compiled .rpyc script.
2
+
3
+ A .rpyc holds Ren'Py's parsed syntax tree, pickled and deflated. That is
4
+ enough to *read* every line of dialogue, which is what makes it possible to
5
+ translate a game that ships without its .rpy sources. It is not enough to write
6
+ one back: the tree also encodes line numbers and file offsets that the engine
7
+ checks, so translated text reaches these games through a generated translation
8
+ file instead of an edit.
9
+
10
+ Unpickling a file from a stranger normally means running their code - the
11
+ pickle format can name any importable object and call it. This reader never
12
+ imports anything the file asks for. Every class name it encounters is answered
13
+ with an inert stand-in that only records the attributes assigned to it, so a
14
+ malicious .rpyc has nothing to reach for.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import io
20
+ import pickle
21
+ import struct
22
+ import zlib
23
+ from pathlib import Path
24
+
25
+ _MAGIC = b"RENPY RPC2"
26
+
27
+ # Node types whose text the player reads.
28
+ _SAY_NODES = {"Say"}
29
+ _MENU_NODES = {"Menu"}
30
+ # Existing translations inside the compiled file - skipping them keeps a
31
+ # part-translated game from being translated a second time.
32
+ _TRANSLATE_NODES = {"Translate", "TranslateString", "TranslateBlock", "TranslatePython"}
33
+
34
+ _MAX_NODES = 2_000_000
35
+
36
+
37
+ class _Stand:
38
+ """Inert stand-in for a class named inside the pickle."""
39
+
40
+ __slots__ = ("__dict__",)
41
+
42
+ def __init__(self, *args, **kwargs) -> None:
43
+ # Ren'Py reconstructs most nodes through __setstate__, but a few are
44
+ # built with positional arguments; keeping them lets the walker still
45
+ # see any strings they carry.
46
+ if args:
47
+ self.__dict__["_args"] = args
48
+
49
+ def __setstate__(self, state) -> None:
50
+ if isinstance(state, dict):
51
+ self.__dict__.update(state)
52
+ else:
53
+ self.__dict__["_state"] = state
54
+
55
+ # Pickle sometimes appends to or updates a reconstructed object.
56
+ def append(self, item) -> None:
57
+ self.__dict__.setdefault("_items", []).append(item)
58
+
59
+ def extend(self, items) -> None:
60
+ self.__dict__.setdefault("_items", []).extend(items)
61
+
62
+ def __setitem__(self, key, value) -> None:
63
+ self.__dict__.setdefault("_map", {})[key] = value
64
+
65
+
66
+ _stand_cache: dict[tuple[str, str], type] = {}
67
+
68
+
69
+ def _stand_for(module: str, name: str) -> type:
70
+ key = (module, name)
71
+ cached = _stand_cache.get(key)
72
+ if cached is None:
73
+ cached = type(name, (_Stand,), {"__module__": module})
74
+ _stand_cache[key] = cached
75
+ return cached
76
+
77
+
78
+ class _SafeUnpickler(pickle.Unpickler):
79
+ """Unpickler that resolves every name to an inert stand-in."""
80
+
81
+ def find_class(self, module: str, name: str):
82
+ return _stand_for(module, name)
83
+
84
+ def persistent_load(self, pid):
85
+ return None
86
+
87
+
88
+ def _slots(blob: bytes) -> dict[int, bytes]:
89
+ """Split an RPC2 container into its numbered slots."""
90
+ slots: dict[int, bytes] = {}
91
+ cursor = len(_MAGIC)
92
+ while cursor + 12 <= len(blob):
93
+ number, start, length = struct.unpack("<III", blob[cursor : cursor + 12])
94
+ cursor += 12
95
+ if number == 0:
96
+ break
97
+ if start + length > len(blob):
98
+ raise ValueError("slot runs past end of file")
99
+ slots[number] = blob[start : start + length]
100
+ return slots
101
+
102
+
103
+ def _load_tree(path: Path):
104
+ blob = path.read_bytes()
105
+ if blob.startswith(_MAGIC):
106
+ slots = _slots(blob)
107
+ # Slot 1 carries the syntax tree in every version that uses this
108
+ # container; the others hold source and metadata.
109
+ payload = slots.get(1)
110
+ if payload is None:
111
+ raise ValueError("no syntax-tree slot in .rpyc")
112
+ data = zlib.decompress(payload)
113
+ else:
114
+ # Ren'Py 6.17 and older: the whole file is one deflated pickle.
115
+ data = zlib.decompress(blob)
116
+ return _SafeUnpickler(io.BytesIO(data)).load()
117
+
118
+
119
+ def _walk(node, sink, seen: set[int], budget: list[int]) -> None:
120
+ """Collect dialogue from the object graph, depth first."""
121
+ if budget[0] <= 0:
122
+ return
123
+ budget[0] -= 1
124
+
125
+ if isinstance(node, (str, bytes, int, float, bool, type(None))):
126
+ return
127
+
128
+ marker = id(node)
129
+ if marker in seen:
130
+ return
131
+ seen.add(marker)
132
+
133
+ if isinstance(node, dict):
134
+ for value in node.values():
135
+ _walk(value, sink, seen, budget)
136
+ return
137
+ if isinstance(node, (list, tuple, set, frozenset)):
138
+ for value in node:
139
+ _walk(value, sink, seen, budget)
140
+ return
141
+
142
+ fields = getattr(node, "__dict__", None)
143
+ if not isinstance(fields, dict):
144
+ return
145
+
146
+ kind = type(node).__name__
147
+
148
+ if kind in _TRANSLATE_NODES:
149
+ # The body of a translate node *is* an existing translation. Reading it
150
+ # would feed a finished translation back through the model, so the
151
+ # whole subtree is left unread - the untranslated original is still in
152
+ # the script as an ordinary Say node.
153
+ return
154
+
155
+ if kind in _SAY_NODES:
156
+ what = fields.get("what")
157
+ if isinstance(what, str):
158
+ sink(what, "dialogue")
159
+
160
+ elif kind in _MENU_NODES:
161
+ items = fields.get("items")
162
+ if isinstance(items, (list, tuple)):
163
+ for item in items:
164
+ if isinstance(item, (list, tuple)) and item and isinstance(item[0], str):
165
+ sink(item[0], "menu")
166
+
167
+ for value in fields.values():
168
+ _walk(value, sink, seen, budget)
169
+
170
+
171
+ def extract_strings(path: Path) -> list[tuple[str, str]]:
172
+ """Return (text, kind) for every readable line of dialogue in a .rpyc."""
173
+ tree = _load_tree(path)
174
+ out: list[tuple[str, str]] = []
175
+ seen_text: set[str] = set()
176
+
177
+ def sink(text: str, kind: str) -> None:
178
+ if not text.strip() or text in seen_text:
179
+ return
180
+ from .text import is_translatable
181
+
182
+ if not is_translatable(text):
183
+ return
184
+ seen_text.add(text)
185
+ out.append((text, kind))
186
+
187
+ _walk(tree, sink, set(), [_MAX_NODES])
188
+ return out
app/renpy/script.py ADDED
@@ -0,0 +1,396 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Find the translatable strings in a .rpy script, with exact source offsets.
2
+
3
+ Ren'Py scripts mix dialogue with executable Python, asset filenames, style
4
+ definitions and screen layout in one file, and the difference between them is
5
+ the difference between a translated game and a game that will not start. So
6
+ this parser is built to be *certain* about what it touches:
7
+
8
+ * Every string literal in the file is located in one pass that understands
9
+ comments, escapes and triple quotes. Nothing downstream ever re-scans raw
10
+ text, so a `#` inside dialogue or a quote inside a comment cannot shift an
11
+ offset.
12
+ * Statements are classified from a "code view" of each line - the same line
13
+ with the inside of every string blanked out - so a keyword mentioned inside
14
+ dialogue is never mistaken for the statement's own keyword.
15
+ * Anything not positively recognised as dialogue, a menu choice or screen text
16
+ is left alone. Over-skipping costs a line of translation; under-skipping
17
+ corrupts a script.
18
+
19
+ The offsets are what makes writing back safe: a translation replaces exactly
20
+ the characters between one pair of quotes, so no amount of re-flowing by the
21
+ model can disturb the code around it.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import re
27
+ from dataclasses import dataclass
28
+
29
+ from .text import EscapeError, is_translatable, unescape
30
+
31
+ # Statements whose entire indented body holds no dialogue. Skipping the block
32
+ # outright is safer than trusting per-line rules inside it: `style` bodies name
33
+ # fonts, `image` bodies name files, `python` bodies are code, and `translate`
34
+ # bodies are somebody else's finished translation.
35
+ _BLOCK_SKIP = {
36
+ "python",
37
+ "init",
38
+ "style",
39
+ "transform",
40
+ "image",
41
+ "layeredimage",
42
+ "testcase",
43
+ "translate",
44
+ "camera",
45
+ }
46
+
47
+ # Single statements that carry no dialogue. Their arguments are label names,
48
+ # asset paths, transitions or expressions.
49
+ _LINE_SKIP = {
50
+ "define",
51
+ "default",
52
+ "jump",
53
+ "call",
54
+ "scene",
55
+ "show",
56
+ "hide",
57
+ "with",
58
+ "play",
59
+ "stop",
60
+ "queue",
61
+ "voice",
62
+ "pause",
63
+ "return",
64
+ "pass",
65
+ "window",
66
+ "nvl",
67
+ "use",
68
+ "add",
69
+ "on",
70
+ "key",
71
+ "timer",
72
+ "imagemap",
73
+ "hotspot",
74
+ "hotbar",
75
+ "drag",
76
+ "draggroup",
77
+ "mousearea",
78
+ "imagebutton",
79
+ "input",
80
+ "bar",
81
+ "vbar",
82
+ "viewport",
83
+ "side",
84
+ "grid",
85
+ "vpgrid",
86
+ "fixed",
87
+ "hbox",
88
+ "vbox",
89
+ "frame",
90
+ "null",
91
+ "has",
92
+ "at",
93
+ "audio",
94
+ "sound",
95
+ "music",
96
+ "movie",
97
+ "transclude",
98
+ "default_focus",
99
+ }
100
+
101
+ # Screen statements whose *first* string is shown to the player. The first, not
102
+ # the last: `textbutton "OK" action Return()` and
103
+ # `text "Hi" style "big"` both put the visible text first and configuration
104
+ # after it.
105
+ _UI_KEYWORDS = {"text", "textbutton", "tooltip", "caption", "alt", "label"}
106
+
107
+ # Prefix words allowed in front of a say statement's dialogue, beyond the
108
+ # speaker's own name and image attributes.
109
+ _SAY_PREFIX_WORDS = {"extend", "nvl"}
110
+
111
+ _IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z_0-9.]*$")
112
+ _FIRST_WORD_RE = re.compile(r"^([A-Za-z_][A-Za-z_0-9]*)")
113
+
114
+
115
+ @dataclass
116
+ class Literal:
117
+ """One string literal, located exactly in the file."""
118
+
119
+ start: int # offset of the first character inside the quotes
120
+ end: int # offset just past the last character inside the quotes
121
+ quote: str # the quote character, for re-escaping
122
+ triple: bool
123
+ line: int # 0-based index of the line the literal opens on
124
+ depth: int # bracket nesting at the opening quote
125
+
126
+
127
+ @dataclass
128
+ class Found:
129
+ """A translatable string plus where to write its translation back."""
130
+
131
+ text: str
132
+ start: int
133
+ end: int
134
+ quote: str
135
+ kind: str # dialogue | menu | ui
136
+ line: int
137
+
138
+
139
+ def scan_literals(source: str) -> tuple[list[Literal], list[str]]:
140
+ """Locate every string literal, and build the blanked-out code view.
141
+
142
+ Returns the literals and one "code line" per source line: the same text
143
+ with string contents and comments replaced by spaces, so positions still
144
+ line up with the original but no statement rule can be fooled by them.
145
+ """
146
+ literals: list[Literal] = []
147
+ code = list(source)
148
+ # A byte-order mark is not whitespace, so it would glue itself to the
149
+ # first statement's keyword and make line 1 unrecognisable. Blanking it in
150
+ # the code view fixes the classification while leaving the real offsets -
151
+ # and the mark itself - untouched.
152
+ if code and code[0] == "":
153
+ code[0] = " "
154
+ line_index = 0
155
+ depth = 0
156
+ index = 0
157
+ length = len(source)
158
+
159
+ while index < length:
160
+ char = source[index]
161
+
162
+ if char == "\n":
163
+ line_index += 1
164
+ index += 1
165
+ continue
166
+
167
+ if char == "#":
168
+ # Comment: blank to end of line. Ren'Py has no block comments.
169
+ while index < length and source[index] != "\n":
170
+ code[index] = " "
171
+ index += 1
172
+ continue
173
+
174
+ if char in "([{":
175
+ depth += 1
176
+ index += 1
177
+ continue
178
+ if char in ")]}":
179
+ depth = max(0, depth - 1)
180
+ index += 1
181
+ continue
182
+
183
+ if char not in "\"'":
184
+ index += 1
185
+ continue
186
+
187
+ # A string opens here. Triple quotes first, so `"""` is never read as
188
+ # an empty string followed by a stray quote.
189
+ quote = char
190
+ triple = source.startswith(quote * 3, index)
191
+ marker = quote * 3 if triple else quote
192
+ open_line = line_index
193
+ inner_start = index + len(marker)
194
+
195
+ cursor = inner_start
196
+ closed = False
197
+ while cursor < length:
198
+ current = source[cursor]
199
+ if current == "\\":
200
+ # Skip the escaped character so an escaped quote cannot close
201
+ # the string. Newlines inside the escape still count for
202
+ # line numbering.
203
+ if cursor + 1 < length:
204
+ if source[cursor + 1] == "\n":
205
+ line_index += 1
206
+ cursor += 2
207
+ continue
208
+ cursor += 1
209
+ continue
210
+ if current == "\n":
211
+ line_index += 1
212
+ if not triple:
213
+ # An unterminated single-quoted string: treat the line end
214
+ # as the end rather than swallowing the rest of the file.
215
+ break
216
+ cursor += 1
217
+ continue
218
+ if source.startswith(marker, cursor):
219
+ closed = True
220
+ break
221
+ cursor += 1
222
+
223
+ inner_end = cursor
224
+ for position in range(inner_start, min(inner_end, length)):
225
+ if code[position] != "\n":
226
+ code[position] = " "
227
+
228
+ if closed:
229
+ literals.append(
230
+ Literal(
231
+ start=inner_start,
232
+ end=inner_end,
233
+ quote=quote,
234
+ triple=triple,
235
+ line=open_line,
236
+ depth=depth,
237
+ )
238
+ )
239
+ index = inner_end + len(marker)
240
+ else:
241
+ index = inner_end
242
+
243
+ code_lines = "".join(code).split("\n")
244
+ return literals, code_lines
245
+
246
+
247
+ def _prefix_is_speaker(prefix: str) -> bool:
248
+ """True when the text before a say statement's dialogue is just a speaker.
249
+
250
+ A say statement's prefix is the character variable plus any image
251
+ attributes - all bare words. Anything with an operator or a call in it is
252
+ some other statement that happens to contain a string.
253
+ """
254
+ if not prefix:
255
+ return True
256
+ for word in prefix.split():
257
+ if word in _SAY_PREFIX_WORDS:
258
+ continue
259
+ if not _IDENT_RE.match(word):
260
+ return False
261
+ return True
262
+
263
+
264
+ def _line_bounds(source: str) -> list[int]:
265
+ """Start offset of every line, so a line index can address the file."""
266
+ starts = [0]
267
+ for index, char in enumerate(source):
268
+ if char == "\n":
269
+ starts.append(index + 1)
270
+ return starts
271
+
272
+
273
+ def find_translatable(source: str) -> list[Found]:
274
+ """Return every translatable string in one .rpy file."""
275
+ literals, code_lines = scan_literals(source)
276
+ line_starts = _line_bounds(source)
277
+
278
+ by_line: dict[int, list[Literal]] = {}
279
+ for literal in literals:
280
+ by_line.setdefault(literal.line, []).append(literal)
281
+
282
+ found: list[Found] = []
283
+ skip_indent: int | None = None
284
+ menu_indent: int | None = None
285
+ continuation = False
286
+ depth = 0
287
+
288
+ for index, code_line in enumerate(code_lines):
289
+ stripped = code_line.strip()
290
+ line_depth_before = depth
291
+ depth += sum(code_line.count(c) for c in "([{")
292
+ depth -= sum(code_line.count(c) for c in ")]}")
293
+ depth = max(0, depth)
294
+
295
+ was_continuation = continuation
296
+ # A statement continues onto the next line while brackets are open or
297
+ # the line ends in a backslash.
298
+ continuation = depth > 0 or code_line.rstrip().endswith("\\")
299
+
300
+ if not stripped:
301
+ continue
302
+
303
+ indent = len(code_line) - len(code_line.lstrip())
304
+
305
+ # A continuation line is part of the statement above it, not a
306
+ # statement of its own - classifying it would read `"Eileen")` from a
307
+ # multi-line Character() call as narration.
308
+ if was_continuation or line_depth_before > 0:
309
+ continue
310
+
311
+ if skip_indent is not None:
312
+ if indent > skip_indent:
313
+ continue
314
+ skip_indent = None
315
+
316
+ if menu_indent is not None and indent <= menu_indent:
317
+ menu_indent = None
318
+
319
+ if stripped.startswith("$"):
320
+ continue
321
+
322
+ match = _FIRST_WORD_RE.match(stripped)
323
+ first = match.group(1) if match else ""
324
+
325
+ if first in _BLOCK_SKIP:
326
+ skip_indent = indent
327
+ continue
328
+
329
+ if first == "menu":
330
+ menu_indent = indent
331
+ continue
332
+
333
+ if first == "screen":
334
+ # Screens hold player-visible text, so only the header is skipped.
335
+ continue
336
+
337
+ candidates = [lit for lit in by_line.get(index, []) if lit.depth == 0]
338
+ if not candidates:
339
+ continue
340
+
341
+ # `label` is two different statements: `label start:` is a script
342
+ # label, `label "Text"` inside a screen is a caption.
343
+ if first == "label" and not stripped[len("label"):].lstrip().startswith(
344
+ ("'", '"')
345
+ ):
346
+ continue
347
+
348
+ if first in _UI_KEYWORDS:
349
+ chosen = candidates[0]
350
+ kind = "ui"
351
+ elif first in _LINE_SKIP:
352
+ continue
353
+ else:
354
+ # Everything before the opening quote of the first string. The
355
+ # quote itself is excluded - it is not part of the speaker.
356
+ opening = candidates[0].start - (3 if candidates[0].triple else 1)
357
+ prefix = code_line[: opening - line_starts[index]].strip()
358
+ # An assignment that escaped the `$` and define checks is not
359
+ # dialogue, whatever it looks like.
360
+ if "=" in prefix:
361
+ continue
362
+ if not _prefix_is_speaker(prefix):
363
+ continue
364
+ # With two strings and no prefix the line is `"Speaker" "text"`,
365
+ # so the dialogue is the last one either way.
366
+ chosen = candidates[-1]
367
+ kind = "menu" if menu_indent is not None and not prefix else "dialogue"
368
+
369
+ raw = source[chosen.start : chosen.end]
370
+ try:
371
+ value = unescape(raw)
372
+ except EscapeError:
373
+ # An escape this tool will not round-trip. Leaving the line in the
374
+ # source untranslated is always recoverable; writing back a guess
375
+ # is not.
376
+ continue
377
+ if not value.strip() or not is_translatable(value):
378
+ continue
379
+ if chosen.triple:
380
+ # Triple-quoted bodies are found and skipped rather than
381
+ # translated: their content is re-indented by Ren'Py and a
382
+ # rewritten body would change the layout it depends on.
383
+ continue
384
+
385
+ found.append(
386
+ Found(
387
+ text=value,
388
+ start=chosen.start,
389
+ end=chosen.end,
390
+ quote=chosen.quote,
391
+ kind=kind,
392
+ line=index + 1,
393
+ )
394
+ )
395
+
396
+ return found
app/renpy/store.py ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """On-disk state for uploaded Ren'Py projects.
2
+
3
+ Kept apart from the RPG Maker projects in its own folder so neither engine can
4
+ ever read the other's metadata, and so listing one never has to filter out the
5
+ other.
6
+
7
+ Only scripts are ever unpacked. People upload whole `game/` folders that are
8
+ mostly art and audio, and none of it is read, so writing gigabytes to disk to
9
+ translate a few megabytes of text would be pure cost.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import shutil
16
+ import time
17
+ import uuid
18
+ import zipfile
19
+ from dataclasses import asdict
20
+ from pathlib import Path
21
+
22
+ from .. import config
23
+ from .extract import SCRIPT_SUFFIXES, Slot, Unit, extract, has_scripts, is_skipped
24
+
25
+ STATUS_READY = "ready"
26
+ STATUS_TRANSLATING = "translating"
27
+ STATUS_DONE = "done"
28
+ STATUS_FAILED = "failed"
29
+ STATUS_CANCELLED = "cancelled"
30
+
31
+ KIND = "renpy"
32
+
33
+
34
+ def root() -> Path:
35
+ path = Path(config.PROJECT_DIR) / "renpy"
36
+ path.mkdir(parents=True, exist_ok=True)
37
+ return path
38
+
39
+
40
+ def project_dir(project_id: str) -> Path:
41
+ return root() / project_id
42
+
43
+
44
+ def _meta_path(project_id: str) -> Path:
45
+ return project_dir(project_id) / "meta.json"
46
+
47
+
48
+ def load_meta(project_id: str) -> dict | None:
49
+ try:
50
+ return json.loads(_meta_path(project_id).read_text(encoding="utf-8"))
51
+ except (OSError, json.JSONDecodeError):
52
+ return None
53
+
54
+
55
+ def save_meta(project_id: str, meta: dict) -> None:
56
+ meta["updated_at"] = time.time()
57
+ path = _meta_path(project_id)
58
+ temp = path.with_suffix(".json.tmp")
59
+ temp.write_text(json.dumps(meta, ensure_ascii=False), encoding="utf-8")
60
+ temp.replace(path)
61
+
62
+
63
+ def load_units(project_id: str) -> list[Unit]:
64
+ raw = json.loads((project_dir(project_id) / "units.json").read_text(encoding="utf-8"))
65
+ return [
66
+ Unit(
67
+ source=item["source"],
68
+ slots=[Slot(**slot) for slot in item["slots"]],
69
+ )
70
+ for item in raw
71
+ ]
72
+
73
+
74
+ def save_units(project_id: str, units: list[Unit]) -> None:
75
+ (project_dir(project_id) / "units.json").write_text(
76
+ json.dumps([asdict(u) for u in units], ensure_ascii=False), encoding="utf-8"
77
+ )
78
+
79
+
80
+ def load_translations(project_id: str) -> dict[str, str]:
81
+ path = project_dir(project_id) / "translations.json"
82
+ try:
83
+ return json.loads(path.read_text(encoding="utf-8"))
84
+ except (OSError, json.JSONDecodeError):
85
+ return {}
86
+
87
+
88
+ def save_translations(project_id: str, translations: dict[str, str]) -> None:
89
+ path = project_dir(project_id) / "translations.json"
90
+ temp = path.with_suffix(".json.tmp")
91
+ temp.write_text(json.dumps(translations, ensure_ascii=False), encoding="utf-8")
92
+ temp.replace(path)
93
+
94
+
95
+ def source_dir(project_id: str) -> Path:
96
+ return project_dir(project_id) / "source"
97
+
98
+
99
+ class UploadError(ValueError):
100
+ """Raised for uploads that hold no usable Ren'Py script."""
101
+
102
+
103
+ def _unpack_scripts(archive: Path, target: Path) -> int:
104
+ """Extract only the scripts from `archive`. Returns how many were written."""
105
+ written = 0
106
+ with zipfile.ZipFile(archive) as zf:
107
+ for member in zf.infolist():
108
+ if member.is_dir():
109
+ continue
110
+ name = Path(member.filename)
111
+ if name.suffix.lower() not in SCRIPT_SUFFIXES:
112
+ continue
113
+ if is_skipped(name):
114
+ continue
115
+
116
+ destination = (target / member.filename).resolve()
117
+ # Reject absolute paths and ../ escapes rather than trusting the
118
+ # archive to stay inside its own folder.
119
+ if not str(destination).startswith(str(target.resolve())):
120
+ raise UploadError(f"Unsafe path in zip: {member.filename}")
121
+
122
+ destination.parent.mkdir(parents=True, exist_ok=True)
123
+ with zf.open(member) as incoming, open(destination, "wb") as outgoing:
124
+ shutil.copyfileobj(incoming, outgoing)
125
+ written += 1
126
+ return written
127
+
128
+
129
+ def create(archive: Path, name: str) -> dict:
130
+ """Unpack an upload's scripts and index the text in them."""
131
+ project_id = uuid.uuid4().hex[:12]
132
+ base = project_dir(project_id)
133
+ source = source_dir(project_id)
134
+ source.mkdir(parents=True, exist_ok=True)
135
+
136
+ try:
137
+ count = _unpack_scripts(archive, source)
138
+ except zipfile.BadZipFile as exc:
139
+ shutil.rmtree(base, ignore_errors=True)
140
+ raise UploadError("Not a valid .zip file") from exc
141
+ except UploadError:
142
+ shutil.rmtree(base, ignore_errors=True)
143
+ raise
144
+
145
+ if not count or not has_scripts(source):
146
+ shutil.rmtree(base, ignore_errors=True)
147
+ raise UploadError(
148
+ "No Ren'Py scripts found in the zip. Expected .rpy or .rpyc files - "
149
+ "either on their own or inside a game folder."
150
+ )
151
+
152
+ units, broken = extract(source)
153
+
154
+ files: dict[str, dict] = {}
155
+ compiled = 0
156
+ for unit in units:
157
+ for slot in unit.slots:
158
+ entry = files.setdefault(slot.file, {"total": 0, "done": 0})
159
+ entry["total"] += 1
160
+ if not slot.writable:
161
+ compiled += 1
162
+
163
+ meta = {
164
+ "id": project_id,
165
+ "kind": KIND,
166
+ "name": name,
167
+ "engine": "renpy",
168
+ "status": STATUS_READY,
169
+ "created_at": time.time(),
170
+ "updated_at": time.time(),
171
+ "target_lang": None,
172
+ "total_units": len(units),
173
+ "total_slots": sum(f["total"] for f in files.values()),
174
+ "translated_units": 0,
175
+ "failed_units": 0,
176
+ "script_files": count,
177
+ # Strings that came out of compiled scripts. They cannot be written
178
+ # back into the .rpyc, so they travel in a generated translation file.
179
+ "compiled_slots": compiled,
180
+ "unreadable_files": broken,
181
+ "files": files,
182
+ "error": None,
183
+ }
184
+ save_units(project_id, units)
185
+ save_translations(project_id, {})
186
+ save_meta(project_id, meta)
187
+ return meta
188
+
189
+
190
+ def list_projects() -> list[dict]:
191
+ metas = []
192
+ for child in root().iterdir():
193
+ if child.is_dir():
194
+ meta = load_meta(child.name)
195
+ if meta:
196
+ metas.append(meta)
197
+ return sorted(metas, key=lambda m: m.get("created_at", 0), reverse=True)
198
+
199
+
200
+ def delete(project_id: str) -> bool:
201
+ target = project_dir(project_id)
202
+ if not target.is_dir():
203
+ return False
204
+ shutil.rmtree(target, ignore_errors=True)
205
+ return True
206
+
207
+
208
+ def purge_expired() -> list[str]:
209
+ cutoff = time.time() - config.PROJECT_RETENTION_HOURS * 3600
210
+ removed = []
211
+ for meta in list_projects():
212
+ if meta.get("updated_at", 0) < cutoff:
213
+ if delete(meta["id"]):
214
+ removed.append(meta["id"])
215
+ return removed
216
+
217
+
218
+ def build_output(project_id: str, meta: dict) -> Path:
219
+ """Package the translated scripts.
220
+
221
+ Only files that actually changed go in the zip. A Ren'Py game folder is
222
+ mostly art and audio, and shipping it back unchanged would turn a few
223
+ hundred kilobytes of script into a download the size of the game.
224
+ """
225
+ from . import inject, tl
226
+
227
+ base = project_dir(project_id)
228
+ staging = base / "build"
229
+ shutil.rmtree(staging, ignore_errors=True)
230
+ shutil.copytree(source_dir(project_id), staging)
231
+
232
+ units = load_units(project_id)
233
+ translations = load_translations(project_id)
234
+ lang = meta.get("target_lang") or "translated"
235
+
236
+ inject.apply(staging, units, translations)
237
+
238
+ # Files that received at least one translation - the only ones worth
239
+ # sending back.
240
+ changed = {
241
+ slot.file
242
+ for unit in units
243
+ if unit.source in translations
244
+ for slot in unit.slots
245
+ if slot.writable
246
+ }
247
+
248
+ generated = tl.write_runtime_translation(staging, units, translations, lang)
249
+
250
+ output = base / "translated.zip"
251
+ output.unlink(missing_ok=True)
252
+ with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as zf:
253
+ for name in sorted(changed):
254
+ path = staging / name
255
+ if path.is_file():
256
+ zf.write(path, name)
257
+ for path in generated:
258
+ zf.write(path, path.relative_to(staging).as_posix())
259
+ shutil.rmtree(staging, ignore_errors=True)
260
+ return output
app/renpy/text.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ren'Py string-literal handling.
2
+
3
+ Two jobs, both of which have to be exactly reversible or the game stops
4
+ compiling:
5
+
6
+ 1. Turning the characters between the quotes in a .rpy file into the text a
7
+ translator should see, and back again.
8
+ 2. Deciding whether a string carries any language at all, so pure markup like
9
+ "{i}[player]{/i}" never costs a generation.
10
+
11
+ Ren'Py's own text syntax matters here. `{...}` is a text tag, `[...]` is a
12
+ variable interpolation, and both are doubled (`{{`, `[[`) to mean a literal
13
+ brace or bracket. Those doubles must survive untouched: turning `[[` into `[`
14
+ would change what the player sees, and turning it into a variable reference
15
+ would crash the game on a missing name.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+
22
+ # The escape sequences this module round-trips. Deliberately short: every
23
+ # sequence here is unescaped on the way in and re-escaped identically on the
24
+ # way out, so a string that is never translated comes back byte-for-byte.
25
+ #
26
+ # A string containing any *other* backslash escape (Ren'Py's `\ ` hard space is
27
+ # the one that shows up in practice) is refused by `unescape` rather than
28
+ # guessed at - see `is_safe_literal`. Those strings are left in the source
29
+ # untranslated, which costs a line of dialogue but can never corrupt a script.
30
+ _UNESCAPE = {
31
+ "\\": "\\",
32
+ '"': '"',
33
+ "'": "'",
34
+ "n": "\n",
35
+ "t": "\t",
36
+ }
37
+ _ESCAPE = {
38
+ "\\": "\\\\",
39
+ "\n": "\\n",
40
+ "\t": "\\t",
41
+ }
42
+
43
+ _ESCAPE_RE = re.compile(r"\\(.)", re.DOTALL)
44
+
45
+ # Text tags and interpolations, with the doubled literal forms matched first so
46
+ # `{{` is never mistaken for the start of a tag.
47
+ _MARKUP_RE = re.compile(
48
+ r"\{\{|\[\[|\{[^{}]*\}|\[[^\[\]]*\]"
49
+ )
50
+
51
+
52
+ class EscapeError(ValueError):
53
+ """Raised for a literal using an escape this module will not round-trip."""
54
+
55
+
56
+ def is_safe_literal(raw: str) -> bool:
57
+ """True when `raw` (the characters between the quotes) round-trips."""
58
+ try:
59
+ unescape(raw)
60
+ except EscapeError:
61
+ return False
62
+ return True
63
+
64
+
65
+ def unescape(raw: str) -> str:
66
+ """Turn the source characters between quotes into the readable text.
67
+
68
+ Raises EscapeError on an escape sequence outside the supported set, so the
69
+ caller can leave that literal alone instead of writing back something that
70
+ means something different.
71
+ """
72
+ out: list[str] = []
73
+ index = 0
74
+ length = len(raw)
75
+ while index < length:
76
+ char = raw[index]
77
+ if char != "\\":
78
+ out.append(char)
79
+ index += 1
80
+ continue
81
+ if index + 1 >= length:
82
+ raise EscapeError("literal ends on a lone backslash")
83
+ nxt = raw[index + 1]
84
+ if nxt not in _UNESCAPE:
85
+ raise EscapeError(f"unsupported escape \\{nxt}")
86
+ out.append(_UNESCAPE[nxt])
87
+ index += 2
88
+ return "".join(out)
89
+
90
+
91
+ def escape(text: str, quote: str) -> str:
92
+ """Render `text` back into the characters that belong between `quote`s.
93
+
94
+ Only the enclosing quote is escaped: a single quote inside a double-quoted
95
+ string is left bare, exactly as Ren'Py writes it.
96
+ """
97
+ out: list[str] = []
98
+ for char in text:
99
+ if char in _ESCAPE:
100
+ out.append(_ESCAPE[char])
101
+ elif char == quote:
102
+ out.append("\\" + char)
103
+ else:
104
+ out.append(char)
105
+ return "".join(out)
106
+
107
+
108
+ def strip_markup(text: str) -> str:
109
+ """Remove text tags and interpolations, keeping the words between them."""
110
+ return _MARKUP_RE.sub(" ", text)
111
+
112
+
113
+ def is_translatable(text: str) -> bool:
114
+ """False for strings that are only markup, numbers or punctuation.
115
+
116
+ A screen is full of these - "{i}{/i}", "[points]", "- - -" - and each one
117
+ sent to the model costs a full generation and invites it to invent words
118
+ that were never in the source.
119
+ """
120
+ return any(char.isalpha() for char in strip_markup(text))
app/renpy/tl.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deliver translations for text that lives in compiled scripts.
2
+
3
+ A .rpyc cannot be edited: its syntax tree encodes the positions the engine
4
+ checks against, so text has to reach the player another way. Ren'Py provides
5
+ two, and a game that ships only .rpyc files needs both:
6
+
7
+ config.say_menu_text_filter every line of dialogue and every menu choice
8
+ passes through it before it is shown, keyed by
9
+ the untranslated text
10
+ translate <lang> strings: the interface strings the game marks with _()
11
+
12
+ Nothing here runs unless the upload actually contained a compiled script. A
13
+ project with its .rpy sources gets its text written straight into those files
14
+ and needs no runtime hook at all.
15
+
16
+ Two failure modes are designed out rather than tested for:
17
+
18
+ * Ren'Py 7.5 and later refuse to start if the same `old` string appears twice
19
+ in a `translate strings:` block ("has been translated more than once"). The
20
+ entries are keyed by source text and asserted unique before writing.
21
+ * A generated .rpy with a syntax error takes the whole game down on launch, so
22
+ the Python half of the hook is compiled here before it is written.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ from pathlib import Path
29
+
30
+ from ..translation import LANGUAGES
31
+ from .text import escape
32
+
33
+ _JSON_NAME = "hymt_dialogue.json"
34
+ _HOOK_NAME = "hymt_translate.rpy"
35
+ _STRINGS_NAME = "hymt_strings.rpy"
36
+
37
+ # The Python that goes inside the hook's `init python:` block, kept here as
38
+ # plain source so it can be compiled before it is written into a .rpy.
39
+ _HOOK_BODY = '''\
40
+ import json
41
+ import os
42
+
43
+ def _hymt_load():
44
+ path = os.path.join(config.gamedir, "tl", "__LANG__", "__JSON__")
45
+ try:
46
+ with open(path, "rb") as handle:
47
+ return json.loads(handle.read().decode("utf-8"))
48
+ except Exception:
49
+ return {}
50
+
51
+ _hymt_map = _hymt_load()
52
+ _hymt_previous = getattr(config, "say_menu_text_filter", None)
53
+
54
+ def _hymt_filter(text):
55
+ if _hymt_previous is not None:
56
+ text = _hymt_previous(text)
57
+ return _hymt_map.get(text, text)
58
+
59
+ config.say_menu_text_filter = _hymt_filter
60
+ '''
61
+
62
+
63
+ def language_name(target_lang: str) -> str:
64
+ """Map a language code to the directory name Ren'Py uses.
65
+
66
+ Ren'Py keys translations by an English language name in lower case
67
+ ("turkish"), not by an ISO code, and the tl/ folder has to match.
68
+ """
69
+ code = (target_lang or "").strip().lower()
70
+ name = LANGUAGES.get(code, code)
71
+ return name.lower().replace(" ", "") or "translated"
72
+
73
+
74
+ def _render_hook(lang: str) -> str:
75
+ body = _HOOK_BODY.replace("__LANG__", lang).replace("__JSON__", _JSON_NAME)
76
+ # Fail here, in the generator, rather than in the player's game.
77
+ compile(body, "<hymt hook>", "exec")
78
+
79
+ indented = "\n".join(
80
+ (" " + line) if line.strip() else "" for line in body.splitlines()
81
+ )
82
+ return (
83
+ "# Generated translation hook.\n"
84
+ "#\n"
85
+ "# The dialogue in this game is inside compiled .rpyc scripts, which\n"
86
+ "# cannot be edited. This filter swaps each line for its translation\n"
87
+ "# as the game displays it, and leaves anything untranslated alone.\n"
88
+ "\n"
89
+ "init python:\n" + indented + "\n"
90
+ )
91
+
92
+
93
+ def _render_strings(lang: str, pairs: list[tuple[str, str]]) -> str:
94
+ lines = [
95
+ "# Generated interface translations.",
96
+ "",
97
+ f"translate {lang} strings:",
98
+ "",
99
+ ]
100
+ for source, translated in pairs:
101
+ lines.append(f' old "{escape(source, chr(34))}"')
102
+ lines.append(f' new "{escape(translated, chr(34))}"')
103
+ lines.append("")
104
+ return "\n".join(lines)
105
+
106
+
107
+ def game_root(staging: Path, units: list) -> Path:
108
+ """The directory the generated files belong in.
109
+
110
+ Ren'Py resolves `config.gamedir` to the game's `game/` folder, and that is
111
+ where tl/ has to live. Uploads arrive either as a game folder or as a bare
112
+ pile of scripts, so the folder is found from where the scripts actually
113
+ sit rather than assumed - putting the hook beside `game/` instead of
114
+ inside it would leave the two halves of the output extracting to different
115
+ places.
116
+ """
117
+ for unit in units:
118
+ for slot in unit.slots:
119
+ parts = Path(slot.file).parts
120
+ if "game" in parts:
121
+ return staging.joinpath(*parts[: parts.index("game") + 1])
122
+ return staging
123
+
124
+
125
+ def write_runtime_translation(
126
+ staging: Path,
127
+ units: list,
128
+ translations: dict[str, str],
129
+ target_lang: str,
130
+ ) -> list[Path]:
131
+ """Write the hook, its data file and the strings block into `staging`.
132
+
133
+ Returns the files created, which is empty when the project had no compiled
134
+ scripts.
135
+ """
136
+ mapping: dict[str, str] = {}
137
+ for unit in units:
138
+ translated = translations.get(unit.source)
139
+ if translated is None or translated == unit.source:
140
+ continue
141
+ if any(not slot.writable for slot in unit.slots):
142
+ mapping[unit.source] = translated
143
+
144
+ if not mapping:
145
+ return []
146
+
147
+ lang = language_name(target_lang)
148
+ root = game_root(staging, units)
149
+ tl_dir = root / "tl" / lang
150
+ tl_dir.mkdir(parents=True, exist_ok=True)
151
+
152
+ json_path = tl_dir / _JSON_NAME
153
+ json_path.write_text(
154
+ json.dumps(mapping, ensure_ascii=False, indent=1), encoding="utf-8"
155
+ )
156
+
157
+ hook_path = root / _HOOK_NAME
158
+ hook_path.write_text(_render_hook(lang), encoding="utf-8")
159
+
160
+ # dict keys are unique by construction, so no `old` can repeat - the
161
+ # condition Ren'Py refuses to start on.
162
+ pairs = sorted(mapping.items())
163
+ assert len({source for source, _ in pairs}) == len(pairs)
164
+ strings_path = tl_dir / _STRINGS_NAME
165
+ strings_path.write_text(_render_strings(lang, pairs), encoding="utf-8")
166
+
167
+ return [json_path, hook_path, strings_path]