diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java index f7265458a..ce5353291 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java @@ -21,7 +21,7 @@ import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.auth.AuthInfo; import org.jackhuang.hmcl.launch.DefaultLauncher; import org.jackhuang.hmcl.launch.ProcessListener; -import org.jackhuang.hmcl.util.i18n.Locales; +import org.jackhuang.hmcl.util.i18n.LocaleUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.platform.ManagedProcess; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; @@ -81,7 +81,7 @@ public final class HMCLGameLauncher extends DefaultLauncher { } Locale locale = Locale.getDefault(); - if (Locales.isEnglish(locale)) + if (LocaleUtils.isEnglish(locale)) return; /* @@ -115,14 +115,6 @@ public final class HMCLGameLauncher extends DefaultLauncher { String region = locale.getCountry(); switch (language) { - case "zh": - case "cmn": - if (Locales.isSimplifiedChinese(locale)) - return "zh_CN"; - if (gameVersion.compareTo("1.16") >= 0 - && (region.equals("HK") || region.equals("MO"))) - return "zh_HK"; - return "zh_TW"; case "ru": return "ru_RU"; case "uk": @@ -135,7 +127,18 @@ public final class HMCLGameLauncher extends DefaultLauncher { return gameVersion.compareTo("1.16") >= 0 ? "lzh" : ""; + case "zh": default: + if (LocaleUtils.isChinese(locale)) { + String script = LocaleUtils.getScript(locale); + if ("Hant".equals(script)) { + if ((region.equals("HK") || region.equals("MO") && gameVersion.compareTo("1.16") >= 0)) + return "zh_HK"; + return "zh_TW"; + } + return "zh_CN"; + } + return ""; } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/FontManager.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/FontManager.java index fd1f6d598..b72141c76 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/FontManager.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/FontManager.java @@ -22,6 +22,8 @@ import javafx.beans.property.SimpleObjectProperty; import javafx.scene.text.Font; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.util.Lazy; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.i18n.DefaultResourceBundleControl; import org.jackhuang.hmcl.util.i18n.I18n; import org.jackhuang.hmcl.util.io.JarUtils; import org.jackhuang.hmcl.util.platform.OperatingSystem; @@ -29,11 +31,13 @@ import org.jackhuang.hmcl.util.platform.SystemUtils; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.io.IOException; import java.net.MalformedURLException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.Objects; +import java.util.*; +import java.util.stream.Stream; import static org.jackhuang.hmcl.setting.ConfigHolder.config; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -50,7 +54,21 @@ public final class FontManager { public static final double DEFAULT_FONT_SIZE = 12.0f; private static final Lazy DEFAULT_FONT = new Lazy<>(() -> { - Font font = tryLoadDefaultFont(Metadata.HMCL_CURRENT_DIRECTORY); + Font font; + + // Recommended + + font = tryLoadLocalizedFont(Metadata.HMCL_CURRENT_DIRECTORY.resolve("font")); + if (font != null) + return font; + + font = tryLoadLocalizedFont(Metadata.HMCL_GLOBAL_DIRECTORY.resolve("font")); + if (font != null) + return font; + + // Legacy + + font = tryLoadDefaultFont(Metadata.HMCL_CURRENT_DIRECTORY); if (font != null) return font; @@ -69,6 +87,8 @@ public final class FontManager { return font; } + // Default + String fcMatchPattern; if (OperatingSystem.CURRENT_OS.isLinuxOrBSD() && !(fcMatchPattern = I18n.getLocale().getFcMatchPattern()).isEmpty()) @@ -108,6 +128,7 @@ public final class FontManager { for (String extension : FONT_EXTENSIONS) { Path path = dir.resolve("font." + extension); if (Files.isRegularFile(path)) { + LOG.info("Load font file: " + path); try { Font font = Font.loadFont(path.toUri().toURL().toExternalForm(), DEFAULT_FONT_SIZE); if (font != null) { @@ -123,6 +144,53 @@ public final class FontManager { return null; } + private static Font tryLoadLocalizedFont(Path dir) { + if (!Files.isDirectory(dir)) + return null; + + try (Stream list = Files.list(dir)) { + Map map = new HashMap<>(); + + Set extensions = Set.of(FONT_EXTENSIONS); + list.forEach(file -> { + if (Files.isRegularFile(file)) { + String fileName = file.getFileName().toString(); + String extension = StringUtils.substringAfterLast(fileName, '.'); + + if (fileName.startsWith("font") && extensions.contains(extension)) { + map.put(fileName.substring(0, fileName.length() - extension.length() - 1), file); + } + } + }); + + List candidateLocales = I18n.getLocale().getCandidateLocales(); + + for (Locale locale : candidateLocales) { + String key = DefaultResourceBundleControl.INSTANCE.toBundleName("font", locale); + + Path path = map.get(key); + if (path != null) { + LOG.info("Load font file: " + path); + try { + Font font = Font.loadFont( + path.toAbsolutePath().normalize().toUri().toURL().toExternalForm(), + DEFAULT_FONT_SIZE); + if (font != null) + return font; + } catch (MalformedURLException ignored) { + } + + LOG.warning("Failed to load font " + path); + } + } + + } catch (IOException e) { + LOG.warning("Failed to load font " + dir, e); + } + + return null; + } + public static Font findByFcMatch(String pattern) { Path fcMatch = SystemUtils.which("fc-match"); if (fcMatch == null) @@ -147,6 +215,7 @@ public final class FontManager { return null; } + LOG.info("Load font file: " + path); Font[] fonts = Font.loadFonts(file.toUri().toURL().toExternalForm(), DEFAULT_FONT_SIZE); if (fonts == null) { LOG.warning("Failed to load font from " + path); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/I18n.java b/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/I18n.java index 72459ab12..cb6bc6288 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/I18n.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/I18n.java @@ -30,11 +30,9 @@ public final class I18n { } private static volatile SupportedLocale locale = Locales.DEFAULT; - private static volatile ResourceBundle resourceBundle = locale.getResourceBundle(); public static void setLocale(SupportedLocale locale) { I18n.locale = locale; - resourceBundle = locale.getResourceBundle(); } public static SupportedLocale getLocale() { @@ -42,11 +40,11 @@ public final class I18n { } public static boolean isUseChinese() { - return Locales.isChinese(locale.getLocale()); + return LocaleUtils.isChinese(locale.getLocale()); } public static ResourceBundle getResourceBundle() { - return resourceBundle; + return locale.getResourceBundle(); } public static String i18n(String key, Object... formatArgs) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/Locales.java b/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/Locales.java index b4daba9e4..db005acb8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/Locales.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/Locales.java @@ -22,9 +22,12 @@ import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; import org.jackhuang.hmcl.download.RemoteVersion; import org.jackhuang.hmcl.download.game.GameRemoteVersion; +import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; +import org.jetbrains.annotations.Nullable; import java.io.IOException; +import java.io.InputStream; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.time.temporal.TemporalAccessor; @@ -36,7 +39,15 @@ public final class Locales { private Locales() { } - public static final SupportedLocale DEFAULT = new SupportedLocale("def", Locale.getDefault()) { + private static Locale getDefaultLocale() { + String language = System.getenv("HMCL_LANGUAGE"); + if (StringUtils.isNotBlank(language)) + return Locale.forLanguageTag(language); + else + return LocaleUtils.SYSTEM_DEFAULT; + } + + public static final SupportedLocale DEFAULT = new SupportedLocale("def", getDefaultLocale()) { @Override public String getDisplayName(SupportedLocale inLocale) { try { @@ -76,12 +87,12 @@ public final class Locales { /** * Chinese (Simplified) */ - public static final SupportedLocale ZH_HANS = new SupportedLocale("zh_CN", Locale.forLanguageTag("zh-Hans")); + public static final SupportedLocale ZH_HANS = new SupportedLocale("zh_CN", LocaleUtils.LOCALE_ZH_HANS); /** * Chinese (Traditional) */ - public static final SupportedLocale ZH_HANT = new SupportedLocale("zh", Locale.forLanguageTag("zh-Hant")); + public static final SupportedLocale ZH_HANT = new SupportedLocale("zh", LocaleUtils.LOCALE_ZH_HANT); /** * Wenyan (Classical Chinese) @@ -90,27 +101,14 @@ public final class Locales { @Override public String getDisplayName(SupportedLocale inLocale) { - if (isChinese(inLocale.locale)) + if (LocaleUtils.isChinese(inLocale.locale)) return "文言"; String name = super.getDisplayName(inLocale); return name.equals("lzh") || name.equals("Literary Chinese") - ? "Classical Chinese" + ? "Chinese (Classical)" : name; } - - @Override - public String formatDateTime(TemporalAccessor time) { - return WenyanUtils.formatDateTime(time); - } - - @Override - public String getDisplaySelfVersion(RemoteVersion version) { - if (version instanceof GameRemoteVersion) - return WenyanUtils.translateGameVersion(GameVersionNumber.asGameVersion(version.getSelfVersion())); - else - return WenyanUtils.translateGenericVersion(version.getSelfVersion()); - } }; public static final List LOCALES = List.of(DEFAULT, EN, ES, JA, RU, UK, ZH_HANS, ZH_HANT, WENYAN); @@ -126,40 +124,13 @@ public final class Locales { return DEFAULT; } - public static boolean isEnglish(Locale locale) { - return locale.getLanguage().equals("en") || locale.getLanguage().isEmpty(); - } - - public static boolean isChinese(Locale locale) { - switch (locale.getLanguage()) { - case "zh": - case "lzh": - case "cmn": - return true; - default: - return false; - } - } - - public static boolean isSimplifiedChinese(Locale locale) { - if (locale.getLanguage().equals("zh") || locale.getLanguage().equals("cmn")) { - String script = locale.getScript(); - if (script.isEmpty()) { - String region = locale.getCountry(); - return region.isEmpty() || region.equals("CN") || region.equals("SG") || region.equals("MY"); - } else - return script.equals("Hans"); - } else { - return false; - } - } - @JsonAdapter(SupportedLocale.TypeAdapter.class) public static class SupportedLocale { private final String name; private final Locale locale; private ResourceBundle resourceBundle; private DateTimeFormatter dateTimeFormatter; + private List candidateLocales; SupportedLocale(String name, Locale locale) { this.name = name; @@ -183,11 +154,18 @@ public final class Locales { public ResourceBundle getResourceBundle() { ResourceBundle bundle = resourceBundle; if (resourceBundle == null) - resourceBundle = bundle = ResourceBundle.getBundle("assets.lang.I18N", locale, Control.INSTANCE); + resourceBundle = bundle = ResourceBundle.getBundle("assets.lang.I18N", locale, + DefaultResourceBundleControl.INSTANCE); return bundle; } + public List getCandidateLocales() { + if (candidateLocales == null) + candidateLocales = List.copyOf(LocaleUtils.getCandidateLocales(locale)); + return candidateLocales; + } + public String i18n(String key, Object... formatArgs) { try { return String.format(getResourceBundle().getString(key), formatArgs); @@ -211,13 +189,23 @@ public final class Locales { public String formatDateTime(TemporalAccessor time) { DateTimeFormatter formatter = dateTimeFormatter; - if (formatter == null) + if (formatter == null) { + if (locale.getLanguage().equals("lzh")) + return WenyanUtils.formatDateTime(time); + formatter = dateTimeFormatter = DateTimeFormatter.ofPattern(getResourceBundle().getString("datetime.format")) .withZone(ZoneId.systemDefault()); + } return formatter.format(time); } public String getDisplaySelfVersion(RemoteVersion version) { + if (locale.getLanguage().equals("lzh")) { + if (version instanceof GameRemoteVersion) + return WenyanUtils.translateGameVersion(GameVersionNumber.asGameVersion(version.getSelfVersion())); + else + return WenyanUtils.translateGenericVersion(version.getSelfVersion()); + } return version.getSelfVersion(); } @@ -225,23 +213,29 @@ public final class Locales { String language = locale.getLanguage(); String region = locale.getCountry(); - if (isEnglish(locale)) + if (LocaleUtils.isEnglish(locale)) return ""; - if (isChinese(locale)) { + if (LocaleUtils.isChinese(locale)) { String lang; String charset; - if (isSimplifiedChinese(locale)) { - lang = region.equals("SG") || region.equals("MY") - ? "zh-" + region - : "zh-CN"; - charset = "0x6e38,0x620f"; - } else { - lang = region.equals("HK") || region.equals("MO") - ? "zh-" + region - : "zh-TW"; - charset = "0x904a,0x6232"; + String script = LocaleUtils.getScript(locale); + switch (script) { + case "Hans": + lang = region.equals("SG") || region.equals("MY") + ? "zh-" + region + : "zh-CN"; + charset = "0x6e38,0x620f"; + break; + case "Hant": + lang = region.equals("HK") || region.equals("MO") + ? "zh-" + region + : "zh-TW"; + charset = "0x904a,0x6232"; + break; + default: + return ""; } return ":lang=" + lang + ":charset=" + charset; @@ -250,9 +244,34 @@ public final class Locales { return region.isEmpty() ? language : language + "-" + region; } + /// Find the builtin localized resource with given name and suffix. + /// + /// For example, if the current locale is `zh-CN`, when calling `findBuiltinResource("assets.lang.foo", "json")`, + /// this method will look for the following built-in resources in order: + /// + /// - `assets/lang/foo_zh_Hans_CN.json` + /// - `assets/lang/foo_zh_Hans.json` + /// - `assets/lang/foo_zh_CN.json` + /// - `assets/lang/foo_zh.json` + /// - `assets/lang/foo.json` + /// + /// This method will open and return the first found resource; + /// if none of the above resources exist, it returns `null`. + public @Nullable InputStream findBuiltinResource(String name, String suffix) { + var control = DefaultResourceBundleControl.INSTANCE; + var classLoader = Locales.class.getClassLoader(); + for (Locale locale : getCandidateLocales()) { + String resourceName = control.toResourceName(control.toBundleName(name, locale), suffix); + InputStream input = classLoader.getResourceAsStream(resourceName); + if (input != null) + return input; + } + return null; + } + public boolean isSameLanguage(SupportedLocale other) { - return this.getLocale().getLanguage().equals(other.getLocale().getLanguage()) - || isChinese(this.getLocale()) && isChinese(other.getLocale()); + return (this.getLocale().getLanguage().equals(other.getLocale().getLanguage())) + || (LocaleUtils.isChinese(this.getLocale()) && LocaleUtils.isChinese(other.getLocale())); } public static final class TypeAdapter extends com.google.gson.TypeAdapter { @@ -268,50 +287,4 @@ public final class Locales { } } - public static final class Control extends ResourceBundle.Control { - public static final Control INSTANCE = new Control(); - - @Override - public List getCandidateLocales(String baseName, Locale locale) { - List candidateLocales = super.getCandidateLocales(baseName, locale); - if (isChinese(locale)) { - int chineseIndex = candidateLocales.indexOf(Locale.CHINESE); - - // For "lzh" and "cmn" - if (chineseIndex < 0) { - if (!(candidateLocales instanceof ArrayList)) - candidateLocales = new ArrayList<>(candidateLocales); - - int i = candidateLocales.size() - 1; - while (i >= 0) { - Locale l = candidateLocales.get(i); - if (!isEnglish(l)) - break; - i--; - } - - chineseIndex = i + 1; - candidateLocales.add(chineseIndex, Locale.CHINESE); - } - - if (isSimplifiedChinese(locale)) { - if (!candidateLocales.contains(Locale.SIMPLIFIED_CHINESE)) { - if (!(candidateLocales instanceof ArrayList)) - candidateLocales = new ArrayList<>(candidateLocales); - candidateLocales.add(chineseIndex, Locale.SIMPLIFIED_CHINESE); - } - } - } - - if (candidateLocales.size() == 1 && candidateLocales.get(0).getLanguage().isEmpty()) { - if (!(candidateLocales instanceof ArrayList)) - candidateLocales = new ArrayList<>(candidateLocales); - - candidateLocales.add(0, Locale.ENGLISH); - } - - return candidateLocales; - } - - } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/MinecraftWiki.java b/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/MinecraftWiki.java index d5a34eb18..af91f6891 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/MinecraftWiki.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/MinecraftWiki.java @@ -50,13 +50,15 @@ public final class MinecraftWiki { } String variantSuffix; - if (Locales.isChinese(locale.getLocale())) { - if (Locales.isSimplifiedChinese(locale.getLocale())) + if (LocaleUtils.isChinese(locale.getLocale())) { + if (!"Hant".equals(LocaleUtils.getScript(locale.getLocale()))) { variantSuffix = "?variant=zh-cn"; - else if (locale.getLocale().getCountry().equals("HK") || locale.getLocale().getCountry().equals("MO")) - variantSuffix = "?variant=zh-hk"; - else - variantSuffix = "?variant=zh-tw"; + } else { + String region = locale.getLocale().getCountry(); + variantSuffix = region.equals("HK") || region.equals("MO") + ? "?variant=zh-hk" + : "?variant=zh-tw"; + } } else variantSuffix = ""; diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/util/i18n/LocalesTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/util/i18n/LocalesTest.java deleted file mode 100644 index 5767953d5..000000000 --- a/HMCL/src/test/java/org/jackhuang/hmcl/util/i18n/LocalesTest.java +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2025 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.util.i18n; - -import org.junit.jupiter.api.Test; - -import java.util.List; -import java.util.Locale; -import java.util.stream.Collectors; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * @author Glavo - */ -public final class LocalesTest { - private static void assertCandidateLocales(String languageTag, List candidateLocales) { - assertEquals(candidateLocales, - Locales.Control.INSTANCE.getCandidateLocales("", Locale.forLanguageTag(languageTag)) - .stream() - .map(Locale::toLanguageTag) - .collect(Collectors.toList())); - } - - @Test - public void testGetCandidateLocales() { - assertCandidateLocales("zh", List.of("zh-CN", "zh", "und")); - assertCandidateLocales("zh-CN", List.of("zh-Hans-CN", "zh-Hans", "zh-CN", "zh", "und")); - assertCandidateLocales("zh-Hans", List.of("zh-Hans", "zh-CN", "zh", "und")); - assertCandidateLocales("zh-Hant", List.of("zh-Hant", "zh-TW", "zh", "und")); - assertCandidateLocales("zh-Hans-US", List.of("zh-Hans-US", "zh-Hans", "zh-US", "zh-CN", "zh", "und")); - assertCandidateLocales("zh-US", List.of("zh-US", "zh", "und")); - assertCandidateLocales("zh-TW", List.of("zh-Hant-TW", "zh-Hant", "zh-TW", "zh", "und")); - assertCandidateLocales("zh-SG", List.of("zh-Hans-SG", "zh-Hans", "zh-SG", "zh-CN", "zh", "und")); - assertCandidateLocales("zh-MY", List.of("zh-MY", "zh-CN", "zh", "und")); - assertCandidateLocales("lzh", List.of("lzh", "zh", "und")); - assertCandidateLocales("cmn", List.of("cmn", "zh-CN", "zh", "und")); - assertCandidateLocales("cmn-Hans", List.of("cmn-Hans", "cmn", "zh-CN", "zh", "und")); - - assertCandidateLocales("ja", List.of("ja", "und")); - assertCandidateLocales("ja-JP", List.of("ja-JP", "ja", "und")); - - assertCandidateLocales("en", List.of("en", "und")); - assertCandidateLocales("und", List.of("en", "und")); - } - - @Test - public void testIsChinese() { - assertTrue(Locales.isChinese(Locale.CHINESE)); - assertTrue(Locales.isChinese(Locale.SIMPLIFIED_CHINESE)); - assertTrue(Locales.isChinese(Locale.TRADITIONAL_CHINESE)); - assertTrue(Locales.isChinese(Locale.forLanguageTag("lzh"))); - assertTrue(Locales.isChinese(Locale.forLanguageTag("cmn"))); - assertTrue(Locales.isChinese(Locale.forLanguageTag("cmn-Hans"))); - } - - @Test - public void testIsSimplifiedChinese() { - assertTrue(Locales.isSimplifiedChinese(Locale.CHINESE)); - assertTrue(Locales.isSimplifiedChinese(Locale.forLanguageTag("zh"))); - assertTrue(Locales.isSimplifiedChinese(Locale.forLanguageTag("zh-Hans"))); - assertTrue(Locales.isSimplifiedChinese(Locale.forLanguageTag("zh-Hans-US"))); - assertTrue(Locales.isSimplifiedChinese(Locale.forLanguageTag("zh-SG"))); - assertTrue(Locales.isSimplifiedChinese(Locale.forLanguageTag("zh-MY"))); - assertTrue(Locales.isSimplifiedChinese(Locale.forLanguageTag("cmn"))); - assertTrue(Locales.isSimplifiedChinese(Locale.forLanguageTag("cmn-Hans"))); - assertTrue(Locales.isSimplifiedChinese(Locale.forLanguageTag("cmn-CN"))); - - assertFalse(Locales.isSimplifiedChinese(Locale.forLanguageTag("zh-Hant"))); - assertFalse(Locales.isSimplifiedChinese(Locale.forLanguageTag("zh-TW"))); - assertFalse(Locales.isSimplifiedChinese(Locale.forLanguageTag("zh-HK"))); - assertFalse(Locales.isSimplifiedChinese(Locale.forLanguageTag("zh-MO"))); - assertFalse(Locales.isSimplifiedChinese(Locale.forLanguageTag("cmn-Hant"))); - } -} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/i18n/DefaultResourceBundleControl.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/i18n/DefaultResourceBundleControl.java new file mode 100644 index 000000000..bf28931e0 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/i18n/DefaultResourceBundleControl.java @@ -0,0 +1,104 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.util.i18n; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.ResourceBundle; + +/// Overrides the default behavior of [ResourceBundle.Control], optimizing the candidate list generation logic. +/// +/// Compared to the default implementation, [DefaultResourceBundleControl] optimizes the following scenarios: +/// +/// - If no language is specified (such as [Locale#ROOT]), `en` is used instead. +/// - For all Chinese locales, if no script is specified, the script (`Hans`/`Hant`/`Latn`) is always inferred based on region and variant. +/// - For all Chinese locales, `zh-CN` is always added to the candidate list. If `zh-Hans` already exists in the candidate list, +/// `zh-CN` is inserted before `zh`; otherwise, it is inserted after `zh`. +/// - For all Traditional Chinese locales, `zh-TW` is always added to the candidate list (before `zh`). +/// - For all Chinese variants (such as `lzh`, `cmn`, `yue`, etc.), a candidate list with the language code replaced by `zh` +/// is added to the end of the candidate list. +/// +/// @author Glavo +public class DefaultResourceBundleControl extends ResourceBundle.Control { + + public static final DefaultResourceBundleControl INSTANCE = new DefaultResourceBundleControl(); + + public DefaultResourceBundleControl() { + } + + private static List ensureEditable(List list) { + return list instanceof ArrayList + ? list + : new ArrayList<>(list); + } + + @Override + public List getCandidateLocales(String baseName, Locale locale) { + if (locale.getLanguage().isEmpty()) + return getCandidateLocales(baseName, Locale.ENGLISH); + + if (LocaleUtils.isChinese(locale)) { + String language = locale.getLanguage(); + String script = locale.getScript(); + + if (script.isEmpty()) { + script = LocaleUtils.getScript(locale); + locale = new Locale.Builder() + .setLocale(locale) + .setScript(script) + .build(); + } + + List locales = super.getCandidateLocales("", locale); + + if (language.equals("zh")) { + if (locales.contains(LocaleUtils.LOCALE_ZH_HANT) && !locales.contains(Locale.TRADITIONAL_CHINESE)) { + locales = ensureEditable(locales); + int chineseIdx = locales.indexOf(Locale.CHINESE); + if (chineseIdx >= 0) + locales.add(chineseIdx, Locale.TRADITIONAL_CHINESE); + } + + if (!locales.contains(Locale.SIMPLIFIED_CHINESE)) { + int chineseIdx = locales.indexOf(Locale.CHINESE); + + if (chineseIdx >= 0) { + locales = ensureEditable(locales); + if (locales.contains(LocaleUtils.LOCALE_ZH_HANS)) + locales.add(chineseIdx, Locale.SIMPLIFIED_CHINESE); + else + locales.add(chineseIdx + 1, Locale.SIMPLIFIED_CHINESE); + } + } + } else { + locales = ensureEditable(locales); + locales.removeIf(it -> !it.getLanguage().equals(language)); + + locales.addAll(getCandidateLocales("", new Locale.Builder() + .setLocale(locale) + .setLanguage("zh") + .build())); + } + + return locales; + } + + return super.getCandidateLocales(baseName, locale); + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/i18n/LocaleUtils.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/i18n/LocaleUtils.java new file mode 100644 index 000000000..0bc3f5957 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/i18n/LocaleUtils.java @@ -0,0 +1,82 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.util.i18n; + +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * @author Glavo + */ +public class LocaleUtils { + + public static final Locale SYSTEM_DEFAULT = Locale.getDefault(); + + public static final Locale LOCALE_ZH_HANS = Locale.forLanguageTag("zh-Hans"); + public static final Locale LOCALE_ZH_HANT = Locale.forLanguageTag("zh-Hant"); + + public static String toLanguageKey(Locale locale) { + if (locale.getLanguage().isEmpty()) + return "default"; + else + return locale.toLanguageTag(); + } + + public static @NotNull List getCandidateLocales(Locale locale) { + return DefaultResourceBundleControl.INSTANCE.getCandidateLocales("", locale); + } + + public static String getScript(Locale locale) { + if (locale.getScript().isEmpty()) { + if (isChinese(locale)) { + if (CHINESE_LATN_VARIANTS.contains(locale.getVariant())) + return "Latn"; + if (locale.getLanguage().equals("lzh") || CHINESE_TRADITIONAL_REGIONS.contains(locale.getCountry())) + return "Hant"; + else + return "Hans"; + } + } + + return locale.getScript(); + } + + // --- + + public static boolean isEnglish(Locale locale) { + return locale.getLanguage().equals("en") || locale.getLanguage().isEmpty(); + } + + public static final Set CHINESE_TRADITIONAL_REGIONS = Set.of("TW", "HK", "MO"); + public static final Set CHINESE_LATN_VARIANTS = Set.of("pinyin", "wadegile", "tongyong"); + public static final Set CHINESE_LANGUAGES = Set.of( + "zh", + "zho", "cmn", "lzh", "cdo", "cjy", "cpx", "czh", + "gan", "hak", "hsn", "mnp", "nan", "wuu", "yue" + ); + + public static boolean isChinese(Locale locale) { + return CHINESE_LANGUAGES.contains(locale.getLanguage()); + } + + private LocaleUtils() { + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/i18n/LocalizedText.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/i18n/LocalizedText.java new file mode 100644 index 000000000..d98e82e44 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/i18n/LocalizedText.java @@ -0,0 +1,103 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.util.i18n; + +import com.google.gson.JsonSyntaxException; +import com.google.gson.TypeAdapter; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.util.*; + +@JsonAdapter(LocalizedText.Adapter.class) +public final class LocalizedText { + private final @Nullable String value; + private final @Nullable Map localizedValues; + + public LocalizedText(String value) { + this.value = value; + this.localizedValues = null; + } + + public LocalizedText(@NotNull Map localizedValues) { + this.value = null; + this.localizedValues = Objects.requireNonNull(localizedValues); + } + + public String getText(@NotNull List candidates) { + if (localizedValues != null) { + for (Locale locale : candidates) { + String value = localizedValues.get(LocaleUtils.toLanguageKey(locale)); + if (value != null) + return value; + } + return null; + } else + return value; + } + + static final class Adapter extends TypeAdapter { + + @Override + public LocalizedText read(JsonReader jsonReader) throws IOException { + JsonToken nextToken = jsonReader.peek(); + if (nextToken == JsonToken.NULL) { + return null; + } else if (nextToken == JsonToken.STRING) { + return new LocalizedText(jsonReader.nextString()); + } else if (nextToken == JsonToken.BEGIN_OBJECT) { + LinkedHashMap localizedValues = new LinkedHashMap<>(); + + jsonReader.beginObject(); + while (jsonReader.hasNext()) { + String name = jsonReader.nextName(); + String value = jsonReader.nextString(); + + localizedValues.put(name, value); + } + jsonReader.endObject(); + + return new LocalizedText(localizedValues); + } else { + throw new JsonSyntaxException("Unexpected token " + nextToken); + } + } + + @Override + public void write(JsonWriter jsonWriter, LocalizedText localizedText) throws IOException { + if (localizedText == null) { + jsonWriter.nullValue(); + } else if (localizedText.localizedValues != null) { + + jsonWriter.beginObject(); + for (var entry : localizedText.localizedValues.entrySet()) { + jsonWriter.name(entry.getKey()); + jsonWriter.value(entry.getValue()); + } + jsonWriter.endObject(); + } else { + jsonWriter.value(localizedText.value); + } + } + } +} diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/util/i18n/LocaleUtilsTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/i18n/LocaleUtilsTest.java new file mode 100644 index 000000000..fc14cc6e6 --- /dev/null +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/i18n/LocaleUtilsTest.java @@ -0,0 +1,116 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.util.i18n; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * @author Glavo + */ +public final class LocaleUtilsTest { + private static void assertCandidateLocales(String languageTag, List candidateLocales) { + assertEquals(candidateLocales, + LocaleUtils.getCandidateLocales(Locale.forLanguageTag(languageTag)) + .stream() + .map(Locale::toLanguageTag) + .collect(Collectors.toList())); + } + + @Test + public void testGetCandidateLocales() { + assertCandidateLocales("zh", List.of("zh-Hans", "zh-CN", "zh", "und")); + assertCandidateLocales("zh-CN", List.of("zh-Hans-CN", "zh-Hans", "zh-CN", "zh", "und")); + assertCandidateLocales("zh-SG", List.of("zh-Hans-SG", "zh-Hans", "zh-SG", "zh-CN", "zh", "und")); + assertCandidateLocales("zh-MY", List.of("zh-Hans-MY", "zh-Hans", "zh-MY", "zh-CN", "zh", "und")); + assertCandidateLocales("zh-US", List.of("zh-Hans-US", "zh-Hans", "zh-US", "zh-CN", "zh", "und")); + assertCandidateLocales("zh-TW", List.of("zh-Hant-TW", "zh-Hant", "zh-TW", "zh", "zh-CN", "und")); + assertCandidateLocales("zh-HK", List.of("zh-Hant-HK", "zh-Hant", "zh-HK", "zh-TW", "zh", "zh-CN", "und")); + assertCandidateLocales("zh-MO", List.of("zh-Hant-MO", "zh-Hant", "zh-MO", "zh-TW", "zh", "zh-CN", "und")); + assertCandidateLocales("zh-Hans", List.of("zh-Hans", "zh-CN", "zh", "und")); + assertCandidateLocales("zh-Hant", List.of("zh-Hant", "zh-TW", "zh", "zh-CN", "und")); + assertCandidateLocales("zh-Hans-US", List.of("zh-Hans-US", "zh-Hans", "zh-US", "zh-CN", "zh", "und")); + assertCandidateLocales("zh-Hant-CN", List.of("zh-Hant-CN", "zh-Hant", "zh-CN", "zh-TW", "zh", "und")); + assertCandidateLocales("zh-Hans-TW", List.of("zh-Hans-TW", "zh-Hans", "zh-TW", "zh-CN", "zh", "und")); + assertCandidateLocales("zh-Latn", List.of("zh-Latn", "zh", "zh-CN", "und")); + assertCandidateLocales("zh-Latn-CN", List.of("zh-Latn-CN", "zh-Latn", "zh-CN", "zh", "und")); + assertCandidateLocales("zh-pinyin", List.of("zh-Latn-pinyin", "zh-Latn", "zh-pinyin", "zh", "zh-CN", "und")); + assertCandidateLocales("lzh", List.of("lzh-Hant", "lzh", "zh-Hant", "zh-TW", "zh", "zh-CN", "und")); + assertCandidateLocales("lzh-Hant", List.of("lzh-Hant", "lzh", "zh-Hant", "zh-TW", "zh", "zh-CN", "und")); + assertCandidateLocales("lzh-Hans", List.of("lzh-Hans", "lzh", "zh-Hans", "zh-CN", "zh", "und")); + assertCandidateLocales("cmn", List.of("cmn-Hans", "cmn", "zh-Hans", "zh-CN", "zh", "und")); + assertCandidateLocales("cmn-Hans", List.of("cmn-Hans", "cmn", "zh-Hans", "zh-CN", "zh", "und")); + assertCandidateLocales("yue", List.of("yue-Hans", "yue", "zh-Hans", "zh-CN", "zh", "und")); + + assertCandidateLocales("ja", List.of("ja", "und")); + assertCandidateLocales("ja-JP", List.of("ja-JP", "ja", "und")); + + assertCandidateLocales("en", List.of("en", "und")); + assertCandidateLocales("und", List.of("en", "und")); + } + + @Test + public void testIsChinese() { + assertTrue(LocaleUtils.isChinese(Locale.CHINESE)); + assertTrue(LocaleUtils.isChinese(Locale.SIMPLIFIED_CHINESE)); + assertTrue(LocaleUtils.isChinese(Locale.TRADITIONAL_CHINESE)); + assertTrue(LocaleUtils.isChinese(LocaleUtils.LOCALE_ZH_HANS)); + assertTrue(LocaleUtils.isChinese(LocaleUtils.LOCALE_ZH_HANT)); + assertTrue(LocaleUtils.isChinese(Locale.forLanguageTag("lzh"))); + assertTrue(LocaleUtils.isChinese(Locale.forLanguageTag("cmn"))); + assertTrue(LocaleUtils.isChinese(Locale.forLanguageTag("cmn-Hans"))); + assertTrue(LocaleUtils.isChinese(Locale.forLanguageTag("yue"))); + + assertFalse(LocaleUtils.isChinese(Locale.ROOT)); + assertFalse(LocaleUtils.isChinese(Locale.ENGLISH)); + assertFalse(LocaleUtils.isChinese(Locale.JAPANESE)); + assertFalse(LocaleUtils.isChinese(Locale.forLanguageTag("es"))); + assertFalse(LocaleUtils.isChinese(Locale.forLanguageTag("ru"))); + assertFalse(LocaleUtils.isChinese(Locale.forLanguageTag("uk"))); + } + + @Test + public void testGetScript() { + assertEquals("Hans", LocaleUtils.getScript(Locale.CHINESE)); + assertEquals("Hans", LocaleUtils.getScript(Locale.forLanguageTag("zh"))); + assertEquals("Hans", LocaleUtils.getScript(Locale.forLanguageTag("zh-Hans"))); + assertEquals("Hans", LocaleUtils.getScript(Locale.forLanguageTag("zh-Hans-US"))); + assertEquals("Hans", LocaleUtils.getScript(Locale.forLanguageTag("zh-SG"))); + assertEquals("Hans", LocaleUtils.getScript(Locale.forLanguageTag("zh-MY"))); + assertEquals("Hans", LocaleUtils.getScript(Locale.forLanguageTag("cmn"))); + assertEquals("Hans", LocaleUtils.getScript(Locale.forLanguageTag("cmn-Hans"))); + assertEquals("Hans", LocaleUtils.getScript(Locale.forLanguageTag("cmn-CN"))); + assertEquals("Hans", LocaleUtils.getScript(Locale.forLanguageTag("lzh-Hans"))); + + assertEquals("Hant", LocaleUtils.getScript(Locale.forLanguageTag("zh-Hant"))); + assertEquals("Hant", LocaleUtils.getScript(Locale.forLanguageTag("zh-TW"))); + assertEquals("Hant", LocaleUtils.getScript(Locale.forLanguageTag("zh-HK"))); + assertEquals("Hant", LocaleUtils.getScript(Locale.forLanguageTag("zh-MO"))); + assertEquals("Hant", LocaleUtils.getScript(Locale.forLanguageTag("cmn-Hant"))); + assertEquals("Hant", LocaleUtils.getScript(Locale.forLanguageTag("lzh"))); + assertEquals("Hant", LocaleUtils.getScript(Locale.forLanguageTag("lzh-Hant"))); + assertEquals("Hant", LocaleUtils.getScript(Locale.forLanguageTag("lzh-CN"))); + + assertEquals("Latn", LocaleUtils.getScript(Locale.forLanguageTag("zh-pinyin"))); + } +}