feat: _() strings and screen texts become translatable strings; search looks in all states; stutter hint
show text _("...") and literal screen texts are listed in Names and menu; typing in search switches the filter from To do to All; the translator is told how stutters are spelled.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
262db2687e
commit
cc71050362
5 files changed
+73
-1
No files matched your search
@@ -62,6 +62,39 @@ class TextIndex:
|
|||||||
modules: list[tuple[str, int]] = field(default_factory=list)
|
modules: list[tuple[str, int]] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
_WORD = re.compile(r"[^\W\d_]")
|
||||||
|
_SCREEN_TEXT = {"text", "textbutton", "label"}
|
||||||
|
|
||||||
|
|
||||||
|
def marked_strings(value: Any) -> list[str]:
|
||||||
|
"""Texts that Ren'Py translates by their source: ``_("...")`` and the literal text of screen widgets.
|
||||||
|
|
||||||
|
``show text _("...")``, ``$ x = _("...")`` and screen ``text "..."`` carry no dialogue id, so they
|
||||||
|
are keyed by the text itself, like menu items. Strings without a letter (numbers, markup) are skipped.
|
||||||
|
"""
|
||||||
|
out: list[str] = []
|
||||||
|
stack = [value]
|
||||||
|
while stack:
|
||||||
|
item = stack.pop()
|
||||||
|
if isinstance(item, list):
|
||||||
|
stack.extend(reversed(item))
|
||||||
|
continue
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
func = item.get("f")
|
||||||
|
if (item.get("k") == "call" and isinstance(func, dict) and func.get("k") == "name"
|
||||||
|
and func.get("id") in ("_", "__") and len(item.get("a") or []) == 1):
|
||||||
|
arg = item["a"][0]
|
||||||
|
if isinstance(arg, dict) and arg.get("k") == "const" and isinstance(arg.get("v"), str):
|
||||||
|
out.append(arg["v"])
|
||||||
|
if item.get("w") in _SCREEN_TEXT and item.get("args"):
|
||||||
|
arg = item["args"][0]
|
||||||
|
if isinstance(arg, dict) and arg.get("k") == "const" and isinstance(arg.get("v"), str):
|
||||||
|
out.append(arg["v"])
|
||||||
|
stack.extend(reversed(list(item.values())))
|
||||||
|
return [text for text in out if text.strip() and _WORD.search(text)]
|
||||||
|
|
||||||
|
|
||||||
def text_index(manifest: dict[str, Any]) -> TextIndex:
|
def text_index(manifest: dict[str, Any]) -> TextIndex:
|
||||||
game_hash = manifest["files"]["game.json"]["hash"]
|
game_hash = manifest["files"]["game.json"]["hash"]
|
||||||
game = blob_json(game_hash)
|
game = blob_json(game_hash)
|
||||||
@@ -109,7 +142,11 @@ def _build_index(game_hash: str, scripts: tuple[tuple[str, str], ...]) -> TextIn
|
|||||||
idx.lines.append(info)
|
idx.lines.append(info)
|
||||||
idx.by_tid[info.tid] = info
|
idx.by_tid[info.tid] = info
|
||||||
count += 1
|
count += 1
|
||||||
|
for text in marked_strings(module["labels"]):
|
||||||
|
idx.strings.setdefault(text, "text")
|
||||||
idx.modules.append((path, count))
|
idx.modules.append((path, count))
|
||||||
|
for text in marked_strings(game.get("screens") or {}):
|
||||||
|
idx.strings.setdefault(text, "text")
|
||||||
return idx
|
return idx
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -96,3 +96,26 @@ def test_entries_page_locates_a_line_in_the_filtered_list(manifest):
|
|||||||
assert tr.entries_page(m, "ru", state="todo", locate=first.tid)["located"] == 0
|
assert tr.entries_page(m, "ru", state="todo", locate=first.tid)["located"] == 0
|
||||||
assert tr.entries_page(m, "ru", locate="")["located"] is None
|
assert tr.entries_page(m, "ru", locate="")["located"] is None
|
||||||
assert tr.entries_page(m, "ru", kind="strings", locate="Эйлин")["located"] is not None
|
assert tr.entries_page(m, "ru", kind="strings", locate="Эйлин")["located"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _mark(text):
|
||||||
|
return {"k": "call", "f": {"k": "name", "id": "_"}, "a": [{"k": "const", "v": text}]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_marked_strings_and_screen_texts_become_translatable_strings():
|
||||||
|
body = [
|
||||||
|
{"op": "show", "img": {"textExpr": _mark("Do you want a short tutorial?")}},
|
||||||
|
{"op": "python", "code": [{"k": "assign", "value": _mark("Hi, [name]!")}]},
|
||||||
|
{"op": "show", "img": {"textExpr": _mark("")}}, # empty: nothing to translate
|
||||||
|
{"op": "show", "img": {"textExpr": _mark("42")}}, # no letters: not text
|
||||||
|
{"op": "show", "img": {"textExpr": {"k": "call", "f": {"k": "name", "id": "other"},
|
||||||
|
"a": [{"k": "const", "v": "Not marked"}]}}},
|
||||||
|
]
|
||||||
|
screens = {"menu": {"body": [
|
||||||
|
{"w": "text", "args": [{"k": "const", "v": "Preferences"}], "props": {}, "body": []},
|
||||||
|
{"w": "textbutton", "args": [{"k": "const", "v": "Quit"}], "props": {}, "body": []},
|
||||||
|
{"w": "text", "args": [{"k": "name", "id": "title"}], "props": {}, "body": []}, # variable
|
||||||
|
{"w": "add", "args": [{"k": "const", "v": "bg.png"}], "props": {}, "body": []}, # not text
|
||||||
|
]}}
|
||||||
|
assert tr.marked_strings([{"name": "start", "body": body}]) == ["Do you want a short tutorial?", "Hi, [name]!"]
|
||||||
|
assert tr.marked_strings(screens) == ["Preferences", "Quit"]
|
||||||
@@ -239,6 +239,11 @@ translate russian strings:
|
|||||||
переводит одну строку с тем же контекстом и глоссарием и помечает её
|
переводит одну строку с тем же контекстом и глоссарием и помечает её
|
||||||
«Машинный». Редактор ищет переводчик сам, только если он уже работал в этом
|
«Машинный». Редактор ищет переводчик сам, только если он уже работал в этом
|
||||||
браузере, чтобы Chrome не спрашивал про локальную сеть без причины;
|
браузере, чтобы Chrome не спрашивал про локальную сеть без причины;
|
||||||
|
* вкладка «Имена и меню» собирает не только имена, пункты меню и `show text "…"`,
|
||||||
|
но и строки `_("…")` из скриптов и постоянный текст экранов (`text`, `textbutton`,
|
||||||
|
`label`): у них нет идентификатора реплики, поэтому они переводятся по тексту
|
||||||
|
оригинала, как в Ren'Py. Список строится из уже конвертированной версии, повторный
|
||||||
|
импорт игры не нужен;
|
||||||
* декоративные теги Ren'Py (`{size=…}`, `{i}`, `{b}`, `{color=…}` и т. п.) внутри
|
* декоративные теги Ren'Py (`{size=…}`, `{i}`, `{b}`, `{color=…}` и т. п.) внутри
|
||||||
слов (`d{size=38}e{/size}ep`) после перевода на место не вернуть, поэтому такие
|
слов (`d{size=38}e{/size}ep`) после перевода на место не вернуть, поэтому такие
|
||||||
строки уходят модели без этих тегов, а результат помечается «Неточно». Строки с
|
строки уходят модели без этих тегов, а результат помечается «Неточно». Строки с
|
||||||
|
|||||||
@@ -309,6 +309,8 @@ function EntriesView({ versionId, lang, tab, ov, onStats, session, fills, fillTi
|
|||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
setQ(query);
|
setQ(query);
|
||||||
setPage(0);
|
setPage(0);
|
||||||
|
// looking for a phrase means looking everywhere, not only among what still needs work
|
||||||
|
if (query.trim()) setState((current) => (current === "todo" ? "all" : current));
|
||||||
}, 300);
|
}, 300);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [query]);
|
}, [query]);
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ function loadPrefs(): Prefs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Visual novels spell stutters with hyphens and repeated letters; models misread the broken word. */
|
||||||
|
const STUTTER_HINT = "The source may contain stuttering written with hyphens and repeated letters, "
|
||||||
|
+ "like \"c-cer-cervix\" or \"m-my\". First work out the intended word, translate that word correctly "
|
||||||
|
+ "and keep the stutter in the translation (for example \"ш-шейка матки\"). Never translate the broken spelling literally.";
|
||||||
|
|
||||||
export type Connection = Link | "idle" | "checking";
|
export type Connection = Link | "idle" | "checking";
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
@@ -151,7 +156,7 @@ export function useTranslatorSession(versionId: string | undefined, version: Ver
|
|||||||
context: [`Game: ${title}.`, prefs.about && g?.description,
|
context: [`Game: ${title}.`, prefs.about && g?.description,
|
||||||
prefs.about && g?.tags?.length ? `Genres and tags: ${g.tags.join(", ")}.` : ""]
|
prefs.about && g?.tags?.length ? `Genres and tags: ${g.tags.join(", ")}.` : ""]
|
||||||
.filter(Boolean).join(" ").slice(0, 4000),
|
.filter(Boolean).join(" ").slice(0, 4000),
|
||||||
instructions: instructions.trim(),
|
instructions: [instructions.trim(), STUTTER_HINT].filter(Boolean).join(" "),
|
||||||
glossary: Object.fromEntries((meta.glossary ?? []).filter((t) => t.src.trim() && t.t.trim())
|
glossary: Object.fromEntries((meta.glossary ?? []).filter((t) => t.src.trim() && t.t.trim())
|
||||||
.map((t) => [t.src.trim(), t.t.trim()])),
|
.map((t) => [t.src.trim(), t.t.trim()])),
|
||||||
provider: prefs.provider || "lmstudio",
|
provider: prefs.provider || "lmstudio",
|
||||||
|
|||||||
Reference in new issue
Block a user