veloren_client_i18n/
lib.rs

1mod error;
2mod raw;
3
4use error::ResourceErr;
5
6#[cfg(any(feature = "bin", feature = "stat", test))]
7pub mod analysis;
8
9use fluent_bundle::{FluentResource, bundle::FluentBundle};
10use intl_memoizer::concurrent::IntlLangMemoizer;
11use unic_langid::LanguageIdentifier;
12
13use deunicode::deunicode;
14
15use hashbrown::HashMap;
16use serde::{Deserialize, Serialize};
17use std::{borrow::Cow, io};
18
19use assets::{
20    AssetExt, AssetHandle, AssetReadGuard, ReloadWatcher, SharedString, source::DirEntry,
21};
22use common_assets as assets;
23use common_i18n::{Content, LocalizationArg};
24use tracing::warn;
25
26// Re-export for argument creation
27pub use fluent::{FluentValue, fluent_args};
28pub use fluent_bundle::FluentArgs;
29
30/// The reference language, aka the more up-to-date localization data.
31/// Also the default language at first startup.
32pub const REFERENCE_LANG: &str = "en";
33
34/// How a language can be described
35#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
36pub struct LanguageMetadata {
37    /// A human friendly language name (e.g. "English (US)")
38    pub language_name: String,
39
40    /// A short text identifier for this language (e.g. "en_US")
41    ///
42    /// On the opposite of `language_name` that can change freely,
43    /// `language_identifier` value shall be stable in time as it
44    /// is used by setting components to store the language
45    /// selected by the user.
46    pub language_identifier: String,
47}
48
49/// Store font metadata
50#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
51pub struct Font {
52    /// Key to retrieve the font in the asset system
53    pub asset_key: String,
54
55    /// Scale ratio to resize the UI text dynamically
56    scale_ratio: f32,
57}
58
59impl Font {
60    /// Scale input size to final UI size
61    #[must_use]
62    pub fn scale(&self, value: u32) -> u32 { (value as f32 * self.scale_ratio).round() as u32 }
63}
64
65/// Store font metadata
66pub type Fonts = HashMap<String, Font>;
67
68/// Store internationalization data
69struct Language {
70    /// The bundle storing all localized texts
71    pub(crate) bundle: FluentBundle<FluentResource, IntlLangMemoizer>,
72
73    /// Font configuration is stored here
74    pub(crate) fonts: Fonts,
75    pub(crate) metadata: LanguageMetadata,
76    pub(crate) convert_utf8_to_ascii: bool,
77}
78
79impl Language {
80    fn try_msg<'a>(&'a self, key: &str, args: Option<&'a FluentArgs>) -> Option<Cow<'a, str>> {
81        let bundle = &self.bundle;
82        let msg = bundle.get_message(key)?;
83        let mut errs = Vec::new();
84        let msg = bundle.format_pattern(msg.value()?, args, &mut errs);
85        for err in errs {
86            tracing::error!("err: {err} for {key}");
87        }
88
89        let msg = if self.convert_utf8_to_ascii {
90            deunicode(&msg).into()
91        } else {
92            msg
93        };
94
95        Some(msg)
96    }
97
98    fn try_attr<'a>(
99        &'a self,
100        key: &str,
101        attr: &str,
102        args: Option<&'a FluentArgs>,
103    ) -> Option<Cow<'a, str>> {
104        let bundle = &self.bundle;
105        let msg = bundle.get_message(key)?;
106        let attr = msg.get_attribute(attr)?;
107        let attr = attr.value();
108
109        let mut errs = Vec::new();
110        let msg = bundle.format_pattern(attr, args, &mut errs);
111        for err in errs {
112            tracing::error!("err: {err} for {key}");
113        }
114
115        let msg = if self.convert_utf8_to_ascii {
116            deunicode(&msg).into()
117        } else {
118            msg
119        };
120
121        Some(msg)
122    }
123
124    /// NOTE: Exists for legacy reasons, avoid.
125    // Read more in the issue on get_variation at Gitlab
126    fn try_variation<'a>(
127        &'a self,
128        key: &str,
129        seed: u16,
130        args: Option<&'a FluentArgs>,
131    ) -> Option<Cow<'a, str>> {
132        let bundle = &self.bundle;
133        let msg = bundle.get_message(key)?;
134        let mut attrs = msg.attributes();
135
136        let mut errs = Vec::new();
137
138        let msg = if attrs.len() != 0 {
139            let idx = usize::from(seed) % attrs.len();
140            // unwrap is ok here, because idx is bound to attrs.len()
141            // by using modulo operator.
142            //
143            // For example:
144            // (I)
145            // * attributes = [.x = 5, .y = 7, z. = 4]
146            // * len = 3
147            // * seed can be 12, 50, 1
148            // 12 % 3 = 0, attrs.skip(0) => first element
149            // 50 % 3 = 2, attrs.skip(2) => third element
150            // 1 % 3 = 1, attrs.skip(1) => second element
151            // (II)
152            // * attributes = []
153            // * len = 0
154            // * no matter what seed is, we return None in code above
155            let variation = attrs.nth(idx).unwrap();
156            bundle.format_pattern(variation.value(), args, &mut errs)
157        } else {
158            // Fall back to single message if there are no attributes
159            bundle.format_pattern(msg.value()?, args, &mut errs)
160        };
161
162        for err in errs {
163            tracing::error!("err: {err} for {key}");
164        }
165
166        let msg = if self.convert_utf8_to_ascii {
167            deunicode(&msg).into()
168        } else {
169            msg
170        };
171
172        Some(msg)
173    }
174}
175impl assets::Compound for Language {
176    fn load(cache: assets::AnyCache, path: &SharedString) -> Result<Self, assets::BoxedError> {
177        let manifest = cache
178            .load::<raw::Manifest>(&[path, ".", "_manifest"].concat())?
179            .cloned();
180        let raw::Manifest {
181            convert_utf8_to_ascii,
182            fonts,
183            metadata,
184        } = manifest;
185
186        let lang_id: LanguageIdentifier = metadata.language_identifier.parse()?;
187        let mut bundle = FluentBundle::new_concurrent(vec![lang_id]);
188
189        // Here go dragons
190        for id in cache.load_rec_dir::<raw::Resource>(path)?.read().ids() {
191            match cache.load(id) {
192                Ok(handle) => {
193                    let source: &raw::Resource = &handle.read();
194                    let src = source.src.clone();
195
196                    let resource = FluentResource::try_new(src).map_err(|(_ast, errs)| {
197                        ResourceErr::parsing_error(errs, id.to_string(), &source.src)
198                    })?;
199
200                    bundle
201                        .add_resource(resource)
202                        .map_err(|e| ResourceErr::BundleError(format!("{e:?}")))?;
203                },
204                Err(err) => {
205                    // TODO: shouldn't we just panic here?
206                    warn!("Unable to load asset {id}, error={err:?}");
207                },
208            }
209        }
210
211        // NOTE:
212        // Basically a hack, but conrod can't use isolation marks yet.
213        // Veloren Issue 1649
214        bundle.set_use_isolating(false);
215
216        Ok(Self {
217            bundle,
218            fonts,
219            metadata,
220            convert_utf8_to_ascii,
221        })
222    }
223}
224
225/// The central data structure to handle localization in Veloren
226// inherit Copy + Clone from AssetHandle (what?)
227#[derive(Copy, Clone)]
228pub struct LocalizationHandle {
229    active: AssetHandle<Language>,
230    watcher: ReloadWatcher,
231    fallback: Option<AssetHandle<Language>>,
232    pub use_english_fallback: bool,
233}
234
235/// Read [`LocalizationGuard`]
236// arbitrary choice to minimize changing all of veloren
237pub type Localization = LocalizationGuard;
238
239/// RAII guard returned from [`LocalizationHandle::read()`], resembles
240/// [`AssetGuard`]
241pub struct LocalizationGuard {
242    active: AssetReadGuard<Language>,
243    fallback: Option<AssetReadGuard<Language>>,
244}
245
246impl LocalizationGuard {
247    /// Get a localized text from the given key in the fallback language.
248    pub fn try_fallback_msg(&self, key: &str) -> Option<Cow<str>> {
249        self.fallback.as_ref().and_then(|fb| fb.try_msg(key, None))
250    }
251
252    /// Get a localized text from the given key
253    ///
254    /// First lookup is done in the active language, second in
255    /// the fallback (if present).
256    pub fn try_msg(&self, key: &str) -> Option<Cow<str>> {
257        self.active
258            .try_msg(key, None)
259            .or_else(|| self.try_fallback_msg(key))
260    }
261
262    /// Get a localized text from the given key
263    ///
264    /// First lookup is done in the active language, second in
265    /// the fallback (if present).
266    /// If the key is not present in the localization object
267    /// then the key itself is returned.
268    pub fn get_msg(&self, key: &str) -> Cow<str> {
269        // NOTE: we clone the key if translation was missing
270        // We could use borrowed version, but it would mean that
271        // `key`, `self`, and result should have the same lifetime.
272        // Which would make it way more awkward to use with runtime generated keys.
273        self.try_msg(key)
274            .unwrap_or_else(|| Cow::Owned(key.to_owned()))
275    }
276
277    /// Get a localized text from the given key using given arguments
278    ///
279    /// First lookup is done in the active language, second in
280    /// the fallback (if present).
281    pub fn try_msg_ctx<'a>(&'a self, key: &str, args: &'a FluentArgs) -> Option<Cow<'static, str>> {
282        // NOTE: as after using args we get our result owned (because you need
283        // to clone pattern during forming value from args), this conversion
284        // to Cow::Owned is no-op.
285        // We could use String here, but using Cow everywhere in i18n API is
286        // prefered for consistency.
287        self.active
288            .try_msg(key, Some(args))
289            .or_else(|| {
290                self.fallback
291                    .as_ref()
292                    .and_then(|fb| fb.try_msg(key, Some(args)))
293            })
294            .map(|res| Cow::Owned(res.into_owned()))
295    }
296
297    /// Get a localized text from the given key using given arguments
298    ///
299    /// First lookup is done in the active language, second in
300    /// the fallback (if present).
301    /// If the key is not present in the localization object
302    /// then the key itself is returned.
303    pub fn get_msg_ctx<'a>(&'a self, key: &str, args: &'a FluentArgs) -> Cow<'static, str> {
304        self.try_msg_ctx(key, args)
305            .unwrap_or_else(|| Cow::Owned(key.to_owned()))
306    }
307
308    /// NOTE: Exists for legacy reasons, avoid.
309    ///
310    /// Get a localized text from the variation of given key
311    ///
312    /// First lookup is done in the active language, second in
313    /// the fallback (if present).
314    // Read more in the issue on get_variation at Gitlab
315    pub fn try_variation(&self, key: &str, seed: u16) -> Option<Cow<str>> {
316        self.active.try_variation(key, seed, None).or_else(|| {
317            self.fallback
318                .as_ref()
319                .and_then(|fb| fb.try_variation(key, seed, None))
320        })
321    }
322
323    /// NOTE: Exists for legacy reasons, avoid.
324    ///
325    /// Get a localized text from the variation of given key
326    ///
327    /// First lookup is done in the active language, second in
328    /// the fallback (if present).
329    /// If the key is not present in the localization object
330    /// then the key itself is returned.
331    // Read more in the issue on get_variation at Gitlab
332    pub fn get_variation(&self, key: &str, seed: u16) -> Cow<str> {
333        self.try_variation(key, seed)
334            .unwrap_or_else(|| Cow::Owned(key.to_owned()))
335    }
336
337    /// NOTE: Exists for legacy reasons, avoid.
338    ///
339    /// Get a localized text from the variation of given key with given
340    /// arguments
341    ///
342    /// First lookup is done in the active language, second in
343    /// the fallback (if present).
344    // Read more in the issue on get_variation at Gitlab
345    pub fn try_variation_ctx<'a>(
346        &'a self,
347        key: &str,
348        seed: u16,
349        args: &'a FluentArgs,
350    ) -> Option<Cow<'a, str>> {
351        self.active
352            .try_variation(key, seed, Some(args))
353            .or_else(|| {
354                self.fallback
355                    .as_ref()
356                    .and_then(|fb| fb.try_variation(key, seed, Some(args)))
357            })
358    }
359
360    // Function to localize content for given language.
361    //
362    // Returns Ok(localized_text) if found no errors.
363    // Returns Err(broken_text) on failure.
364    //
365    // broken_text will have i18n keys in it, just i18n key if it was instant miss
366    // or text with missed keys inlined if it was missed down the chain.
367    fn get_content_for_lang(lang: &Language, content: &Content) -> Result<String, String> {
368        match content {
369            Content::Plain(text) => Ok(text.clone()),
370            Content::Key(key) => lang
371                .try_msg(key, None)
372                .map(Cow::into_owned)
373                .ok_or_else(|| key.to_string()),
374            Content::Attr(key, attr) => lang
375                .try_attr(key, attr, None)
376                .map(Cow::into_owned)
377                .ok_or_else(|| format!("{key}.{attr}")),
378            Content::Localized { key, seed, args } => {
379                // flag to detect failure down the chain
380                let mut is_arg_failure = false;
381
382                let mut fargs = FluentArgs::new();
383                for (k, arg) in args {
384                    let arg_val = match arg {
385                        LocalizationArg::Content(content) => {
386                            let arg_res = Self::get_content_for_lang(lang, content)
387                                .unwrap_or_else(|broken_text| {
388                                    is_arg_failure = true;
389                                    broken_text
390                                })
391                                .into();
392
393                            FluentValue::String(arg_res)
394                        },
395                        LocalizationArg::Nat(n) => FluentValue::from(n),
396                    };
397                    fargs.set(k, arg_val);
398                }
399
400                lang.try_variation(key, *seed, Some(&fargs))
401                    .map(Cow::into_owned)
402                    .ok_or_else(|| key.clone())
403                    .and_then(|text| if is_arg_failure { Err(text) } else { Ok(text) })
404            },
405        }
406    }
407
408    /// Tries its best to localize compound message.
409    ///
410    /// # Example
411    /// ```text
412    /// Content::Localized { "npc-speech-tell_site", seed, {
413    ///     "dir" => Content::Localized("npc-speech-dir_north", seed, {})
414    ///     "dist" => Content::Localized("npc-speech-dist_very_far", seed, {})
415    ///     "site" => Content::Plain(site)
416    /// }}
417    /// ```
418    /// ```fluent
419    /// npc-speech-tell_site =
420    ///    .a0 = Have you visited { $site }? It's just { $dir } of here!
421    ///    .a1 = You should visit { $site } some time.
422    ///    .a2 = If you travel { $dist } to the { $dir }, you can get to { $site }.
423    ///    .a3 = To the { $dir } you'll find { $site }, it's { $dist }.
424    ///
425    /// npc-speech-dir_north = north
426    /// # ... other keys
427    ///
428    /// npc-speech-dist_very_far = very far away
429    /// # ... other keys
430    /// ```
431    ///
432    /// 1) Because content we want is localized itself and has arguments, we
433    ///    iterate over them and localize, recursively. Having that, we localize
434    ///    our content.
435    /// 2) Now there is a chance that some of args have missing internalization.
436    ///    In that case, we insert arg name as placeholder and mark it as
437    ///    broken. Then we repeat *whole* procedure on fallback language if we
438    ///    have it.
439    /// 3) Otherwise, return result from (1).
440    // NOTE: it's important that we only use one language at the time, because
441    // otherwise we will get partially-translated message.
442    //
443    // TODO: return Cow<str>?
444    pub fn get_content(&self, content: &Content) -> String {
445        match Self::get_content_for_lang(&self.active, content) {
446            Ok(text) => text,
447            // If localisation or some part of it failed, repeat with fallback.
448            // If it did fail as well, it's probably because fallback was disabled,
449            // so we don't have better option other than returning broken text
450            // we produced earlier.
451            Err(broken_text) => self
452                .fallback
453                .as_ref()
454                .and_then(|fb| Self::get_content_for_lang(fb, content).ok())
455                .unwrap_or(broken_text),
456        }
457    }
458
459    pub fn get_content_fallback(&self, content: &Content) -> String {
460        self.fallback
461            .as_ref()
462            .map(|fb| Self::get_content_for_lang(fb, content))
463            .transpose()
464            .map(|msg| msg.unwrap_or_default())
465            .unwrap_or_else(|e| e)
466    }
467
468    /// NOTE: Exists for legacy reasons, avoid.
469    ///
470    /// Get a localized text from the variation of given key with given
471    /// arguments
472    ///
473    /// First lookup is done in the active language, second in
474    /// the fallback (if present).
475    /// If the key is not present in the localization object
476    /// then the key itself is returned.
477    // Read more in the issue on get_variation at Gitlab
478    pub fn get_variation_ctx<'a>(
479        &'a self,
480        key: &str,
481        seed: u16,
482        args: &'a FluentArgs,
483    ) -> Cow<'a, str> {
484        self.try_variation_ctx(key, seed, args)
485            .unwrap_or_else(|| Cow::Owned(key.to_owned()))
486    }
487
488    /// Get a localized text from the given key by given attribute
489    ///
490    /// First lookup is done in the active language, second in
491    /// the fallback (if present).
492    pub fn try_attr(&self, key: &str, attr: &str) -> Option<Cow<str>> {
493        self.active.try_attr(key, attr, None).or_else(|| {
494            self.fallback
495                .as_ref()
496                .and_then(|fb| fb.try_attr(key, attr, None))
497        })
498    }
499
500    /// Get a localized text from the given key by given attribute
501    ///
502    /// First lookup is done in the active language, second in
503    /// the fallback (if present).
504    /// If the key is not present in the localization object
505    /// then the key itself is returned.
506    pub fn get_attr(&self, key: &str, attr: &str) -> Cow<str> {
507        self.try_attr(key, attr)
508            .unwrap_or_else(|| Cow::Owned(format!("{key}.{attr}")))
509    }
510
511    /// Get a localized text from the given key by given attribute and arguments
512    ///
513    /// First lookup is done in the active language, second in
514    /// the fallback (if present).
515    pub fn try_attr_ctx(
516        &self,
517        key: &str,
518        attr: &str,
519        args: &FluentArgs,
520    ) -> Option<Cow<'static, str>> {
521        // NOTE: we explicitly Own result, because in 99.999% cases it got
522        // owned during formatting of arguments, hence it's a no-op, but makes
523        // using this function much easier
524        self.active
525            .try_attr(key, attr, Some(args))
526            .or_else(|| {
527                self.fallback
528                    .as_ref()
529                    .and_then(|fb| fb.try_attr(key, attr, Some(args)))
530            })
531            .map(|res| Cow::Owned(res.into_owned()))
532    }
533
534    /// Get a localized text from the given key by given attribute and arguments
535    ///
536    /// First lookup is done in the active language, second in
537    /// the fallback (if present).
538    /// If the key is not present in the localization object
539    /// then the key itself is returned.
540    pub fn get_attr_ctx(&self, key: &str, attr: &str, args: &FluentArgs) -> Cow<'static, str> {
541        self.try_attr_ctx(key, attr, args)
542            .unwrap_or_else(|| Cow::Owned(format!("{key}.{attr}")))
543    }
544
545    #[must_use]
546    pub fn fonts(&self) -> &Fonts { &self.active.fonts }
547
548    #[must_use]
549    pub fn metadata(&self) -> &LanguageMetadata { &self.active.metadata }
550}
551
552impl LocalizationHandle {
553    pub fn set_english_fallback(&mut self, use_english_fallback: bool) {
554        self.use_english_fallback = use_english_fallback;
555    }
556
557    #[must_use]
558    pub fn read(&self) -> LocalizationGuard {
559        LocalizationGuard {
560            active: self.active.read(),
561            fallback: if self.use_english_fallback {
562                self.fallback.map(|f| f.read())
563            } else {
564                None
565            },
566        }
567    }
568
569    /// # Errors
570    /// Returns error if active of fallback language can't be loaded
571    pub fn load(specifier: &str) -> Result<Self, assets::Error> {
572        let default_key = ["voxygen.i18n.", REFERENCE_LANG].concat();
573        let language_key = ["voxygen.i18n.", specifier].concat();
574        let is_default = language_key == default_key;
575        let active = Language::load(&language_key)?;
576        Ok(Self {
577            active,
578            watcher: active.reload_watcher(),
579            fallback: if is_default {
580                None
581            } else {
582                Some(Language::load(&default_key)?)
583            },
584            use_english_fallback: false,
585        })
586    }
587
588    #[must_use]
589    pub fn load_expect(specifier: &str) -> Self {
590        Self::load(specifier).expect("Can't load language files")
591    }
592
593    pub fn reloaded(&mut self) -> bool { self.watcher.reloaded() }
594}
595
596struct FindManifests;
597
598impl assets::DirLoadable for FindManifests {
599    fn select_ids(
600        cache: assets::AnyCache,
601        specifier: &SharedString,
602    ) -> io::Result<Vec<SharedString>> {
603        use assets::Source;
604
605        let mut specifiers = Vec::new();
606
607        let source = cache.raw_source();
608        source.read_dir(specifier, &mut |entry| {
609            if let DirEntry::Directory(spec) = entry {
610                let manifest_spec = [spec, ".", "_manifest"].concat();
611
612                if source.exists(DirEntry::File(&manifest_spec, "ron")) {
613                    specifiers.push(manifest_spec.into());
614                }
615            }
616        })?;
617
618        Ok(specifiers)
619    }
620}
621
622#[derive(Clone, Debug)]
623struct LocalizationList(Vec<LanguageMetadata>);
624
625impl assets::Compound for LocalizationList {
626    fn load(cache: assets::AnyCache, specifier: &SharedString) -> Result<Self, assets::BoxedError> {
627        // List language directories
628        let languages = assets::load_rec_dir::<FindManifests>(specifier)
629            .unwrap_or_else(|e| panic!("Failed to get manifests from {}: {:?}", specifier, e))
630            .read()
631            .ids()
632            .filter_map(|spec| cache.load::<raw::Manifest>(spec).ok())
633            .map(|localization| localization.read().metadata.clone())
634            .collect();
635
636        Ok(LocalizationList(languages))
637    }
638}
639
640/// Load all the available languages located in the voxygen asset directory
641#[must_use]
642pub fn list_localizations() -> Vec<LanguageMetadata> {
643    let LocalizationList(list) = LocalizationList::load_expect_cloned("voxygen.i18n");
644    list
645}
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650
651    #[test]
652    // Test that localization list is loaded (not empty)
653    fn check_localization_list() {
654        let list = list_localizations();
655        assert!(!list.is_empty());
656    }
657
658    #[test]
659    // Test that reference language can be loaded
660    fn validate_reference_language() { let _ = LocalizationHandle::load_expect(REFERENCE_LANG); }
661
662    #[test]
663    // Test to verify that all languages are valid and loadable
664    fn validate_all_localizations() {
665        let list = list_localizations();
666        for meta in list {
667            let _ = LocalizationHandle::load_expect(&meta.language_identifier);
668        }
669    }
670
671    #[test]
672    fn test_strict_all_localizations() {
673        use analysis::{Language, ReferenceLanguage};
674        use assets::find_root;
675
676        let root = find_root().unwrap();
677        let i18n_directory = root.join("assets/voxygen/i18n");
678        let reference = ReferenceLanguage::at(&i18n_directory.join(REFERENCE_LANG));
679
680        let list = list_localizations();
681
682        for meta in list {
683            let code = meta.language_identifier;
684            let lang = Language {
685                code: code.clone(),
686                path: i18n_directory.join(code.clone()),
687            };
688            // TODO: somewhere here should go check that all needed
689            // versions are given
690            reference.compare_with(&lang);
691        }
692    }
693}