From 7576bf6a01a844fb309ff46b5a393bb153091399 Mon Sep 17 00:00:00 2001 From: Glavo Date: Thu, 4 Sep 2025 15:51:17 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9C=A8=20LocaleUtils=20=E4=B8=AD=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E6=9B=B4=E5=A4=9A=E5=B7=A5=E5=85=B7=E6=96=B9=E6=B3=95?= =?UTF-8?q?=20(#4390)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jackhuang/hmcl/setting/FontManager.java | 58 +++----- .../org/jackhuang/hmcl/util/i18n/I18n.java | 48 ++++++- .../org/jackhuang/hmcl/util/i18n/Locales.java | 40 ------ HMCLCore/build.gradle.kts | 1 + .../jackhuang/hmcl/util/i18n/LocaleUtils.java | 129 ++++++++++++++++-- .../hmcl/util/i18n/LocaleUtilsTest.java | 61 +++++++++ gradle/libs.versions.toml | 2 + 7 files changed, 247 insertions(+), 92 deletions(-) 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 b72141c76..a73a60257 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/FontManager.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/FontManager.java @@ -22,22 +22,19 @@ 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.i18n.LocaleUtils; import org.jackhuang.hmcl.util.io.JarUtils; import org.jackhuang.hmcl.util.platform.OperatingSystem; 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.*; -import java.util.stream.Stream; import static org.jackhuang.hmcl.setting.ConfigHolder.config; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -145,47 +142,32 @@ public final class FontManager { } private static Font tryLoadLocalizedFont(Path dir) { - if (!Files.isDirectory(dir)) + Map> fontFiles = LocaleUtils.findAllLocalizedFiles(dir, "font", Set.of(FONT_EXTENSIONS)); + if (fontFiles.isEmpty()) return null; - try (Stream list = Files.list(dir)) { - Map map = new HashMap<>(); + List candidateLocales = I18n.getLocale().getCandidateLocales(); - Set extensions = Set.of(FONT_EXTENSIONS); - list.forEach(file -> { - if (Files.isRegularFile(file)) { - String fileName = file.getFileName().toString(); - String extension = StringUtils.substringAfterLast(fileName, '.'); + for (Locale locale : candidateLocales) { + Map extToFiles = fontFiles.get(LocaleUtils.toLanguageKey(locale)); + if (extToFiles != null) { + for (String ext : FONT_EXTENSIONS) { + Path fontFile = extToFiles.get(ext); + if (fontFile != null) { + LOG.info("Load font file: " + fontFile); + try { + Font font = Font.loadFont( + fontFile.toAbsolutePath().normalize().toUri().toURL().toExternalForm(), + DEFAULT_FONT_SIZE); + if (font != null) + return font; + } catch (MalformedURLException ignored) { + } - if (fileName.startsWith("font") && extensions.contains(extension)) { - map.put(fileName.substring(0, fileName.length() - extension.length() - 1), file); + LOG.warning("Failed to load font " + fontFile); } } - }); - - 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; 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 cb6bc6288..440b58d82 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 @@ -20,7 +20,12 @@ package org.jackhuang.hmcl.util.i18n; import org.jackhuang.hmcl.download.RemoteVersion; import org.jackhuang.hmcl.download.game.GameRemoteVersion; import org.jackhuang.hmcl.util.i18n.Locales.SupportedLocale; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; +import org.jetbrains.annotations.Nullable; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; import java.time.temporal.TemporalAccessor; import java.util.*; @@ -60,7 +65,48 @@ public final class I18n { } public static String getDisplaySelfVersion(RemoteVersion version) { - return locale.getDisplaySelfVersion(version); + if (locale.getLocale().getLanguage().equals("lzh")) { + if (version instanceof GameRemoteVersion) + return WenyanUtils.translateGameVersion(GameVersionNumber.asGameVersion(version.getSelfVersion())); + else + return WenyanUtils.translateGenericVersion(version.getSelfVersion()); + } + return version.getSelfVersion(); + } + + /// Find the builtin localized resource with given name and suffix. + /// + /// For example, if the current locale is `zh-CN`, when calling `getBuiltinResource("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 return the first found resource; + /// if none of the above resources exist, it returns `null`. + public static @Nullable URL getBuiltinResource(String name, String suffix) { + var control = DefaultResourceBundleControl.INSTANCE; + var classLoader = I18n.class.getClassLoader(); + for (Locale locale : locale.getCandidateLocales()) { + String resourceName = control.toResourceName(control.toBundleName(name, locale), suffix); + URL input = classLoader.getResource(resourceName); + if (input != null) + return input; + } + return null; + } + + /// @see [#getBuiltinResource(String, String) ] + public static @Nullable InputStream getBuiltinResourceAsStream(String name, String suffix) { + URL resource = getBuiltinResource(name, suffix); + try { + return resource != null ? resource.openStream() : null; + } catch (IOException e) { + return null; + } } public static String getWikiLink(GameRemoteVersion remoteVersion) { 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 db005acb8..dc7c7283d 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 @@ -20,14 +20,9 @@ package org.jackhuang.hmcl.util.i18n; import com.google.gson.annotations.JsonAdapter; 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; @@ -199,16 +194,6 @@ public final class Locales { 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(); - } - public String getFcMatchPattern() { String language = locale.getLanguage(); String region = locale.getCountry(); @@ -244,31 +229,6 @@ 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())) || (LocaleUtils.isChinese(this.getLocale()) && LocaleUtils.isChinese(other.getLocale())); diff --git a/HMCLCore/build.gradle.kts b/HMCLCore/build.gradle.kts index 1fc707005..ab5f9d807 100644 --- a/HMCLCore/build.gradle.kts +++ b/HMCLCore/build.gradle.kts @@ -30,4 +30,5 @@ dependencies { compileOnlyApi(libs.jetbrains.annotations) testImplementation(libs.jna.platform) + testImplementation(libs.jimfs) } 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 index 0bc3f5957..cebb0b611 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/i18n/LocaleUtils.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/i18n/LocaleUtils.java @@ -17,34 +17,44 @@ */ package org.jackhuang.hmcl.util.i18n; +import org.jackhuang.hmcl.util.StringUtils; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; -import java.util.List; -import java.util.Locale; -import java.util.Set; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.stream.Stream; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; /** * @author Glavo */ -public class LocaleUtils { +public final 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 final String DEFAULT_LANGUAGE_KEY = "default"; + + /// Convert a locale to the language key. + /// + /// The language key is in the format of BCP 47 language tag. + /// If the locale is the default locale (language is empty), "default" will be returned. public static String toLanguageKey(Locale locale) { - if (locale.getLanguage().isEmpty()) - return "default"; - else - return locale.toLanguageTag(); + return locale.getLanguage().isEmpty() + ? DEFAULT_LANGUAGE_KEY + : locale.stripExtensions().toLanguageTag(); } - public static @NotNull List getCandidateLocales(Locale locale) { - return DefaultResourceBundleControl.INSTANCE.getCandidateLocales("", locale); - } - - public static String getScript(Locale locale) { + /// Get the script of the locale. If the script is empty and the language is Chinese, + /// the script will be inferred based on the language, the region and the variant. + public static @NotNull String getScript(Locale locale) { if (locale.getScript().isEmpty()) { if (isChinese(locale)) { if (CHINESE_LATN_VARIANTS.contains(locale.getVariant())) @@ -59,6 +69,99 @@ public class LocaleUtils { return locale.getScript(); } + public static @NotNull List getCandidateLocales(Locale locale) { + return DefaultResourceBundleControl.INSTANCE.getCandidateLocales("", locale); + } + + public static @Nullable T getByCandidateLocales(Map map, List candidateLocales) { + for (Locale locale : candidateLocales) { + String key = toLanguageKey(locale); + if (map.containsKey(key)) + return map.get(key); + } + return null; + } + + /// Find all localized files in the given directory with the given base name and extension. + /// The file name should be in the format of `baseName[_languageTag].ext`. + /// + /// @return A map of language key to file path. + public static @NotNull @Unmodifiable Map findAllLocalizedFiles(Path dir, String baseName, String ext) { + if (Files.isDirectory(dir)) { + String suffix = "." + ext; + String defaultName = baseName + suffix; + String noDefaultPrefix = baseName + "_"; + + try (Stream list = Files.list(dir)) { + var result = new LinkedHashMap(); + + list.forEach(file -> { + if (Files.isRegularFile(file)) { + String fileName = file.getFileName().toString(); + if (fileName.equals(defaultName)) { + result.put(DEFAULT_LANGUAGE_KEY, file); + } else if (fileName.startsWith(noDefaultPrefix) && fileName.endsWith(suffix)) { + String languageKey = fileName.substring(noDefaultPrefix.length(), fileName.length() - suffix.length()) + .replace('_', '-'); + + if (!languageKey.isEmpty()) + result.put(languageKey, file); + } + } + }); + + return result; + } catch (IOException e) { + LOG.warning("Failed to list files in directory " + dir, e); + } + } + + return Map.of(); + } + + /// Find all localized files in the given directory with the given base name and extensions. + /// The file name should be in the format of `baseName[_languageTag].ext`. + /// + /// @return A map of language key to a map of extension to file path. + public static @NotNull @Unmodifiable Map> findAllLocalizedFiles(Path dir, String baseName, Collection exts) { + if (Files.isDirectory(dir)) { + try (Stream list = Files.list(dir)) { + var result = new LinkedHashMap>(); + + list.forEach(file -> { + if (Files.isRegularFile(file)) { + String fileName = file.getFileName().toString(); + if (!fileName.startsWith(baseName)) + return; + + String ext = StringUtils.substringAfterLast(fileName, '.'); + if (!exts.contains(ext)) + return; + + String languageKey; + int defaultFileNameLength = baseName.length() + ext.length() + 1; + if (fileName.length() == defaultFileNameLength) + languageKey = DEFAULT_LANGUAGE_KEY; + else if (fileName.length() > defaultFileNameLength + 1 && fileName.charAt(baseName.length()) == '_') + languageKey = fileName.substring(baseName.length() + 1, fileName.length() - ext.length() - 1) + .replace('_', '-'); + else + return; + + result.computeIfAbsent(languageKey, key -> new HashMap<>()) + .put(ext, file); + } + }); + + return result; + } catch (IOException e) { + LOG.warning("Failed to list files in directory " + dir, e); + } + } + + return Map.of(); + } + // --- public static boolean isEnglish(Locale locale) { 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 index fc14cc6e6..df3383acc 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/util/i18n/LocaleUtilsTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/i18n/LocaleUtilsTest.java @@ -17,10 +17,17 @@ */ package org.jackhuang.hmcl.util.i18n; +import com.google.common.jimfs.Configuration; +import com.google.common.jimfs.Jimfs; import org.junit.jupiter.api.Test; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.List; import java.util.Locale; +import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.*; @@ -113,4 +120,58 @@ public final class LocaleUtilsTest { assertEquals("Latn", LocaleUtils.getScript(Locale.forLanguageTag("zh-pinyin"))); } + + @Test + public void testFindAllLocalizedFiles() throws IOException { + try (var testFs = Jimfs.newFileSystem(Configuration.unix())) { + Path testDir = testFs.getPath("/test-dir"); + Files.createDirectories(testDir); + + Files.createFile(testDir.resolve("meow.json")); + Files.createFile(testDir.resolve("meow_zh.json")); + Files.createFile(testDir.resolve("meow_zh_CN.json")); + Files.createFile(testDir.resolve("meow_zh_Hans.json")); + Files.createFile(testDir.resolve("meow_zh_Hans_CN.json")); + Files.createFile(testDir.resolve("meow_en.json")); + Files.createFile(testDir.resolve("meow_en.toml")); + + Files.createFile(testDir.resolve("meow_.json")); + Files.createFile(testDir.resolve("meowmeow.json")); + Files.createFile(testDir.resolve("woem.json")); + Files.createFile(testDir.resolve("meow.txt")); + Files.createDirectories(testDir.resolve("subdir")); + Files.createDirectories(testDir.resolve("meow_en_US.json")); + + Path notExistsDir = testFs.getPath("/not-exists"); + Path emptyDir = testFs.getPath("/empty"); + Files.createDirectories(emptyDir); + + assertEquals(Map.of(), LocaleUtils.findAllLocalizedFiles(emptyDir, "meow", "json")); + assertEquals(Map.of(), LocaleUtils.findAllLocalizedFiles(emptyDir, "meow", Set.of("json", "toml"))); + assertEquals(Map.of(), LocaleUtils.findAllLocalizedFiles(notExistsDir, "meow", "json")); + assertEquals(Map.of(), LocaleUtils.findAllLocalizedFiles(notExistsDir, "meow", Set.of("json", "toml"))); + + assertEquals(Map.of( + "default", testDir.resolve("meow.json"), + "zh", testDir.resolve("meow_zh.json"), + "zh-CN", testDir.resolve("meow_zh_CN.json"), + "zh-Hans", testDir.resolve("meow_zh_Hans.json"), + "zh-Hans-CN", testDir.resolve("meow_zh_Hans_CN.json"), + "en", testDir.resolve("meow_en.json") + ), + LocaleUtils.findAllLocalizedFiles(testDir, "meow", "json")); + assertEquals(Map.of( + "default", Map.of("json", testDir.resolve("meow.json")), + "zh", Map.of("json", testDir.resolve("meow_zh.json")), + "zh-CN", Map.of("json", testDir.resolve("meow_zh_CN.json")), + "zh-Hans", Map.of("json", testDir.resolve("meow_zh_Hans.json")), + "zh-Hans-CN", Map.of("json", testDir.resolve("meow_zh_Hans_CN.json")), + "en", Map.of( + "json", testDir.resolve("meow_en.json"), + "toml", testDir.resolve("meow_en.toml") + ) + ), + LocaleUtils.findAllLocalizedFiles(testDir, "meow", Set.of("json", "toml"))); + } + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 17aa1942d..a4e5bc563 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,6 +20,7 @@ authlib-injector = "1.2.5" # testing junit = "5.13.4" +jimfs = "1.3.0" # plugins shadow = "9.0.1" @@ -48,6 +49,7 @@ authlib-injector = { module = "org.glavo.hmcl:authlib-injector", version.ref = " # testing junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } +jimfs = { module = "com.google.jimfs:jimfs", version.ref = "jimfs" } [plugins] shadow = { id = "com.gradleup.shadow", version.ref = "shadow" }