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