Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions PCL.Core.Test/LocalizationTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Xml.Linq;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using PCL.Core.App.Localization;

namespace PCL.Core.Test;

[TestClass]
public class LocalizationTest
{
private static readonly string[] LanguageFiles = LocalizationService.SupportedLanguages
.Select(language => language.Code)
.ToArray();

[TestMethod]
public void AllLanguageDictionariesShouldContainBaseKeys()
{
var baseKeys = LoadKeys("zh-CN");

foreach (var language in LanguageFiles.Where(language => language != "zh-CN"))
{
var keys = LoadKeys(language);
var missing = baseKeys.Except(keys).ToArray();

Assert.IsEmpty(missing, $"{language} 缺少语言键:{string.Join(", ", missing)}");
}
}


[TestMethod]
public void LanguageDictionariesShouldNotContainDuplicateKeys()
{
foreach (var language in LanguageFiles)
{
var keys = LoadKeyList(language);
var duplicated = keys
.GroupBy(key => key)
.Where(group => group.Count() > 1)
.Select(group => group.Key)
.ToArray();

Assert.IsEmpty(duplicated, $"{language} 存在重复语言键:{string.Join(", ", duplicated)}");
}
}

[TestMethod]
public void LanguageKeysShouldUseDotNaming()
{
foreach (var language in LanguageFiles)
foreach (var key in LoadKeys(language))
{
Assert.IsFalse(string.IsNullOrWhiteSpace(key), $"{language} 存在空语言键");
Assert.IsTrue(key.Contains('.'), $"{language} 的语言键缺少分组分隔符:{key}");
Assert.IsFalse(key.Contains(' '), $"{language} 的语言键不应包含空格:{key}");
}
}


[TestMethod]
public void SupportedLanguagesShouldHaveValidCultureAndResourceDictionary()
{
foreach (var language in LocalizationService.SupportedLanguages)
{
CultureInfo.GetCultureInfo(language.CultureName);

var filePath = Path.Combine(GetRepositoryRoot(), "PCL.Core", "App", "Localization", "Languages",
language.Code + ".xaml");
Assert.IsTrue(File.Exists(filePath), $"{language.Code} 缺少语言资源文件");
}
}


[TestMethod]
public void FontProfileShouldFollowCultureGlyphStandard()
{
Assert.AreEqual(LocalizationFontProfile.SimplifiedChinese,
LocalizationFontService.ResolveProfileFromCultureName("zh-CN"));
Assert.AreEqual(LocalizationFontProfile.SimplifiedChinese,
LocalizationFontService.ResolveProfileFromCultureName("zh-Hans"));
Assert.AreEqual(LocalizationFontProfile.TraditionalChinese,
LocalizationFontService.ResolveProfileFromCultureName("zh-TW"));
Assert.AreEqual(LocalizationFontProfile.TraditionalChinese,
LocalizationFontService.ResolveProfileFromCultureName("zh_HK"));
Assert.AreEqual(LocalizationFontProfile.TraditionalChinese,
LocalizationFontService.ResolveProfileFromCultureName("zh-Hant-HK"));
Assert.AreEqual(LocalizationFontProfile.Japanese,
LocalizationFontService.ResolveProfileFromCultureName("ja-JP"));
Assert.AreEqual(LocalizationFontProfile.Korean,
LocalizationFontService.ResolveProfileFromCultureName("ko-KR"));
Assert.AreEqual(LocalizationFontProfile.English,
LocalizationFontService.ResolveProfileFromCultureName("en-US"));
Assert.AreEqual(LocalizationFontProfile.English,
LocalizationFontService.ResolveProfileFromCultureName("en-GB"));
Assert.AreEqual(LocalizationFontProfile.Other,
LocalizationFontService.ResolveProfileFromCultureName("fr-FR"));
Assert.AreEqual(LocalizationFontProfile.Other,
LocalizationFontService.ResolveProfileFromCultureName("es-ES"));
Assert.AreEqual(LocalizationFontProfile.Other,
LocalizationFontService.ResolveProfileFromCultureName("pt-BR"));
}

[TestMethod]
public void FontProfileAliasesShouldNotMakeLanguageResourceSupported()
{
Assert.IsFalse(LocalizationService.IsLanguageSupported("zh-HK"));
Assert.AreEqual(LocalizationService.DefaultLanguageCode, LocalizationService.ResolveLanguage("zh-HK").Code);
}

private static HashSet<string> LoadKeys(string language)
{
return LoadKeyList(language).ToHashSet();
}

private static string[] LoadKeyList(string language)
{
var filePath = Path.Combine(GetRepositoryRoot(), "PCL.Core", "App", "Localization", "Languages",
language + ".xaml");
var document = XDocument.Load(filePath);
var keyAttributeName = XName.Get("Key", "http://schemas.microsoft.com/winfx/2006/xaml");
return document.Descendants()
.Select(element => element.Attribute(keyAttributeName)?.Value)
.Where(key => !string.IsNullOrWhiteSpace(key))
.Select(key => key!)
.ToArray();
}

private static string GetRepositoryRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
if (Directory.Exists(Path.Combine(directory.FullName, "PCL.Core"))) return directory.FullName;
directory = directory.Parent;
}

Assert.Fail("无法定位仓库根目录");
return string.Empty;
}
}
22 changes: 22 additions & 0 deletions PCL.Core/App/Config.cs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,27 @@ public static partial class Config
/// </summary>
[ConfigItem<bool>("DetailedInstanceClassification", false, ConfigSource.Local)] public partial bool DetailedInstanceClassification { get; set; }

/// <summary>
/// 本地化配置。
/// </summary>
[ConfigGroup("Localization", ConfigSource.Local)] partial class LocalizationConfigGroup
{
/// <summary>
/// UI 语言。auto 表示跟随系统语言。
/// </summary>
[ConfigItem<string>("UiLanguage", "auto")] public partial string Language { get; set; }

/// <summary>
/// UI 展示格式所使用的区域性。auto 表示跟随系统区域格式。
/// </summary>
[ConfigItem<string>("UiFormatCulture", "auto")] public partial string FormatCulture { get; set; }

/// <summary>
/// 区域覆盖。auto 表示自动判断。
/// </summary>
[ConfigItem<string>("UiRegion", "auto")] public partial string Region { get; set; }
}

/// <summary>
/// 界面主题配置。
/// </summary>
Expand Down Expand Up @@ -410,6 +431,7 @@ partial class HideConfigGroup
// 子页面 设置
[ConfigItem<bool>("UiHiddenSetupLaunch", false, ConfigSource.Local)] public partial bool SetupLaunch { get; set; }
[ConfigItem<bool>("UiHiddenSetupUi", false, ConfigSource.Local)] public partial bool SetupUi { get; set; }
[ConfigItem<bool>("UiHiddenSetupLauncherLanguage", false, ConfigSource.Local)] public partial bool SetupLauncherLanguage { get; set; }
[ConfigItem<bool>("UiHiddenSetupLauncherMisc", false, ConfigSource.Local)] public partial bool SetupLauncherMisc { get; set; }
[ConfigItem<bool>("UiHiddenSetupGameManage", false, ConfigSource.Local)] public partial bool SetupGameManage { get; set; }
[ConfigItem<bool>("UiHiddenSetupJava", false, ConfigSource.Local)] public partial bool SetupJava { get; set; }
Expand Down
129 changes: 129 additions & 0 deletions PCL.Core/App/Localization/Lang.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
using System;
using System.Globalization;
using System.Windows;
using PCL.Core.App.IoC;

namespace PCL.Core.App.Localization;

/// <summary>
/// <p>
/// 本地化文本与展示格式访问辅助。
/// </p>
/// <p>
/// <see cref="Lang" /> 用于代码中读取本地化文本资源,以及按照当前展示区域性格式化文本参数、日期时间和数值。
/// 它面向 C# 代码侧调用;XAML 中的静态文本优先使用 <c>DynamicResource</c>,
/// XAML 绑定值的格式化优先使用 <see cref="LocalizationFormatConverter" />。
/// </p>
/// <p>
/// 文本资源来自当前应用的资源字典。正常运行时优先通过
/// <see cref="Application.Current" /> 查找资源;在应用生命周期早期或测试环境中,
/// 会回退到 <see cref="Lifecycle.CurrentApplication" /> 进行一次安全查找。
/// </p>
/// <p>
/// 该类中的格式化方法使用 <see cref="CultureInfo.CurrentCulture" />,
/// 因此会跟随 <see cref="LocalizationService" /> 当前设置的展示格式区域性。
/// 它们只适合生成展示给用户看的文本,不应用于配置文件、日志、协议、缓存键、文件名等
/// 需要稳定格式的场景;这些场景应显式使用 <see cref="CultureInfo.InvariantCulture" />。
/// </p>
/// </summary>
public static class Lang
{
/// <summary>
/// 获取指定资源键对应的本地化文本。
/// </summary>
/// <param name="key">
/// 资源键。不能为空、空字符串或空白字符串。
/// </param>
/// <returns>
/// 找到资源时返回本地化文本;
/// 未找到资源时,调试构建返回 <c>!key!</c>,发布构建返回 <paramref name="key" /> 本身。
/// </returns>
/// <exception cref="ArgumentException">
/// <paramref name="key" /> 为空、空字符串或空白字符串。
/// </exception>
public static string Text(string key)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);

if (Application.Current?.TryFindResource(key) is string text) return text;
if (_LifecycleSafeFindResource(key) is string fallbackText) return fallbackText;

#if DEBUG
return $"!{key}!";
#else
return key;
#endif
}

/// <summary>
/// 获取指定资源键对应的本地化格式文本,并使用当前展示区域性格式化参数。
/// 资源文本应使用标准 .NET 复合格式字符串,例如 <c>{0}</c>、<c>{1:N2}</c>。
/// 该方法适合代码中生成用户可见句子,例如提示、说明、状态文本。
/// </summary>
/// <param name="key">
/// 资源键。不能为空、空字符串或空白字符串。
/// </param>
/// <param name="args">
/// 用于填充资源文本中格式占位符的参数。
/// </param>
/// <returns>
/// 使用当前展示区域性格式化后的本地化文本。
/// </returns>
public static string Text(string key, params object?[] args)
{
return string.Format(CultureInfo.CurrentCulture, Text(key), args);
}

/// <summary>
/// <p>使用当前展示区域性格式化日期时间。</p>
/// </summary>
/// <param name="value">
/// 要格式化的日期时间。
/// </param>
/// <param name="format">
/// 标准或自定义日期时间格式字符串,默认使用 <c>G</c>。
/// </param>
/// <returns>
/// 使用当前展示区域性格式化后的日期时间文本。
/// </returns>
public static string Date(DateTime value, string format = "G")
{
return value.ToString(format, CultureInfo.CurrentCulture);
}

/// <summary>
/// 使用当前展示区域性格式化数值。
/// </summary>
/// <typeparam name="T">
/// 实现 <see cref="IFormattable" /> 的数值或可格式化类型。
/// </typeparam>
/// <param name="value">
/// 要格式化的值。
/// </param>
/// <param name="format">
/// 标准或自定义格式字符串。为 <see langword="null" /> 时使用类型默认格式。
/// </param>
/// <returns>
/// 使用当前展示区域性格式化后的文本。
/// </returns>
public static string Number<T>(T value, string? format = null) where T : IFormattable
{
return value.ToString(format, CultureInfo.CurrentCulture);
}

private static object? _LifecycleSafeFindResource(string key)
{
try
{
return Lifecycle.CurrentApplication.TryFindResource(key);
}
catch (InvalidOperationException)
{
return null;
}
catch (NullReferenceException)
{
return null;
}
}
}
32 changes: 32 additions & 0 deletions PCL.Core/App/Localization/Languages/en-GB.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sys="clr-namespace:System;assembly=mscorlib">

<sys:String x:Key="Localization.Meta.Code">en-GB</sys:String>
<sys:String x:Key="Localization.Meta.Name">English (United Kingdom)</sys:String>

<sys:String x:Key="Localization.Language.Auto">Follow system ({0})</sys:String>

<sys:String x:Key="Localization.FormatCulture.FollowLanguage">Follow UI language</sys:String>
<sys:String x:Key="Setup.LauncherLanguage.Title">Language</sys:String>
<sys:String x:Key="Setup.LauncherLanguage.CardTitle">Language</sys:String>
<sys:String x:Key="Setup.LauncherLanguage.UiLanguage">Display language</sys:String>
<sys:String x:Key="Setup.LauncherLanguage.FormatCulture">Regional format</sys:String>
<sys:String x:Key="Setup.LauncherLanguage.Hint">Multi-language support is currently limited to certain new interfaces. Others will be updated in upcoming migrations.</sys:String>
<sys:String x:Key="Setup.LauncherLanguage.Changed">Language settings updated</sys:String>

<sys:String x:Key="Localization.FormatCulture.Auto">Follow system regional format</sys:String>

<sys:String x:Key="Common.Action.Confirm">OK</sys:String>
<sys:String x:Key="Common.Action.Cancel">Cancel</sys:String>
<sys:String x:Key="Common.Action.Close">Close</sys:String>
<sys:String x:Key="Common.Action.Search">Search</sys:String>
<sys:String x:Key="Common.Action.Refresh">Refresh</sys:String>
<sys:String x:Key="Common.Action.Reset">Reset</sys:String>

<sys:String x:Key="Main.Title.Launch">Launch</sys:String>
<sys:String x:Key="Main.Title.Download">Download</sys:String>
<sys:String x:Key="Main.Title.Settings">Settings</sys:String>
<sys:String x:Key="Main.Title.Tools">Tools</sys:String>
</ResourceDictionary>
32 changes: 32 additions & 0 deletions PCL.Core/App/Localization/Languages/en-US.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sys="clr-namespace:System;assembly=mscorlib">

<sys:String x:Key="Localization.Meta.Code">en-US</sys:String>
<sys:String x:Key="Localization.Meta.Name">English (US)</sys:String>

<sys:String x:Key="Localization.Language.Auto">Follow system ({0})</sys:String>

<sys:String x:Key="Localization.FormatCulture.FollowLanguage">Follow UI language</sys:String>
<sys:String x:Key="Setup.LauncherLanguage.Title">Language</sys:String>
<sys:String x:Key="Setup.LauncherLanguage.CardTitle">Language</sys:String>
<sys:String x:Key="Setup.LauncherLanguage.UiLanguage">Display language</sys:String>
<sys:String x:Key="Setup.LauncherLanguage.FormatCulture">Regional format</sys:String>
<sys:String x:Key="Setup.LauncherLanguage.Hint">Multi-language support is currently limited to certain new interfaces. Others will be updated in upcoming migrations.</sys:String>
<sys:String x:Key="Setup.LauncherLanguage.Changed">Language settings updated</sys:String>

<sys:String x:Key="Localization.FormatCulture.Auto">Follow system regional format</sys:String>

<sys:String x:Key="Common.Action.Confirm">OK</sys:String>
<sys:String x:Key="Common.Action.Cancel">Cancel</sys:String>
<sys:String x:Key="Common.Action.Close">Close</sys:String>
<sys:String x:Key="Common.Action.Search">Search</sys:String>
<sys:String x:Key="Common.Action.Refresh">Refresh</sys:String>
<sys:String x:Key="Common.Action.Reset">Reset</sys:String>

<sys:String x:Key="Main.Title.Launch">Launch</sys:String>
<sys:String x:Key="Main.Title.Download">Download</sys:String>
<sys:String x:Key="Main.Title.Settings">Settings</sys:String>
<sys:String x:Key="Main.Title.Tools">Tools</sys:String>
</ResourceDictionary>
Loading
Loading