Build each screen in one language: the screen texts from the translations, European letters in the fonts, and Language & region in the app

The firmware takes its texts from screen_manager/translations/<code>.json at build time (components/smart_display:
one table in main.cpp, keys in screen_text_keys.h), and the fonts carry every letter European languages write with.
The app keeps Settings -> Language & region (language, clock, numbers) for every screen, writes LANGUAGE into the
screens' YAML and sends the clock and the number format in the layout message; the per-screen 24-hour clock goes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-19 14:24:19 +02:00
parent 9362a5f1db
commit 378d0b3e61
51 changed files with 2321 additions and 383 deletions
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""The translations in screen_manager/translations/ (app 0.2.90); docs/TRANSLATING.md says how they work.
python3 tools/i18n.py check every language against English: keys, placeholders, plural forms, letters
the screens can't draw, screen texts much longer than the English
python3 tools/i18n.py header [--check] write (or compare) the key header and the tests' English table that the
firmware's `screen` texts need, after changing the keys of en.json
python3 tools/i18n.py cldr [--write] day and month names, date orders and the decimal mark from the Unicode CLDR
(Node's Intl), into every language's screen.date and screen.number
"""
import argparse
import importlib.util
import json
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
FOLDER = ROOT / 'screen_manager' / 'translations'
KEYS_HEADER = ROOT / 'components' / 'smart_display' / 'screen_text_keys.h'
TESTS_HEADER = ROOT / 'tests' / 'screen_text_en.h'
CORE = ROOT / 'packages' / 'core.yaml'
def generator():
"""components/smart_display/screen_text_gen.py, which the firmware build itself uses."""
spec = importlib.util.spec_from_file_location('screen_text_gen', ROOT / 'components' / 'smart_display' / 'screen_text_gen.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def languages():
"""{code: data} of every language file, English first."""
found = {path.stem: json.loads(path.read_text(encoding='utf-8')) for path in sorted(FOLDER.glob('*.json'))}
return dict(sorted(found.items(), key=lambda item: (item[0] != 'en', item[0])))
def header(check):
gen = generator()
wanted = {KEYS_HEADER: gen.keys_header([key for key, _ in gen.screen_pairs(gen.load('en', FOLDER))]),
TESTS_HEADER: gen.english_header(FOLDER)}
stale = [path for path, text in wanted.items() if not path.exists() or path.read_text(encoding='utf-8') != text]
if check:
for path in stale:
print(f'{path.relative_to(ROOT)} is out of date: run python3 tools/i18n.py header')
return 1 if stale else 0
for path in stale:
path.write_text(wanted[path], encoding='utf-8')
print(f'wrote {path.relative_to(ROOT)}')
return 0
PLACEHOLDER = re.compile(r'\{([a-z_]+)\}')
FORMS = {'one_other': 2, 'one_upto_1': 2, 'slavic_pl': 3, 'east_slavic': 3, 'none': 1}
def font_letters():
"""The characters every text font of the screens carries: what a `screen` text may use."""
text = CORE.read_text(encoding='utf-8')
sets = []
for block in re.finditer(r'(?ms)^ - file: "\$\{FONT_DIR\}/Roboto-\d+\.ttf"\n(.*?)(?=^ - |^\S|\Z)', text):
body = block.group(1)
if not re.search(r'(?m)^\s+id: (headline|label|sublabel|sublabel_big|watch_value)\s*$', body):
continue
glyphs = re.search(r'(?s)glyphs:\s*\[(.*?)\]', body)
if glyphs:
letters = set()
for item in re.findall(r"'((?:[^'\\]|\\.)*)'|\"((?:[^\"\\]|\\.)*)\"", glyphs.group(1)):
letters.update(item[0] or item[1])
sets.append(letters)
return set.intersection(*sets) if sets else set()
def check():
gen = generator()
langs = languages()
english = langs['en']
problems, notes = [], []
base = dict(gen.flatten({k: v for k, v in english.items() if k != '_meta'}))
drawable = font_letters()
for code, data in langs.items():
meta = data.get('_meta', {})
for field in ('name', 'english', 'script', 'plural', 'checked'):
if field not in meta:
problems.append(f'{code}: _meta.{field} is missing')
forms = FORMS.get(meta.get('plural'))
if forms is None:
problems.append(f'{code}: _meta.plural must be one of {", ".join(FORMS)}')
own = dict(gen.flatten({k: v for k, v in data.items() if k != '_meta'}))
for key in own.keys() - base.keys():
problems.append(f'{code}: {key} is not in en.json')
missing = [key for key in base if key not in own]
if code != 'en' and missing:
notes.append(f'{code}: {len(missing)} of {len(base)} texts still in English')
for key, text in own.items():
if key not in base or not isinstance(text, str):
continue
source = base[key]
if set(PLACEHOLDER.findall(text)) != set(PLACEHOLDER.findall(source)):
problems.append(f'{code}: {key} has placeholders {sorted(set(PLACEHOLDER.findall(text)))}, '
f'English {sorted(set(PLACEHOLDER.findall(source)))}')
plural = ' | ' in source
if plural and forms and len(text.split('|')) not in (1, forms):
problems.append(f'{code}: {key} needs {forms} forms separated by " | "')
if key.startswith('screen.') and drawable:
unknown = sorted(set(text) - drawable - {'\n'})
if unknown:
problems.append(f'{code}: {key} uses letters the screens can\'t draw: {"".join(unknown)}')
if code != 'en' and len(text) > max(8, len(source) * 1.6) and not plural:
notes.append(f'{code}: {key} is much longer than the English ({len(text)} vs {len(source)})')
for line in notes:
print('note:', line)
for line in problems:
print('problem:', line)
print(f'{len(langs)} languages, {len(base)} texts: {len(problems)} problems')
return 1 if problems else 0
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = parser.add_subparsers(dest='command', required=True)
sub.add_parser('check')
head = sub.add_parser('header')
head.add_argument('--check', action='store_true')
cldr = sub.add_parser('cldr')
cldr.add_argument('--write', action='store_true')
args = parser.parse_args()
if args.command == 'check':
return check()
if args.command == 'header':
return header(args.check)
if args.command == 'cldr':
return cldr_dates(args.write)
return 2
def cldr_dates(write):
raise SystemExit('not yet')
if __name__ == '__main__':
sys.exit(main())