perf: replace i18n pointer tables with offset tables, strip unused strings (#1408)
## Summary * **What is the goal of this PR?** Reduce the flash footprint of the i18n string data and tooling improvements to `gen_i18n.py`. * **What changes are included?** ### 1. `lib/I18n/I18n.cpp` — use offset-based lookup `I18n::get()` previously dereferenced a `const char* const*` pointer array. It now uses a two-field `LangStrings` struct (a flat char blob + a `uint16_t` offset table) generated for each language: ```cpp // before const char* const* strings = getStringArray(_language); return strings[index]; // after const LangStrings lang = getLanguageStrings(_language); return lang.data + lang.offsets[index]; ``` Lookup cost is unchanged — still O(1), one array load and one addition. ### 2. `scripts/gen_i18n.py` — new generated layout Each language's string data is now emitted as: - **`STRINGS_XX_DATA[]`** — a single `const char[]` blob of all strings concatenated with `\0` separators. - **`OFFSETS_XX[]`** — a `uint16_t` array of one byte-offset per `StrId` into the blob. Previously each language had a `const char* const STRINGS_XX[]` pointer array (4 bytes/entry on ESP32-C3). #### Flash savings | Table type | Size per language | 19 languages | |---|---|---| | `const char*` pointer array (before) | `339 × 4 = 1,356 B` | **25,764 B** | | `uint16_t` offset table (after) | `339 × 2 = 678 B` | **12,882 B** | | **Saved** | | **12,882 B (~12.6 KB)** | String data size is unchanged — 133,092 B across 19 languages. **Total: 158,856 B → 145,974 B** (pointer tables → offset tables). ### 3. Build-time stripping of unused strings `gen_i18n.py` now scans the `src/` and `lib/` trees for `STR_*` references and, during a PlatformIO build, automatically omits the 52 strings that are defined in YAML but never referenced in code. This further reduces the compiled output from 339 → 287 string keys per language. --- ## `gen_i18n.py` CLI reference ``` python gen_i18n.py [translations_dir [output_dir]] [options] ``` | Argument / Flag | Default | Description | |---|---|---| | `translations_dir` | `lib/I18n/translations` | Path to the per-language YAML files | | `output_dir` | `lib/I18n/` | Where to write the generated `.h` / `.cpp` files | | `--src-dirs DIR [DIR …]` | `src lib` | Directories scanned for `STR_*` usage | | `--strip-unused` | off | Remove unreferenced `STR_*` keys from generated output | | `--verbose` / `-v` | off | Print per-key INFO/WARNING messages and `Generated:` lines | The PlatformIO build (SCons `pre:` hook) calls `main(strip_unused=True)` automatically, so unused strings are always stripped from firmware builds without any manual flag. **Default run output** (no flags) shows a per-language summary table: ``` Language Code Own Fallback Unused Data (B) ------------------ ---- --- -------- ------ -------- English EN 339 0 52 5,266 Belarusian BE 310 29 52 9,673 … Total: 339 | Used in code: 287 | Never used: 52 Flash (now): 133,092 B strings + 12,882 B offset tables (uint16_t) = 145,974 B Flash (before): 133,092 B strings + 25,764 B pointer tables (ptr32) = 158,856 B Saved by offset tables: 12,882 B ``` --- ### AI Usage Did you use AI tools to help write this code? **YES** (GitHub Copilot) --------- Co-authored-by: Zach Nelson <zach@zdnelson.com>
This commit is contained in:
+55
-12
@@ -4,13 +4,15 @@
|
|||||||
#include <HardwareSerial.h>
|
#include <HardwareSerial.h>
|
||||||
#include <Serialization.h>
|
#include <Serialization.h>
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
#include "I18nStrings.h"
|
#include "I18nStrings.h"
|
||||||
|
|
||||||
using namespace i18n_strings;
|
using namespace i18n_strings;
|
||||||
|
|
||||||
// Settings file path
|
// Settings file path
|
||||||
static constexpr const char* SETTINGS_FILE = "/.crosspoint/language.bin";
|
static constexpr const char* SETTINGS_FILE = "/.crosspoint/language.bin";
|
||||||
static constexpr uint8_t SETTINGS_VERSION = 1;
|
static constexpr uint8_t SETTINGS_VERSION = 2;
|
||||||
|
|
||||||
I18n& I18n::getInstance() {
|
I18n& I18n::getInstance() {
|
||||||
static I18n instance;
|
static I18n instance;
|
||||||
@@ -24,8 +26,8 @@ const char* I18n::get(StrId id) const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use generated helper function - no hardcoded switch needed!
|
// Use generated helper function - no hardcoded switch needed!
|
||||||
const char* const* strings = getStringArray(_language);
|
const LangStrings lang = getLanguageStrings(_language);
|
||||||
return strings[index];
|
return lang.data + lang.offsets[index];
|
||||||
}
|
}
|
||||||
|
|
||||||
void I18n::setLanguage(Language lang) {
|
void I18n::setLanguage(Language lang) {
|
||||||
@@ -44,6 +46,14 @@ const char* I18n::getLanguageName(Language lang) const {
|
|||||||
return LANGUAGE_NAMES[index];
|
return LANGUAGE_NAMES[index];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const char* I18n::getLanguageCode(Language lang) const {
|
||||||
|
const auto index = static_cast<size_t>(lang);
|
||||||
|
if (index >= static_cast<size_t>(Language::_COUNT)) {
|
||||||
|
return LANGUAGE_CODES[0];
|
||||||
|
}
|
||||||
|
return LANGUAGE_CODES[index];
|
||||||
|
}
|
||||||
|
|
||||||
void I18n::saveSettings() {
|
void I18n::saveSettings() {
|
||||||
Storage.mkdir("/.crosspoint");
|
Storage.mkdir("/.crosspoint");
|
||||||
|
|
||||||
@@ -54,10 +64,11 @@ void I18n::saveSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
serialization::writePod(file, SETTINGS_VERSION);
|
serialization::writePod(file, SETTINGS_VERSION);
|
||||||
serialization::writePod(file, static_cast<uint8_t>(_language));
|
serialization::writeString(file, getLanguageCode(_language));
|
||||||
|
|
||||||
file.close();
|
file.close();
|
||||||
Serial.printf("[I18N] Settings saved: language=%d\n", static_cast<int>(_language));
|
Serial.printf("[I18N] Settings saved: language=%d code=%s\n", static_cast<int>(_language),
|
||||||
|
getLanguageCode(_language));
|
||||||
}
|
}
|
||||||
|
|
||||||
void I18n::loadSettings() {
|
void I18n::loadSettings() {
|
||||||
@@ -69,17 +80,49 @@ void I18n::loadSettings() {
|
|||||||
|
|
||||||
uint8_t version;
|
uint8_t version;
|
||||||
serialization::readPod(file, version);
|
serialization::readPod(file, version);
|
||||||
if (version != SETTINGS_VERSION) {
|
|
||||||
Serial.printf("[I18N] Settings version mismatch\n");
|
if (version == SETTINGS_VERSION) {
|
||||||
|
std::string code;
|
||||||
|
serialization::readString(file, code);
|
||||||
|
bool found = false;
|
||||||
|
|
||||||
|
for (uint8_t i = 0; i < getLanguageCount(); i++) {
|
||||||
|
if (code == LANGUAGE_CODES[i]) {
|
||||||
|
_language = static_cast<Language>(i);
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (found) {
|
||||||
|
Serial.printf("[I18N] Loaded language code: %s (%d)\n", code.c_str(), static_cast<int>(_language));
|
||||||
|
} else {
|
||||||
|
Serial.printf("[I18N] Unknown language code in settings: %s\n", code.c_str());
|
||||||
|
}
|
||||||
|
file.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
uint8_t lang;
|
// Legacy migration path: version 1 stored language enum index directly.
|
||||||
serialization::readPod(file, lang);
|
if (version == 1) {
|
||||||
if (lang < static_cast<size_t>(Language::_COUNT)) {
|
uint8_t lang;
|
||||||
_language = static_cast<Language>(lang);
|
serialization::readPod(file, lang);
|
||||||
Serial.printf("[I18N] Loaded language: %d\n", static_cast<int>(_language));
|
if (lang < static_cast<size_t>(Language::_COUNT)) {
|
||||||
|
_language = static_cast<Language>(lang);
|
||||||
|
Serial.printf("[I18N] Migrating v1 language index: %d -> %s\n", static_cast<int>(_language),
|
||||||
|
getLanguageCode(_language));
|
||||||
|
file.close();
|
||||||
|
saveSettings();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
file.close();
|
||||||
|
Serial.printf("[I18N] Invalid v1 language index: %d\n", static_cast<int>(lang));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Serial.printf("[I18N] Settings version mismatch: %d\n", static_cast<int>(version));
|
||||||
|
|
||||||
|
file.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate character set for a specific language
|
// Generate character set for a specific language
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class I18n {
|
|||||||
|
|
||||||
Language getLanguage() const { return _language; }
|
Language getLanguage() const { return _language; }
|
||||||
void setLanguage(Language lang);
|
void setLanguage(Language lang);
|
||||||
|
const char* getLanguageCode(Language lang) const;
|
||||||
const char* getLanguageName(Language lang) const;
|
const char* getLanguageName(Language lang) const;
|
||||||
|
|
||||||
void saveSettings();
|
void saveSettings();
|
||||||
|
|||||||
+416
-104
@@ -15,24 +15,32 @@ Each YAML file must contain:
|
|||||||
The English file is the reference. Missing keys in other languages are
|
The English file is the reference. Missing keys in other languages are
|
||||||
automatically filled from English, with a warning.
|
automatically filled from English, with a warning.
|
||||||
|
|
||||||
Usage:
|
By default the script scans the src/ and lib/ trees for STR_* references and
|
||||||
python gen_i18n.py <translations_dir> <output_dir>
|
reports any translation keys that are never used. Pass --strip-unused to
|
||||||
|
omit those keys from the generated output entirely.
|
||||||
|
|
||||||
Example:
|
Usage:
|
||||||
|
python gen_i18n.py [translations_dir [output_dir]] [options]
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
python gen_i18n.py
|
||||||
python gen_i18n.py lib/I18n/translations lib/I18n/
|
python gen_i18n.py lib/I18n/translations lib/I18n/
|
||||||
|
python gen_i18n.py --strip-unused
|
||||||
|
python gen_i18n.py --strip-unused --src-dirs src lib/EpdFont
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Dict, Tuple
|
from typing import Dict, List, Optional, Set, Tuple
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# YAML file reading (simple key: "value" format, no PyYAML dependency)
|
# YAML file reading (simple key: "value" format, no PyYAML dependency)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _unescape_yaml_value(raw: str, filepath: str = "", line_num: int = 0) -> str:
|
def _unescape_yaml_value(raw: str, filepath: str = "", line_num: int = 0) -> str:
|
||||||
"""
|
"""
|
||||||
Process escape sequences in a YAML value string.
|
Process escape sequences in a YAML value string.
|
||||||
@@ -51,9 +59,7 @@ def _unescape_yaml_value(raw: str, filepath: str = "", line_num: int = 0) -> str
|
|||||||
elif nxt == "n":
|
elif nxt == "n":
|
||||||
result.append("\n")
|
result.append("\n")
|
||||||
else:
|
else:
|
||||||
raise ValueError(
|
raise ValueError(f"{filepath}:{line_num}: unknown escape '\\{nxt}'")
|
||||||
f"{filepath}:{line_num}: unknown escape '\\{nxt}'"
|
|
||||||
)
|
|
||||||
i += 2
|
i += 2
|
||||||
else:
|
else:
|
||||||
result.append(raw[i])
|
result.append(raw[i])
|
||||||
@@ -103,9 +109,11 @@ def parse_yaml_file(filepath: str) -> Dict[str, str]:
|
|||||||
# Load all languages from a directory of YAML files
|
# Load all languages from a directory of YAML files
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def load_translations(
|
def load_translations(
|
||||||
translations_dir: str,
|
translations_dir: str,
|
||||||
) -> Tuple[List[str], List[str], List[str], Dict[str, List[str]]]:
|
verbose: bool = False,
|
||||||
|
) -> Tuple[List[str], List[str], List[str], Dict[str, List[str]], List[Set[str]]]:
|
||||||
"""
|
"""
|
||||||
Read every YAML file in *translations_dir* and return:
|
Read every YAML file in *translations_dir* and return:
|
||||||
language_codes e.g. ["EN", "ES", ...]
|
language_codes e.g. ["EN", "ES", ...]
|
||||||
@@ -138,6 +146,31 @@ def load_translations(
|
|||||||
if english_file is None:
|
if english_file is None:
|
||||||
raise ValueError("No YAML file with _language_code: EN found")
|
raise ValueError("No YAML file with _language_code: EN found")
|
||||||
|
|
||||||
|
duplicate_orders: Dict[str, List[str]] = {}
|
||||||
|
order_to_files: Dict[str, List[str]] = {}
|
||||||
|
for fname, data in parsed.items():
|
||||||
|
order = data.get("_order")
|
||||||
|
if not order:
|
||||||
|
continue
|
||||||
|
order_to_files.setdefault(order, []).append(fname)
|
||||||
|
|
||||||
|
for order, files in order_to_files.items():
|
||||||
|
if len(files) > 1:
|
||||||
|
duplicate_orders[order] = sorted(files)
|
||||||
|
|
||||||
|
if duplicate_orders:
|
||||||
|
duplicate_messages = [
|
||||||
|
f"_order {order}: {', '.join(files)}"
|
||||||
|
for order, files in sorted(
|
||||||
|
duplicate_orders.items(), key=lambda item: int(item[0])
|
||||||
|
)
|
||||||
|
]
|
||||||
|
raise ValueError(
|
||||||
|
"Duplicate _order values found:\n "
|
||||||
|
+ "\n ".join(duplicate_messages)
|
||||||
|
+ "\nEach _order value must be unique to ensure a deterministic language order."
|
||||||
|
)
|
||||||
|
|
||||||
# Order: English first, then by _order metadata (falls back to filename)
|
# Order: English first, then by _order metadata (falls back to filename)
|
||||||
def sort_key(fname: str) -> Tuple[int, int, str]:
|
def sort_key(fname: str) -> Tuple[int, int, str]:
|
||||||
"""English always first (0), then by _order, then by filename."""
|
"""English always first (0), then by _order, then by filename."""
|
||||||
@@ -174,16 +207,20 @@ def load_translations(
|
|||||||
raise ValueError(f"Invalid C++ identifier in English file: '{key}'")
|
raise ValueError(f"Invalid C++ identifier in English file: '{key}'")
|
||||||
|
|
||||||
# Build translations dict, filling missing keys from English
|
# Build translations dict, filling missing keys from English
|
||||||
|
inherited_sets: List[Set[str]] = [set() for _ in ordered_files]
|
||||||
translations: Dict[str, List[str]] = {}
|
translations: Dict[str, List[str]] = {}
|
||||||
for key in string_keys:
|
for key in string_keys:
|
||||||
row: List[str] = []
|
row: List[str] = []
|
||||||
for fname in ordered_files:
|
for lang_idx, fname in enumerate(ordered_files):
|
||||||
data = parsed[fname]
|
data = parsed[fname]
|
||||||
value = data.get(key, "")
|
value = data.get(key, "")
|
||||||
if not value.strip() and fname != english_file:
|
if not value.strip() and fname != english_file:
|
||||||
value = english_data[key]
|
value = english_data[key]
|
||||||
lang_code = parsed[fname].get("_language_code", fname)
|
inherited_sets[lang_idx].add(key)
|
||||||
print(f" INFO: '{key}' missing in {lang_code}, using English fallback")
|
if verbose:
|
||||||
|
print(
|
||||||
|
f" INFO: '{key}' missing in {language_codes[lang_idx]}, using English fallback"
|
||||||
|
)
|
||||||
row.append(value)
|
row.append(value)
|
||||||
translations[key] = row
|
translations[key] = row
|
||||||
|
|
||||||
@@ -195,45 +232,72 @@ def load_translations(
|
|||||||
extra = [k for k in data if not k.startswith("_") and k not in english_data]
|
extra = [k for k in data if not k.startswith("_") and k not in english_data]
|
||||||
if extra:
|
if extra:
|
||||||
lang_code = data.get("_language_code", fname)
|
lang_code = data.get("_language_code", fname)
|
||||||
print(f" WARNING: {lang_code} has keys not in English: {', '.join(extra)}")
|
if verbose:
|
||||||
|
print(
|
||||||
|
f" WARNING: {lang_code} has keys not in English: {', '.join(extra)}"
|
||||||
|
)
|
||||||
|
|
||||||
print(f"Loaded {len(language_codes)} languages, {len(string_keys)} string keys")
|
if verbose:
|
||||||
return language_codes, language_names, string_keys, translations
|
print(f"Loaded {len(language_codes)} languages, {len(string_keys)} string keys")
|
||||||
|
return language_codes, language_names, string_keys, translations, inherited_sets
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Unused-string detection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_GENERATED_FILENAMES: Set[str] = {"I18nKeys.h", "I18nStrings.h", "I18nStrings.cpp"}
|
||||||
|
|
||||||
|
|
||||||
|
def find_used_string_keys(
|
||||||
|
src_dirs: List[str],
|
||||||
|
skip_filenames: Optional[Set[str]] = None,
|
||||||
|
) -> Set[str]:
|
||||||
|
"""
|
||||||
|
Scan C/C++ source files under *src_dirs* for STR_* identifiers.
|
||||||
|
|
||||||
|
Files whose basename appears in *skip_filenames* are skipped so that
|
||||||
|
the generated I18n files don't count as "usage" of themselves.
|
||||||
|
|
||||||
|
Returns the set of all STR_KEY names that appear at least once.
|
||||||
|
"""
|
||||||
|
if skip_filenames is None:
|
||||||
|
skip_filenames = _GENERATED_FILENAMES
|
||||||
|
|
||||||
|
pattern = re.compile(r"\bSTR_[A-Za-z0-9_]+\b")
|
||||||
|
used: Set[str] = set()
|
||||||
|
|
||||||
|
for src_dir in src_dirs:
|
||||||
|
p = Path(src_dir)
|
||||||
|
if not p.is_dir():
|
||||||
|
continue
|
||||||
|
for f in p.rglob("*"):
|
||||||
|
if f.suffix not in {".cpp", ".h", ".c"}:
|
||||||
|
continue
|
||||||
|
if f.name in skip_filenames:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
text = f.read_text(encoding="utf-8", errors="replace")
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
for m in pattern.finditer(text):
|
||||||
|
used.add(m.group(0))
|
||||||
|
|
||||||
|
return used
|
||||||
|
|
||||||
|
|
||||||
|
def report_unused_keys(
|
||||||
|
string_keys: List[str],
|
||||||
|
used_keys: Set[str],
|
||||||
|
) -> List[str]:
|
||||||
|
"""Return a sorted list of keys from *string_keys* absent in *used_keys*."""
|
||||||
|
return [k for k in sorted(string_keys) if k not in used_keys]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# C++ string escaping
|
# C++ string escaping
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
LANG_ABBREVIATIONS = {
|
|
||||||
"english": "EN",
|
|
||||||
"español": "ES", "espanol": "ES",
|
|
||||||
"italiano": "IT",
|
|
||||||
"svenska": "SV",
|
|
||||||
"français": "FR", "francais": "FR",
|
|
||||||
"deutsch": "DE", "german": "DE",
|
|
||||||
"polski": "PL",
|
|
||||||
"português": "PT", "portugues": "PT", "português (brasil)": "PO",
|
|
||||||
"中文": "ZH", "chinese": "ZH",
|
|
||||||
"日本語": "JA", "japanese": "JA",
|
|
||||||
"한국어": "KO", "korean": "KO",
|
|
||||||
"русский": "RU", "russian": "RU",
|
|
||||||
"العربية": "AR", "arabic": "AR",
|
|
||||||
"עברית": "HE", "hebrew": "HE",
|
|
||||||
"فارسی": "FA", "persian": "FA",
|
|
||||||
"čeština": "CS",
|
|
||||||
"türkçe": "TR", "turkish": "TR",
|
|
||||||
"Қазақша": "KK", "kazakh": "KK",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_lang_abbreviation(lang_code: str, lang_name: str) -> str:
|
|
||||||
"""Return a 2-letter abbreviation for a language."""
|
|
||||||
lower = lang_name.lower()
|
|
||||||
if lower in LANG_ABBREVIATIONS:
|
|
||||||
return LANG_ABBREVIATIONS[lower]
|
|
||||||
return lang_code[:2].upper()
|
|
||||||
|
|
||||||
|
|
||||||
def escape_cpp_string(s: str) -> List[str]:
|
def escape_cpp_string(s: str) -> List[str]:
|
||||||
r"""
|
r"""
|
||||||
@@ -267,12 +331,12 @@ def escape_cpp_string(s: str) -> List[str]:
|
|||||||
|
|
||||||
if ch == "\\" and i + 1 < len(s):
|
if ch == "\\" and i + 1 < len(s):
|
||||||
nxt = s[i + 1]
|
nxt = s[i + 1]
|
||||||
if nxt in "ntr\"\\":
|
if nxt in 'ntr"\\':
|
||||||
current.append(ch + nxt)
|
current.append(ch + nxt)
|
||||||
i += 2
|
i += 2
|
||||||
elif nxt == "x" and i + 3 < len(s):
|
elif nxt == "x" and i + 3 < len(s):
|
||||||
current.append(s[i : i + 4])
|
current.append(s[i : i + 4])
|
||||||
_flush() # segment break after hex
|
_flush() # segment break after hex
|
||||||
i += 4
|
i += 4
|
||||||
else:
|
else:
|
||||||
current.append("\\\\")
|
current.append("\\\\")
|
||||||
@@ -286,7 +350,7 @@ def escape_cpp_string(s: str) -> List[str]:
|
|||||||
else:
|
else:
|
||||||
for byte in ch.encode("utf-8"):
|
for byte in ch.encode("utf-8"):
|
||||||
current.append(f"\\x{byte:02X}")
|
current.append(f"\\x{byte:02X}")
|
||||||
_flush() # segment break after hex
|
_flush() # segment break after hex
|
||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
# Flush remaining content
|
# Flush remaining content
|
||||||
@@ -321,11 +385,11 @@ def format_cpp_string_literal(segments: List[str], indent: str = " ") -> List
|
|||||||
last_space = -1
|
last_space = -1
|
||||||
idx = 0
|
idx = 0
|
||||||
while idx <= MAX_CONTENT_LEN and idx < len(current):
|
while idx <= MAX_CONTENT_LEN and idx < len(current):
|
||||||
if current[idx] == ' ':
|
if current[idx] == " ":
|
||||||
last_space = idx
|
last_space = idx
|
||||||
|
|
||||||
# Handle escapes to step correctly
|
# Handle escapes to step correctly
|
||||||
if current[idx] == '\\':
|
if current[idx] == "\\":
|
||||||
idx += 2
|
idx += 2
|
||||||
else:
|
else:
|
||||||
idx += 1
|
idx += 1
|
||||||
@@ -340,7 +404,7 @@ def format_cpp_string_literal(segments: List[str], indent: str = " ") -> List
|
|||||||
# No space, forced break at MAX_CONTENT_LEN (or slightly less)
|
# No space, forced break at MAX_CONTENT_LEN (or slightly less)
|
||||||
cut_at = MAX_CONTENT_LEN
|
cut_at = MAX_CONTENT_LEN
|
||||||
# Don't cut in the middle of an escape sequence
|
# Don't cut in the middle of an escape sequence
|
||||||
if current[cut_at - 1] == '\\':
|
if current[cut_at - 1] == "\\":
|
||||||
cut_at -= 1
|
cut_at -= 1
|
||||||
|
|
||||||
lines.append(f'{indent}"{current[:cut_at]}"')
|
lines.append(f'{indent}"{current[:cut_at]}"')
|
||||||
@@ -356,6 +420,7 @@ def format_cpp_string_literal(segments: List[str], indent: str = " ") -> List
|
|||||||
# Character-set computation
|
# Character-set computation
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def compute_character_set(translations: Dict[str, List[str]], lang_index: int) -> str:
|
def compute_character_set(translations: Dict[str, List[str]], lang_index: int) -> str:
|
||||||
"""Return a sorted string of every unique character used in a language."""
|
"""Return a sorted string of every unique character used in a language."""
|
||||||
chars = set()
|
chars = set()
|
||||||
@@ -369,11 +434,13 @@ def compute_character_set(translations: Dict[str, List[str]], lang_index: int) -
|
|||||||
# Code generators
|
# Code generators
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def generate_keys_header(
|
def generate_keys_header(
|
||||||
languages: List[str],
|
languages: List[str],
|
||||||
language_names: List[str],
|
language_names: List[str],
|
||||||
string_keys: List[str],
|
string_keys: List[str],
|
||||||
output_path: str,
|
output_path: str,
|
||||||
|
verbose: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Generate I18nKeys.h."""
|
"""Generate I18nKeys.h."""
|
||||||
lines: List[str] = [
|
lines: List[str] = [
|
||||||
@@ -381,14 +448,15 @@ def generate_keys_header(
|
|||||||
"#include <cstdint>",
|
"#include <cstdint>",
|
||||||
"",
|
"",
|
||||||
"// THIS FILE IS AUTO-GENERATED BY gen_i18n.py. DO NOT EDIT.",
|
"// THIS FILE IS AUTO-GENERATED BY gen_i18n.py. DO NOT EDIT.",
|
||||||
|
"// clang-format off",
|
||||||
"",
|
"",
|
||||||
"// Forward declaration for string arrays",
|
"// Forward declarations for flat string data blobs and offset tables",
|
||||||
"namespace i18n_strings {",
|
"namespace i18n_strings {",
|
||||||
]
|
]
|
||||||
|
|
||||||
for code, name in zip(languages, language_names):
|
for code in languages:
|
||||||
abbrev = get_lang_abbreviation(code, name)
|
lines.append(f"extern const char STRINGS_{code}_DATA[];")
|
||||||
lines.append(f"extern const char* const STRINGS_{abbrev}[];")
|
lines.append(f"extern const uint16_t OFFSETS_{code}[];")
|
||||||
|
|
||||||
lines.append("} // namespace i18n_strings")
|
lines.append("} // namespace i18n_strings")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
@@ -403,6 +471,10 @@ def generate_keys_header(
|
|||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# Extern declarations
|
# Extern declarations
|
||||||
|
lines.append("// Language codes (defined in I18nStrings.cpp)")
|
||||||
|
lines.append("extern const char* const LANGUAGE_CODES[];")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
lines.append("// Language display names (defined in I18nStrings.cpp)")
|
lines.append("// Language display names (defined in I18nStrings.cpp)")
|
||||||
lines.append("extern const char* const LANGUAGE_NAMES[];")
|
lines.append("extern const char* const LANGUAGE_NAMES[];")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
@@ -420,17 +492,28 @@ def generate_keys_header(
|
|||||||
lines.append("};")
|
lines.append("};")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# getStringArray helper
|
# LangStrings struct
|
||||||
lines.append("// Helper function to get string array for a language")
|
lines.append("// Holds a flat string blob and its offset table for one language")
|
||||||
lines.append("inline const char* const* getStringArray(Language lang) {")
|
lines.append("struct LangStrings {")
|
||||||
|
lines.append(" const char* data;")
|
||||||
|
lines.append(" const uint16_t* offsets;")
|
||||||
|
lines.append("};")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# getLanguageStrings helper
|
||||||
|
lines.append("// Helper function to get string data for a language")
|
||||||
|
lines.append("inline LangStrings getLanguageStrings(Language lang) {")
|
||||||
lines.append(" switch (lang) {")
|
lines.append(" switch (lang) {")
|
||||||
for code, name in zip(languages, language_names):
|
for code in languages:
|
||||||
abbrev = get_lang_abbreviation(code, name)
|
|
||||||
lines.append(f" case Language::{code}:")
|
lines.append(f" case Language::{code}:")
|
||||||
lines.append(f" return i18n_strings::STRINGS_{abbrev};")
|
lines.append(
|
||||||
first_abbrev = get_lang_abbreviation(languages[0], language_names[0])
|
f" return {{i18n_strings::STRINGS_{code}_DATA, i18n_strings::OFFSETS_{code}}};"
|
||||||
|
)
|
||||||
|
first_code = languages[0]
|
||||||
lines.append(" default:")
|
lines.append(" default:")
|
||||||
lines.append(f" return i18n_strings::STRINGS_{first_abbrev};")
|
lines.append(
|
||||||
|
f" return {{i18n_strings::STRINGS_{first_code}_DATA, i18n_strings::OFFSETS_{first_code}}};"
|
||||||
|
)
|
||||||
lines.append(" }")
|
lines.append(" }")
|
||||||
lines.append("}")
|
lines.append("}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
@@ -451,9 +534,9 @@ def generate_keys_header(
|
|||||||
key=lambda i: languages[i],
|
key=lambda i: languages[i],
|
||||||
)
|
)
|
||||||
sorted_indices = [english_idx] + rest
|
sorted_indices = [english_idx] + rest
|
||||||
comment_names = ", ".join(language_names[i] for i in sorted_indices)
|
|
||||||
lines.append("// Sorted language indices by code (auto-generated by gen_i18n.py)")
|
lines.append("// Sorted language indices by code (auto-generated by gen_i18n.py)")
|
||||||
lines.append(f"// Order: {comment_names}")
|
for rank, idx in enumerate(sorted_indices):
|
||||||
|
lines.append(f"// {rank:>2}: {languages[idx]:<4} {language_names[idx]}")
|
||||||
lines.append(
|
lines.append(
|
||||||
"constexpr uint8_t SORTED_LANGUAGE_INDICES[] = {"
|
"constexpr uint8_t SORTED_LANGUAGE_INDICES[] = {"
|
||||||
f"{', '.join(str(i) for i in sorted_indices)}"
|
f"{', '.join(str(i) for i in sorted_indices)}"
|
||||||
@@ -463,38 +546,36 @@ def generate_keys_header(
|
|||||||
lines.append(
|
lines.append(
|
||||||
"static_assert(sizeof(SORTED_LANGUAGE_INDICES) / sizeof(SORTED_LANGUAGE_INDICES[0]) == getLanguageCount(),"
|
"static_assert(sizeof(SORTED_LANGUAGE_INDICES) / sizeof(SORTED_LANGUAGE_INDICES[0]) == getLanguageCount(),"
|
||||||
)
|
)
|
||||||
lines.append(
|
lines.append(' "SORTED_LANGUAGE_INDICES size mismatch");')
|
||||||
' "SORTED_LANGUAGE_INDICES size mismatch");'
|
|
||||||
)
|
|
||||||
|
|
||||||
_write_file(output_path, lines)
|
_write_file(output_path, lines, verbose)
|
||||||
|
|
||||||
|
|
||||||
def generate_strings_header(
|
def generate_strings_header(
|
||||||
languages: List[str],
|
languages: List[str],
|
||||||
language_names: List[str],
|
language_names: List[str],
|
||||||
output_path: str,
|
output_path: str,
|
||||||
|
verbose: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Generate I18nStrings.h."""
|
"""Generate I18nStrings.h."""
|
||||||
lines: List[str] = [
|
lines: List[str] = [
|
||||||
"#pragma once",
|
"#pragma once",
|
||||||
'#include <string>',
|
|
||||||
"",
|
|
||||||
'#include "I18nKeys.h"',
|
'#include "I18nKeys.h"',
|
||||||
"",
|
"",
|
||||||
"// THIS FILE IS AUTO-GENERATED BY gen_i18n.py. DO NOT EDIT.",
|
"// THIS FILE IS AUTO-GENERATED BY gen_i18n.py. DO NOT EDIT.",
|
||||||
|
"// clang-format off",
|
||||||
"",
|
"",
|
||||||
"namespace i18n_strings {",
|
"namespace i18n_strings {",
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
|
|
||||||
for code, name in zip(languages, language_names):
|
for code in languages:
|
||||||
abbrev = get_lang_abbreviation(code, name)
|
lines.append(f"extern const char STRINGS_{code}_DATA[];")
|
||||||
lines.append(f"extern const char* const STRINGS_{abbrev}[];")
|
lines.append(f"extern const uint16_t OFFSETS_{code}[];")
|
||||||
|
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("} // namespace i18n_strings")
|
lines.append("} // namespace i18n_strings")
|
||||||
_write_file(output_path, lines)
|
_write_file(output_path, lines, verbose)
|
||||||
|
|
||||||
|
|
||||||
def generate_strings_cpp(
|
def generate_strings_cpp(
|
||||||
@@ -503,15 +584,26 @@ def generate_strings_cpp(
|
|||||||
string_keys: List[str],
|
string_keys: List[str],
|
||||||
translations: Dict[str, List[str]],
|
translations: Dict[str, List[str]],
|
||||||
output_path: str,
|
output_path: str,
|
||||||
|
verbose: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Generate I18nStrings.cpp."""
|
"""Generate I18nStrings.cpp."""
|
||||||
lines: List[str] = [
|
lines: List[str] = [
|
||||||
|
"// THIS FILE IS AUTO-GENERATED BY gen_i18n.py. DO NOT EDIT.",
|
||||||
|
"// clang-format off",
|
||||||
'#include "I18nStrings.h"',
|
'#include "I18nStrings.h"',
|
||||||
"",
|
"",
|
||||||
"// THIS FILE IS AUTO-GENERATED BY gen_i18n.py. DO NOT EDIT.",
|
"#include <cstddef>",
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# LANGUAGE_NAMES array
|
||||||
|
lines.append("// Language codes")
|
||||||
|
lines.append("const char* const LANGUAGE_CODES[] = {")
|
||||||
|
for code in languages:
|
||||||
|
_append_string_entry(lines, code)
|
||||||
|
lines.append("};")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
# LANGUAGE_NAMES array
|
# LANGUAGE_NAMES array
|
||||||
lines.append("// Language display names")
|
lines.append("// Language display names")
|
||||||
lines.append("const char* const LANGUAGE_NAMES[] = {")
|
lines.append("const char* const LANGUAGE_NAMES[] = {")
|
||||||
@@ -529,18 +621,38 @@ def generate_strings_cpp(
|
|||||||
lines.append("};")
|
lines.append("};")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# Per-language string arrays
|
# Per-language flat string blobs and offset tables
|
||||||
lines.append("namespace i18n_strings {")
|
lines.append("namespace i18n_strings {")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
for lang_idx, (code, name) in enumerate(zip(languages, language_names)):
|
for lang_idx, code in enumerate(languages):
|
||||||
abbrev = get_lang_abbreviation(code, name)
|
lang_strings = [translations[key][lang_idx] for key in string_keys]
|
||||||
lines.append(f"const char* const STRINGS_{abbrev}[] = {{")
|
|
||||||
|
|
||||||
for key in string_keys:
|
# Precompute byte offsets (UTF-8 encoded, +1 per string for null terminator)
|
||||||
text = translations[key][lang_idx]
|
offsets: List[int] = []
|
||||||
_append_string_entry(lines, text)
|
current_offset = 0
|
||||||
|
for s in lang_strings:
|
||||||
|
offsets.append(current_offset)
|
||||||
|
current_offset += len(s.encode("utf-8")) + 1
|
||||||
|
if current_offset > 65535:
|
||||||
|
raise ValueError(
|
||||||
|
f"Language {code}: total string data ({current_offset} bytes) "
|
||||||
|
"exceeds uint16_t offset range (65535)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Flat string data blob — all strings concatenated with \0 separators.
|
||||||
|
lines.append(f"const char STRINGS_{code}_DATA[] =")
|
||||||
|
for text in lang_strings:
|
||||||
|
_append_string_data_entry(lines, text)
|
||||||
|
lines.append(";")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Offset table — one uint16_t per StrId
|
||||||
|
lines.append(f"const uint16_t OFFSETS_{code}[] = {{")
|
||||||
|
chunk_size = 12
|
||||||
|
for i in range(0, len(offsets), chunk_size):
|
||||||
|
chunk = offsets[i : i + chunk_size]
|
||||||
|
lines.append(" " + ", ".join(str(o) for o in chunk) + ",")
|
||||||
lines.append("};")
|
lines.append("};")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
@@ -549,25 +661,108 @@ def generate_strings_cpp(
|
|||||||
|
|
||||||
# Compile-time size checks
|
# Compile-time size checks
|
||||||
lines.append("// Compile-time validation of array sizes")
|
lines.append("// Compile-time validation of array sizes")
|
||||||
for code, name in zip(languages, language_names):
|
for code in languages:
|
||||||
abbrev = get_lang_abbreviation(code, name)
|
|
||||||
lines.append(
|
lines.append(
|
||||||
f"static_assert(sizeof(i18n_strings::STRINGS_{abbrev}) "
|
f"static_assert(sizeof(i18n_strings::OFFSETS_{code}) "
|
||||||
f"/ sizeof(i18n_strings::STRINGS_{abbrev}[0]) =="
|
f"/ sizeof(i18n_strings::OFFSETS_{code}[0]) =="
|
||||||
)
|
)
|
||||||
lines.append(" static_cast<size_t>(StrId::_COUNT),")
|
lines.append(" static_cast<size_t>(StrId::_COUNT),")
|
||||||
lines.append(f' "STRINGS_{abbrev} size mismatch");')
|
lines.append(f' "OFFSETS_{code} size mismatch");')
|
||||||
|
|
||||||
_write_file(output_path, lines)
|
_write_file(output_path, lines, verbose)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _append_string_entry(
|
|
||||||
lines: List[str], text: str, comment: str = ""
|
def _print_language_table(
|
||||||
|
language_codes: List[str],
|
||||||
|
language_names: List[str],
|
||||||
|
inherited_sets: List[Set[str]],
|
||||||
|
string_keys: List[str],
|
||||||
|
unused_keys: Set[str],
|
||||||
|
data_sizes: List[int],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Print a per-language summary table."""
|
||||||
|
total = len(string_keys)
|
||||||
|
headers = ("Language", "Code", "Own", "Fallback", "Unused", "Data (B)")
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for code, name, inherited, size in zip(
|
||||||
|
language_codes, language_names, inherited_sets, data_sizes
|
||||||
|
):
|
||||||
|
own = total - len(inherited)
|
||||||
|
fallback = len(inherited)
|
||||||
|
# strings this language translated but the code never calls
|
||||||
|
unused = len(unused_keys - inherited)
|
||||||
|
rows.append((name, code, str(own), str(fallback), str(unused), str(size)))
|
||||||
|
|
||||||
|
# EN first, then alphabetically by ISO code
|
||||||
|
rows.sort(key=lambda r: (0 if r[1] == "EN" else 1, r[1]))
|
||||||
|
|
||||||
|
col_widths = [len(h) for h in headers]
|
||||||
|
for row in rows:
|
||||||
|
for i, cell in enumerate(row):
|
||||||
|
col_widths[i] = max(col_widths[i], len(cell))
|
||||||
|
|
||||||
|
fmt = " ".join(f"{{:<{w}}}" for w in col_widths)
|
||||||
|
sep = " ".join("-" * w for w in col_widths)
|
||||||
|
|
||||||
|
def _safe_print(line: str) -> None:
|
||||||
|
print(
|
||||||
|
line.encode(sys.stdout.encoding or "utf-8", errors="replace").decode(
|
||||||
|
sys.stdout.encoding or "utf-8", errors="replace"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
_safe_print(fmt.format(*headers))
|
||||||
|
_safe_print(sep)
|
||||||
|
for row in rows:
|
||||||
|
_safe_print(fmt.format(*row))
|
||||||
|
used = total - len(unused_keys)
|
||||||
|
total_size = sum(data_sizes)
|
||||||
|
n_lang = len(rows)
|
||||||
|
n_keys = len(string_keys)
|
||||||
|
# Current layout: uint16_t offset table (2 B per string per language)
|
||||||
|
offset_table_size = n_lang * n_keys * 2
|
||||||
|
current_total = total_size + offset_table_size
|
||||||
|
# Previous layout: const char* pointer array (4 B per string per language)
|
||||||
|
old_pointer_table_size = n_lang * n_keys * 4
|
||||||
|
old_total = total_size + old_pointer_table_size
|
||||||
|
saved = old_total - current_total
|
||||||
|
print(
|
||||||
|
f"\n Total: {total} | Used in code: {used} | Never used: {len(unused_keys)}"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" Flash (now): {total_size:>7,} B strings + {offset_table_size:>6,} B offset tables (uint16_t)"
|
||||||
|
f" = {current_total:>7,} B"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" Flash (before): {total_size:>7,} B strings + {old_pointer_table_size:>6,} B pointer tables (ptr32)"
|
||||||
|
f" = {old_total:>7,} B"
|
||||||
|
)
|
||||||
|
print(f" Saved by offset tables: {saved:,} B")
|
||||||
|
|
||||||
|
|
||||||
|
def _append_string_data_entry(lines: List[str], text: str) -> None:
|
||||||
|
"""
|
||||||
|
Escape *text*, append a \\0 null separator, and format as indented C++
|
||||||
|
string literal lines for inclusion in a flat char data array blob.
|
||||||
|
"""
|
||||||
|
segments = escape_cpp_string(text)
|
||||||
|
# Append the null entry separator to the last segment
|
||||||
|
if segments and segments[-1] != "":
|
||||||
|
segments[-1] += "\\0"
|
||||||
|
elif segments:
|
||||||
|
segments[-1] = "\\0"
|
||||||
|
else:
|
||||||
|
segments = ["\\0"]
|
||||||
|
lines.extend(format_cpp_string_literal(segments))
|
||||||
|
|
||||||
|
|
||||||
|
def _append_string_entry(lines: List[str], text: str, comment: str = "") -> None:
|
||||||
"""Escape *text*, format as indented C++ lines, append comma (and optional comment)."""
|
"""Escape *text*, format as indented C++ lines, append comma (and optional comment)."""
|
||||||
segments = escape_cpp_string(text)
|
segments = escape_cpp_string(text)
|
||||||
formatted = format_cpp_string_literal(segments)
|
formatted = format_cpp_string_literal(segments)
|
||||||
@@ -576,21 +771,30 @@ def _append_string_entry(
|
|||||||
lines.extend(formatted)
|
lines.extend(formatted)
|
||||||
|
|
||||||
|
|
||||||
def _write_file(path: str, lines: List[str]) -> None:
|
def _write_file(path: str, lines: List[str], verbose: bool = False) -> None:
|
||||||
with open(path, "w", encoding="utf-8", newline="\n") as f:
|
with open(path, "w", encoding="utf-8", newline="\n") as f:
|
||||||
f.write("\n".join(lines))
|
f.write("\n".join(lines))
|
||||||
f.write("\n")
|
f.write("\n")
|
||||||
print(f"Generated: {path}")
|
if verbose:
|
||||||
|
print(f"Generated: {path}")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Main
|
# Main
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def main(translations_dir=None, output_dir=None) -> None:
|
|
||||||
|
def main(
|
||||||
|
translations_dir: Optional[str] = None,
|
||||||
|
output_dir: Optional[str] = None,
|
||||||
|
src_dirs: Optional[List[str]] = None,
|
||||||
|
strip_unused: bool = False,
|
||||||
|
verbose: bool = False,
|
||||||
|
) -> None:
|
||||||
# Default paths (relative to project root)
|
# Default paths (relative to project root)
|
||||||
default_translations_dir = "lib/I18n/translations"
|
default_translations_dir = "lib/I18n/translations"
|
||||||
default_output_dir = "lib/I18n/"
|
default_output_dir = "lib/I18n/"
|
||||||
|
default_src_dirs = ["src", "lib"]
|
||||||
|
|
||||||
if translations_dir is None or output_dir is None:
|
if translations_dir is None or output_dir is None:
|
||||||
if len(sys.argv) == 3:
|
if len(sys.argv) == 3:
|
||||||
@@ -601,6 +805,8 @@ def main(translations_dir=None, output_dir=None) -> None:
|
|||||||
translations_dir = default_translations_dir
|
translations_dir = default_translations_dir
|
||||||
output_dir = default_output_dir
|
output_dir = default_output_dir
|
||||||
|
|
||||||
|
if src_dirs is None:
|
||||||
|
src_dirs = default_src_dirs
|
||||||
|
|
||||||
if not os.path.isdir(translations_dir):
|
if not os.path.isdir(translations_dir):
|
||||||
print(f"Error: Translations directory not found: {translations_dir}")
|
print(f"Error: Translations directory not found: {translations_dir}")
|
||||||
@@ -610,26 +816,91 @@ def main(translations_dir=None, output_dir=None) -> None:
|
|||||||
print(f"Error: Output directory not found: {output_dir}")
|
print(f"Error: Output directory not found: {output_dir}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
print(f"Reading translations from: {translations_dir}")
|
if verbose:
|
||||||
print(f"Output directory: {output_dir}")
|
print(f"Reading translations from: {translations_dir}")
|
||||||
print()
|
print(f"Output directory: {output_dir}")
|
||||||
|
print()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
languages, language_names, string_keys, translations = load_translations(
|
languages, language_names, string_keys, translations, inherited_sets = (
|
||||||
translations_dir
|
load_translations(translations_dir, verbose)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- Unused-string detection ---
|
||||||
|
scan_dirs = [d for d in src_dirs if os.path.isdir(d)]
|
||||||
|
if scan_dirs:
|
||||||
|
used_keys = find_used_string_keys(scan_dirs)
|
||||||
|
unused_set = set(report_unused_keys(string_keys, used_keys))
|
||||||
|
else:
|
||||||
|
used_keys = set(string_keys)
|
||||||
|
unused_set = set()
|
||||||
|
|
||||||
|
# --- Missing-string detection (used in code but absent from English) ---
|
||||||
|
missing_keys = sorted(used_keys - set(string_keys))
|
||||||
|
if missing_keys:
|
||||||
|
print(
|
||||||
|
f"\n CRITICAL: {len(missing_keys)} string(s) used in source but missing from english.yaml:"
|
||||||
|
)
|
||||||
|
for key in missing_keys:
|
||||||
|
print(f" - {key}")
|
||||||
|
print()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Compute per-language data blob sizes:
|
||||||
|
# sum of UTF-8 byte length + 1 (null terminator) per string
|
||||||
|
data_sizes = [
|
||||||
|
sum(len(translations[k][i].encode("utf-8")) + 1 for k in string_keys)
|
||||||
|
for i in range(len(languages))
|
||||||
|
]
|
||||||
|
|
||||||
|
_print_language_table(
|
||||||
|
languages,
|
||||||
|
language_names,
|
||||||
|
inherited_sets,
|
||||||
|
string_keys,
|
||||||
|
unused_set,
|
||||||
|
data_sizes,
|
||||||
|
)
|
||||||
|
print()
|
||||||
|
|
||||||
|
if verbose and unused_set:
|
||||||
|
print(f" Unused keys ({len(unused_set)}):")
|
||||||
|
for key in sorted(unused_set):
|
||||||
|
print(f" - {key}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if unused_set and strip_unused:
|
||||||
|
string_keys = [k for k in string_keys if k not in unused_set]
|
||||||
|
translations = {
|
||||||
|
k: v for k, v in translations.items() if k not in unused_set
|
||||||
|
}
|
||||||
|
inherited_sets = [s - unused_set for s in inherited_sets]
|
||||||
|
print(f" Stripping {len(unused_set)} unused string(s) from output.")
|
||||||
|
|
||||||
out = Path(output_dir)
|
out = Path(output_dir)
|
||||||
generate_keys_header(languages, language_names, string_keys, str(out / "I18nKeys.h"))
|
generate_keys_header(
|
||||||
generate_strings_header(languages, language_names, str(out / "I18nStrings.h"))
|
languages, language_names, string_keys, str(out / "I18nKeys.h"), verbose
|
||||||
|
)
|
||||||
|
generate_strings_header(
|
||||||
|
languages, language_names, str(out / "I18nStrings.h"), verbose
|
||||||
|
)
|
||||||
generate_strings_cpp(
|
generate_strings_cpp(
|
||||||
languages, language_names, string_keys, translations, str(out / "I18nStrings.cpp")
|
languages,
|
||||||
|
language_names,
|
||||||
|
string_keys,
|
||||||
|
translations,
|
||||||
|
str(out / "I18nStrings.cpp"),
|
||||||
|
verbose,
|
||||||
)
|
)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("✓ Code generation complete!")
|
print("Code generation complete!")
|
||||||
print(f" Languages: {len(languages)}")
|
print(f" Languages: {len(languages)}")
|
||||||
print(f" String keys: {len(string_keys)}")
|
print(f" String keys: {len(string_keys)}")
|
||||||
|
if unused_set and not strip_unused:
|
||||||
|
print(
|
||||||
|
f" Unused keys: {len(unused_set)} (pass --strip-unused to remove them)"
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"\nError: {e}")
|
print(f"\nError: {e}")
|
||||||
@@ -637,11 +908,52 @@ def main(translations_dir=None, output_dir=None) -> None:
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Generate I18n C++ files from per-language YAML translations."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"translations_dir",
|
||||||
|
nargs="?",
|
||||||
|
default=None,
|
||||||
|
help="Path to the translations directory (default: lib/I18n/translations)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"output_dir",
|
||||||
|
nargs="?",
|
||||||
|
default=None,
|
||||||
|
help="Path to the output directory (default: lib/I18n/)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--src-dirs",
|
||||||
|
nargs="+",
|
||||||
|
metavar="DIR",
|
||||||
|
default=None,
|
||||||
|
help="Source directories to scan for STR_* usage (default: src lib)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--strip-unused",
|
||||||
|
action="store_true",
|
||||||
|
help="Remove unused STR_* keys from the generated output",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--verbose",
|
||||||
|
"-v",
|
||||||
|
action="store_true",
|
||||||
|
help="Print per-key INFO/WARNING messages and file generation details",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
main(
|
||||||
|
args.translations_dir,
|
||||||
|
args.output_dir,
|
||||||
|
args.src_dirs,
|
||||||
|
args.strip_unused,
|
||||||
|
args.verbose,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
Import("env")
|
Import("env")
|
||||||
print("Running i18n generation script from PlatformIO...")
|
main(strip_unused=True)
|
||||||
main()
|
|
||||||
except NameError:
|
except NameError:
|
||||||
pass
|
pass
|
||||||
|
|||||||
Reference in New Issue
Block a user