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 }
422 }
423
424 /// Tries its best to localize compound message.
425 ///
426 /// # Example
427 /// ```text
428 /// Content::Localized { "npc-speech-tell_site", seed, {
429 /// "dir" => Content::Localized("npc-speech-dir_north", seed, {})
430 /// "dist" => Content::Localized("npc-speech-dist_very_far", seed, {})
431 /// "site" => Content::Plain(site)
432 /// }}
433 /// ```
434 /// ```fluent
435 /// npc-speech-tell_site =
436 /// .a0 = Have you visited { $site }? It's just { $dir } of here!
437 /// .a1 = You should visit { $site } some time.
438 /// .a2 = If you travel { $dist } to the { $dir }, you can get to { $site }.
439 /// .a3 = To the { $dir } you'll find { $site }, it's { $dist }.
440 ///
441 /// npc-speech-dir_north = north
442 /// # ... other keys
443 ///
444 /// npc-speech-dist_very_far = very far away
445 /// # ... other keys
446 /// ```
447 ///
448 /// 1) Because content we want is localized itself and has arguments, we
449 /// iterate over them and localize, recursively. Having that, we localize
450 /// our content.
451 /// 2) Now there is a chance that some of args have missing internalization.
452 /// In that case, we insert arg name as placeholder and mark it as
453 /// broken. Then we repeat *whole* procedure on fallback language if we
454 /// have it.
455 /// 3) Otherwise, return result from (1).
456 // NOTE: it's important that we only use one language at the time, because
457 // otherwise we will get partially-translated message.
458 //
459 // TODO: return Cow<str>?
460 pub fn get_content(&self, content: &Content) -> String {
461 match Self::get_content_for_lang(&self.active, content) {
462 Ok(text) => text,
463 // If localisation or some part of it failed, repeat with fallback.
464 // If it did fail as well, it's probably because fallback was disabled,
465 // so we don't have better option other than returning broken text
466 // we produced earlier.
467 Err(broken_text) => self
468 .fallback
469 .as_ref()
470 .and_then(|fb| Self::get_content_for_lang(fb, content).ok())
471 .unwrap_or(broken_text),
472 }
473 }
474
475 pub fn get_content_fallback(&self, content: &Content) -> String {
476 self.fallback
477 .as_ref()
478 .map(|fb| Self::get_content_for_lang(fb, content))
479 .transpose()
480 .map(|msg| msg.unwrap_or_default())
481 .unwrap_or_else(|e| e)
482 }
483
484 /// NOTE: Exists for legacy reasons, avoid.
485 ///
486 /// Get a localized text from the variation of given key with given
487 /// arguments
488 ///
489 /// First lookup is done in the active language, second in
490 /// the fallback (if present).
491 /// If the key is not present in the localization object
492 /// then the key itself is returned.
493 // Read more in the issue on get_variation at Gitlab
494 pub fn get_variation_ctx<'a>(
495 &'a self,
496 key: &str,
497 seed: u16,
498 args: &'a FluentArgs,
499 ) -> Cow<'a, str> {
500 self.try_variation_ctx(key, seed, args)
501 .unwrap_or_else(|| Cow::Owned(key.to_owned()))
502 }
503
504 /// Get a localized text from the given key by given attribute
505 ///
506 /// First lookup is done in the active language, second in
507 /// the fallback (if present).
508 pub fn try_attr(&self, key: &str, attr: &str) -> Option<Cow<'_, str>> {
509 self.active.try_attr(key, attr, None).or_else(|| {
510 self.fallback
511 .as_ref()
512 .and_then(|fb| fb.try_attr(key, attr, None))
513 })
514 }
515
516 /// Get a localized text from the given key by given attribute
517 ///
518 /// First lookup is done in the active language, second in
519 /// the fallback (if present).
520 /// If the key is not present in the localization object
521 /// then the key itself is returned.
522 pub fn get_attr(&self, key: &str, attr: &str) -> Cow<'_, str> {
523 self.try_attr(key, attr)
524 .unwrap_or_else(|| Cow::Owned(format!("{key}.{attr}")))
525 }
526
527 /// Get a localized text from the given key by given attribute and arguments
528 ///
529 /// First lookup is done in the active language, second in
530 /// the fallback (if present).
531 pub fn try_attr_ctx(
532 &self,
533 key: &str,
534 attr: &str,
535 args: &FluentArgs,
536 ) -> Option<Cow<'static, str>> {
537 // NOTE: we explicitly Own result, because in 99.999% cases it got
538 // owned during formatting of arguments, hence it's a no-op, but makes
539 // using this function much easier
540 self.active
541 .try_attr(key, attr, Some(args))
542 .or_else(|| {
543 self.fallback
544 .as_ref()
545 .and_then(|fb| fb.try_attr(key, attr, Some(args)))
546 })
547 .map(|res| Cow::Owned(res.into_owned()))
548 }
549
550 /// Get a localized text from the given key by given attribute and arguments
551 ///
552 /// First lookup is done in the active language, second in
553 /// the fallback (if present).
554 /// If the key is not present in the localization object
555 /// then the key itself is returned.
556 pub fn get_attr_ctx(&self, key: &str, attr: &str, args: &FluentArgs) -> Cow<'static, str> {
557 self.try_attr_ctx(key, attr, args)
558 .unwrap_or_else(|| Cow::Owned(format!("{key}.{attr}")))
559 }
560
561 #[must_use]
562 pub fn fonts(&self) -> &Fonts { &self.active.fonts }
563
564 #[must_use]
565 pub fn metadata(&self) -> &LanguageMetadata { &self.active.metadata }
566}
567
568impl LocalizationHandle {
569 pub fn set_english_fallback(&mut self, use_english_fallback: bool) {
570 self.use_english_fallback = use_english_fallback;
571 }
572
573 #[must_use]
574 pub fn read(&self) -> LocalizationGuard {
575 LocalizationGuard {
576 active: self.active.read(),
577 fallback: if self.use_english_fallback {
578 self.fallback.map(|f| f.read())
579 } else {
580 None
581 },
582 }
583 }
584
585 /// # Errors
586 /// Returns error if active of fallback language can't be loaded
587 pub fn load(specifier: &str) -> Result<Self, assets::Error> {
588 let default_key = ["voxygen.i18n.", REFERENCE_LANG].concat();
589 let language_key = ["voxygen.i18n.", specifier].concat();
590 let is_default = language_key == default_key;
591 let active = Language::load(&language_key)?;
592 Ok(Self {
593 active,
594 watcher: active.reload_watcher(),
595 fallback: if is_default {
596 None
597 } else {
598 Some(Language::load(&default_key)?)
599 },
600 use_english_fallback: false,
601 })
602 }
603
604 #[must_use]
605 pub fn load_expect(specifier: &str) -> Self {
606 Self::load(specifier).expect("Can't load language files")
607 }
608
609 pub fn reloaded(&mut self) -> bool { self.watcher.reloaded() }
610}
611
612#[derive(Clone, Debug)]
613struct LocalizationList(Vec<LanguageMetadata>);
614
615impl assets::Asset for LocalizationList {
616 fn load(cache: &AssetCache, specifier: &SharedString) -> Result<Self, BoxedError> {
617 // List language directories
618 let languages = assets::load_rec_dir::<raw::Manifest>(specifier)
619 .unwrap_or_else(|e| panic!("Failed to get manifests from {}: {:?}", specifier, e))
620 .read()
621 .ids()
622 .filter_map(|spec| cache.load::<raw::Manifest>(spec).ok())
623 .map(|localization| localization.read().metadata.clone())
624 .collect();
625
626 Ok(LocalizationList(languages))
627 }
628}
629
630/// Load all the available languages located in the voxygen asset directory
631#[must_use]
632pub fn list_localizations() -> Vec<LanguageMetadata> {
633 let LocalizationList(list) = LocalizationList::load_expect_cloned("voxygen.i18n");
634 list
635}
636
637#[cfg(test)]
638mod tests {
639 use super::*;
640
641 #[test]
642 // Test that localization list is loaded (not empty)
643 fn check_localization_list() {
644 let list = list_localizations();
645 assert!(!list.is_empty());
646 }
647
648 #[test]
649 // Test that reference language can be loaded
650 fn validate_reference_language() { let _ = LocalizationHandle::load_expect(REFERENCE_LANG); }
651
652 #[test]
653 // Test to verify that all languages are valid and loadable
654 fn validate_all_localizations() {
655 let list = list_localizations();
656 for meta in list {
657 let _ = LocalizationHandle::load_expect(&meta.language_identifier);
658 }
659 }
660
661 #[test]
662 fn test_strict_all_localizations() {
663 use analysis::{Language, ReferenceLanguage};
664
665 let root = assets::find_root().unwrap();
666 let i18n_directory = root.join("assets/voxygen/i18n");
667 let reference = ReferenceLanguage::at(&i18n_directory.join(REFERENCE_LANG));
668
669 let list = list_localizations();
670
671 for meta in list {
672 let code = meta.language_identifier;
673 let lang = Language {
674 code: code.clone(),
675 path: i18n_directory.join(code.clone()),
676 };
677 // TODO: somewhere here should go check that all needed
678 // versions are given
679 reference.compare_with(&lang);
680 }
681 }
682}