Skip to main content

futu_core/
localization.rs

1//! C++ OpenD-compatible language-pack lookup.
2//!
3//! C++ loads `Resources/Languages/{zh_cn,en}/*.ini` at GUI startup and then
4//! resolves `FTStringDefine.StringID` through `core::lang::tr(...)`.
5//! Refs:
6//! - FutuOpenD/Src/FTGatewayGui/Application.cpp:66-99
7//! - FutuOpenD/Src/NNProtoCenter/Basic/MultiLanguage/FTQueryLanguage.cpp:285-334
8//! - FutuOpenD/Src/NNProtoCenter/Basic/MultiLanguage/FTQueryLanguage.cpp:380-401
9
10use std::collections::BTreeMap;
11use std::io::Read;
12use std::sync::OnceLock;
13
14const EN_API_LANG_GZ: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/lang_en_api.ini.gz"));
15const EN_STATIC_LANG_GZ: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/lang_en_static.ini.gz"));
16const ZH_CN_API_LANG_GZ: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/lang_zh_cn_api.ini.gz"));
17const ZH_CN_STATIC_LANG_GZ: &[u8] =
18    include_bytes!(concat!(env!("OUT_DIR"), "/lang_zh_cn_static.ini.gz"));
19
20/// C++ language ids that are actually accepted by `FTQueryLanguage`.
21///
22/// `LanguageID_HK=1` exists in C++ headers, but `CheckLanguageIDInternal` only
23/// accepts Simplified Chinese and English, so `1` normalizes to English.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
25pub enum LanguageId {
26    SimplifiedChinese = 0,
27    English = 2,
28}
29
30/// Normalize an OpenD app language id the same way C++ `FTQueryLanguage` does.
31pub fn normalize_language_id(language_id: i32) -> LanguageId {
32    match language_id {
33        0 => LanguageId::SimplifiedChinese,
34        2 => LanguageId::English,
35        _ => LanguageId::English,
36    }
37}
38
39#[derive(Debug, Clone, Default)]
40pub struct LanguageStore {
41    texts: BTreeMap<LanguageId, BTreeMap<u32, String>>,
42}
43
44impl LanguageStore {
45    pub fn new() -> Self {
46        Self::default()
47    }
48
49    /// Load a C++ language `.ini` text file.
50    ///
51    /// Lines shaped as `numeric_key=value` start or replace one entry. A line
52    /// without a numeric key is appended to the previous entry with a newline,
53    /// matching C++ multiline behavior.
54    pub fn load_ini(&mut self, language: LanguageId, text: &str) {
55        let mut last_key: Option<u32> = None;
56        for raw_line in text.split('\n') {
57            let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
58            if let Some((key_text, value)) = line.split_once('=')
59                && let Ok(key) = key_text.parse::<u32>()
60            {
61                self.texts
62                    .entry(language)
63                    .or_default()
64                    .insert(key, decode_escaped_text(value));
65                last_key = Some(key);
66                continue;
67            }
68
69            if let Some(key) = last_key
70                && let Some(existing) = self
71                    .texts
72                    .get_mut(&language)
73                    .and_then(|texts| texts.get_mut(&key))
74            {
75                existing.push('\n');
76                existing.push_str(line);
77            }
78        }
79    }
80
81    pub fn translate_key(
82        &self,
83        language: LanguageId,
84        key: u32,
85        fallback: Option<&str>,
86        allow_english_fallback: bool,
87    ) -> Option<String> {
88        if let Some(text) = self.find_non_empty(language, key) {
89            return Some(text.to_string());
90        }
91        if allow_english_fallback
92            && language != LanguageId::English
93            && let Some(text) = self.find_non_empty(LanguageId::English, key)
94        {
95            return Some(text.to_string());
96        }
97        fallback.map(ToOwned::to_owned)
98    }
99
100    pub fn translate_key_with_template(
101        &self,
102        language: LanguageId,
103        key: u32,
104        fallback: Option<&str>,
105        allow_english_fallback: bool,
106        template: &BTreeMap<String, String>,
107    ) -> Option<String> {
108        let text = self.translate_key(language, key, fallback, allow_english_fallback)?;
109        Some(replace_placeholders(text, template))
110    }
111
112    fn find_non_empty(&self, language: LanguageId, key: u32) -> Option<&str> {
113        self.texts
114            .get(&language)
115            .and_then(|texts| texts.get(&key))
116            .map(String::as_str)
117            .filter(|value| !value.is_empty())
118    }
119}
120
121pub fn translate_embedded_key(language: LanguageId, key: u32) -> Option<String> {
122    if let Some(text) = embedded_store(language).translate_key(language, key, None, false) {
123        return Some(text);
124    }
125    if language != LanguageId::English {
126        return embedded_store(LanguageId::English).translate_key(
127            LanguageId::English,
128            key,
129            None,
130            false,
131        );
132    }
133    None
134}
135
136pub fn translate_embedded_key_with_template(
137    language: LanguageId,
138    key: u32,
139    template: &BTreeMap<String, String>,
140) -> Option<String> {
141    if let Some(text) =
142        embedded_store(language).translate_key_with_template(language, key, None, false, template)
143    {
144        return Some(text);
145    }
146    if language != LanguageId::English {
147        return embedded_store(LanguageId::English).translate_key_with_template(
148            LanguageId::English,
149            key,
150            None,
151            false,
152            template,
153        );
154    }
155    None
156}
157
158fn embedded_store(language: LanguageId) -> &'static LanguageStore {
159    static ZH_CN_STORE: OnceLock<LanguageStore> = OnceLock::new();
160    static EN_STORE: OnceLock<LanguageStore> = OnceLock::new();
161
162    match language {
163        LanguageId::SimplifiedChinese => {
164            ZH_CN_STORE.get_or_init(|| load_embedded_store(LanguageId::SimplifiedChinese))
165        }
166        LanguageId::English => EN_STORE.get_or_init(|| load_embedded_store(LanguageId::English)),
167    }
168}
169
170fn load_embedded_store(language: LanguageId) -> LanguageStore {
171    let mut store = LanguageStore::new();
172    // C++ enumerates `*.ini` by filename; `api_lang.ini` sorts before
173    // `static_lang.ini`, so static_lang can override duplicate keys.
174    match language {
175        LanguageId::SimplifiedChinese => {
176            load_compressed_ini(&mut store, LanguageId::SimplifiedChinese, ZH_CN_API_LANG_GZ);
177            load_compressed_ini(
178                &mut store,
179                LanguageId::SimplifiedChinese,
180                ZH_CN_STATIC_LANG_GZ,
181            );
182        }
183        LanguageId::English => {
184            load_compressed_ini(&mut store, LanguageId::English, EN_API_LANG_GZ);
185            load_compressed_ini(&mut store, LanguageId::English, EN_STATIC_LANG_GZ);
186        }
187    }
188    store
189}
190
191fn load_compressed_ini(store: &mut LanguageStore, language: LanguageId, bytes: &[u8]) {
192    let mut decoder = flate2::read::GzDecoder::new(bytes);
193    let mut text = String::new();
194    if let Err(error) = decoder.read_to_string(&mut text) {
195        tracing::error!(
196            ?language,
197            %error,
198            "embedded C++ language pack decode failed; skipping this pack"
199        );
200        return;
201    }
202    store.load_ini(language, &text);
203}
204
205fn decode_escaped_text(text: &str) -> String {
206    let mut decoded = String::with_capacity(text.len());
207    let mut chars = text.chars();
208    while let Some(ch) = chars.next() {
209        if ch != '\\' {
210            decoded.push(ch);
211            continue;
212        }
213
214        match chars.next() {
215            Some('r') => decoded.push('\r'),
216            Some('n') => decoded.push('\n'),
217            Some('t') => decoded.push('\t'),
218            Some('\\') => decoded.push('\\'),
219            Some(other) => {
220                decoded.push('\\');
221                decoded.push(other);
222            }
223            None => decoded.push('\\'),
224        }
225    }
226    decoded
227}
228
229fn replace_placeholders(mut text: String, template: &BTreeMap<String, String>) -> String {
230    for (key, value) in template {
231        text = text.replace(&format!("{{{{{key}}}}}"), value);
232        text = text.replace(&format!("{{{key}}}"), value);
233    }
234    text
235}
236
237#[cfg(test)]
238mod tests;