Files
sunnypilot/system/ui/lib/multilang.py
github-actions[bot] f398269b1a sunnypilot v2026.04.06-4377
version: sunnypilot v2026.001.000 (dev)
date: 2026-04-06T01:24:35
master commit: 8fec761f89
2026-04-06 01:24:35 +00:00

215 lines
5.8 KiB
Python

from importlib.resources import files
import json
import os
import re
from openpilot.common.basedir import BASEDIR
from openpilot.common.swaglog import cloudlog
try:
from openpilot.common.params import Params
except ImportError:
Params = None
SYSTEM_UI_DIR = os.path.join(BASEDIR, "system", "ui")
UI_DIR = files("openpilot.selfdrive.ui")
TRANSLATIONS_DIR = UI_DIR.joinpath("translations")
LANGUAGES_FILE = TRANSLATIONS_DIR.joinpath("languages.json")
UNIFONT_LANGUAGES = [
"th",
"zh-CHT",
"zh-CHS",
"ko",
"ja",
]
# Plural form selectors for supported languages
PLURAL_SELECTORS = {
'en': lambda n: 0 if n == 1 else 1,
'de': lambda n: 0 if n == 1 else 1,
'fr': lambda n: 0 if n <= 1 else 1,
'pt-BR': lambda n: 0 if n <= 1 else 1,
'es': lambda n: 0 if n == 1 else 1,
'tr': lambda n: 0 if n == 1 else 1,
'uk': lambda n: 0 if n % 10 == 1 and n % 100 != 11 else (1 if 2 <= n % 10 <= 4 and not 12 <= n % 100 <= 14 else 2),
'th': lambda n: 0,
'zh-CHT': lambda n: 0,
'zh-CHS': lambda n: 0,
'ko': lambda n: 0,
'ja': lambda n: 0,
}
def _parse_quoted(s: str) -> str:
"""Parse a PO-format quoted string."""
s = s.strip()
if not (s.startswith('"') and s.endswith('"')):
raise ValueError(f"Expected quoted string: {s!r}")
s = s[1:-1]
result: list[str] = []
i = 0
while i < len(s):
if s[i] == '\\' and i + 1 < len(s):
c = s[i + 1]
if c == 'n':
result.append('\n')
elif c == 't':
result.append('\t')
elif c == '"':
result.append('"')
elif c == '\\':
result.append('\\')
else:
result.append(s[i:i + 2])
i += 2
else:
result.append(s[i])
i += 1
return ''.join(result)
def load_translations(path) -> tuple[dict[str, str], dict[str, list[str]]]:
"""Parse a .po file and return (translations, plurals) dicts.
translations: msgid -> msgstr
plurals: msgid -> [msgstr[0], msgstr[1], ...]
"""
with path.open(encoding='utf-8') as f:
lines = f.readlines()
translations: dict[str, str] = {}
plurals: dict[str, list[str]] = {}
# Parser state
msgid = msgid_plural = msgstr = ""
msgstr_plurals: dict[int, str] = {}
field: str | None = None
plural_idx = 0
def finish():
nonlocal msgid, msgid_plural, msgstr, msgstr_plurals, field
if msgid: # skip header (empty msgid)
if msgid_plural:
max_idx = max(msgstr_plurals.keys()) if msgstr_plurals else 0
plurals[msgid] = [msgstr_plurals.get(i, '') for i in range(max_idx + 1)]
else:
if msgstr:
translations[msgid] = msgstr
msgid = msgid_plural = msgstr = ""
msgstr_plurals = {}
field = None
for raw in lines:
line = raw.strip()
if not line:
finish()
continue
if line.startswith('#'):
continue
if line.startswith('msgid_plural '):
msgid_plural = _parse_quoted(line[len('msgid_plural '):])
field = 'msgid_plural'
continue
if line.startswith('msgid '):
msgid = _parse_quoted(line[len('msgid '):])
field = 'msgid'
continue
m = re.match(r'msgstr\[(\d+)]\s+(.*)', line)
if m:
plural_idx = int(m.group(1))
msgstr_plurals[plural_idx] = _parse_quoted(m.group(2))
field = 'msgstr_plural'
continue
if line.startswith('msgstr '):
msgstr = _parse_quoted(line[len('msgstr '):])
field = 'msgstr'
continue
if line.startswith('"'):
val = _parse_quoted(line)
if field == 'msgid':
msgid += val
elif field == 'msgid_plural':
msgid_plural += val
elif field == 'msgstr':
msgstr += val
elif field == 'msgstr_plural':
msgstr_plurals[plural_idx] += val
finish()
return translations, plurals
class Multilang:
def __init__(self):
self._params = Params() if Params is not None else None
self._language: str = "en"
self.languages: dict[str, str] = {}
self.codes: dict[str, str] = {}
self._translations: dict[str, str] = {}
self._plurals: dict[str, list[str]] = {}
self._plural_selector = PLURAL_SELECTORS.get('en', lambda n: 0)
self._load_languages()
@property
def language(self) -> str:
return self._language
def requires_unifont(self) -> bool:
"""Certain languages require unifont to render their glyphs."""
return self._language in UNIFONT_LANGUAGES
def setup(self):
try:
po_path = TRANSLATIONS_DIR.joinpath(f'app_{self._language}.po')
self._translations, self._plurals = load_translations(po_path)
self._plural_selector = PLURAL_SELECTORS.get(self._language, lambda n: 0)
cloudlog.debug(f"Loaded translations for language: {self._language}")
except FileNotFoundError:
cloudlog.error(f"No translation file found for language: {self._language}, using default.")
self._translations = {}
self._plurals = {}
def change_language(self, language_code: str) -> None:
self._params.put("LanguageSetting", language_code)
self._language = language_code
self.setup()
def tr(self, text: str) -> str:
return self._translations.get(text, text) or text
def trn(self, singular: str, plural: str, n: int) -> str:
if singular in self._plurals:
idx = self._plural_selector(n)
forms = self._plurals[singular]
if idx < len(forms) and forms[idx]:
return forms[idx]
return singular if n == 1 else plural
def _load_languages(self):
with LANGUAGES_FILE.open(encoding='utf-8') as f:
self.languages = json.load(f)
self.codes = {v: k for k, v in self.languages.items()}
if self._params is not None:
lang = str(self._params.get("LanguageSetting")).removeprefix("main_")
if lang in self.codes:
self._language = lang
multilang = Multilang()
multilang.setup()
tr, trn = multilang.tr, multilang.trn
# no-op marker for static strings translated later
def tr_noop(s: str) -> str:
return s