diff --git a/backend/app/translation.py b/backend/app/translation.py index 45efb44..0a6ee9f 100644 --- a/backend/app/translation.py +++ b/backend/app/translation.py @@ -62,6 +62,39 @@ class TextIndex: 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: game_hash = manifest["files"]["game.json"]["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.by_tid[info.tid] = info count += 1 + for text in marked_strings(module["labels"]): + idx.strings.setdefault(text, "text") idx.modules.append((path, count)) + for text in marked_strings(game.get("screens") or {}): + idx.strings.setdefault(text, "text") return idx diff --git a/backend/tests/test_translation.py b/backend/tests/test_translation.py index 51b7f97..6d2b933 100644 --- a/backend/tests/test_translation.py +++ b/backend/tests/test_translation.py @@ -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", locate="")["located"] is 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"] diff --git a/docs/LOCALIZATION.md b/docs/LOCALIZATION.md index 7f16078..0acd989 100644 --- a/docs/LOCALIZATION.md +++ b/docs/LOCALIZATION.md @@ -239,6 +239,11 @@ translate russian strings: переводит одну строку с тем же контекстом и глоссарием и помечает её «Машинный». Редактор ищет переводчик сам, только если он уже работал в этом браузере, чтобы Chrome не спрашивал про локальную сеть без причины; +* вкладка «Имена и меню» собирает не только имена, пункты меню и `show text "…"`, + но и строки `_("…")` из скриптов и постоянный текст экранов (`text`, `textbutton`, + `label`): у них нет идентификатора реплики, поэтому они переводятся по тексту + оригинала, как в Ren'Py. Список строится из уже конвертированной версии, повторный + импорт игры не нужен; * декоративные теги Ren'Py (`{size=…}`, `{i}`, `{b}`, `{color=…}` и т. п.) внутри слов (`d{size=38}e{/size}ep`) после перевода на место не вернуть, поэтому такие строки уходят модели без этих тегов, а результат помечается «Неточно». Строки с diff --git a/frontend/src/pages/TranslationEditor.tsx b/frontend/src/pages/TranslationEditor.tsx index 850ab23..041b902 100644 --- a/frontend/src/pages/TranslationEditor.tsx +++ b/frontend/src/pages/TranslationEditor.tsx @@ -309,6 +309,8 @@ function EntriesView({ versionId, lang, tab, ov, onStats, session, fills, fillTi const t = setTimeout(() => { setQ(query); 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); return () => clearTimeout(t); }, [query]); diff --git a/frontend/src/translator/session.ts b/frontend/src/translator/session.ts index af16dae..497f096 100644 --- a/frontend/src/translator/session.ts +++ b/frontend/src/translator/session.ts @@ -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 interface Session { @@ -151,7 +156,7 @@ export function useTranslatorSession(versionId: string | undefined, version: Ver context: [`Game: ${title}.`, prefs.about && g?.description, prefs.about && g?.tags?.length ? `Genres and tags: ${g.tags.join(", ")}.` : ""] .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()) .map((t) => [t.src.trim(), t.t.trim()])), provider: prefs.provider || "lmstudio",