From 36d71bd14e0ca17f3a883c404076068785691b52 Mon Sep 17 00:00:00 2001 From: Glavo Date: Mon, 22 Sep 2025 22:07:06 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E9=A2=A0=E5=80=92=E7=9A=84?= =?UTF-8?q?=E8=8B=B1=E8=AF=AD=20(#4527)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .editorconfig | 1 + .gitignore | 1 + HMCL/build.gradle.kts | 37 ++- .../jackhuang/hmcl/game/HMCLGameLauncher.java | 11 +- .../org/jackhuang/hmcl/setting/Config.java | 5 +- .../jackhuang/hmcl/ui/main/SettingsPage.java | 4 +- .../jackhuang/hmcl/ui/main/SettingsView.java | 2 +- .../org/jackhuang/hmcl/util/i18n/I18n.java | 8 +- .../org/jackhuang/hmcl/util/i18n/Locales.java | 273 ---------------- .../hmcl/util/i18n/MinecraftWiki.java | 4 +- .../hmcl/util/i18n/SupportedLocale.java | 272 ++++++++++++++++ .../hmcl/util/i18n/UpsideDownUtils.java | 68 ++++ .../resources/assets/lang/I18N.properties | 14 +- .../i18n/DefaultResourceBundleControl.java | 9 +- .../jackhuang/hmcl/util/i18n/LocaleUtils.java | 91 ++++-- .../resources/assets/lang/iso_languages.csv | 187 +++++++++++ .../resources/assets/lang/sublanguages.csv | 1 + .../hmcl/util/i18n/LocaleUtilsTest.java | 21 +- buildSrc/build.gradle.kts | 8 +- .../gradle/{ => l10n}/CheckTranslations.java | 2 +- .../hmcl/gradle/l10n/CreateLanguageList.java | 102 ++++++ .../l10n/CreateLocaleNamesResourceBundle.java | 298 ++++++++++++++++++ .../hmcl/gradle/l10n/LocalizationUtils.java | 110 +++++++ .../hmcl/gradle/l10n/UpsideDownTranslate.java | 155 +++++++++ .../l10n/LocaleNamesOverride.properties | 23 ++ .../l10n/LocaleNamesOverride_zh.properties | 23 ++ .../LocaleNamesOverride_zh_Hant.properties | 20 ++ docs/Localization_zh.md | 70 +++- 28 files changed, 1488 insertions(+), 332 deletions(-) delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/Locales.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/SupportedLocale.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/UpsideDownUtils.java create mode 100644 HMCLCore/src/main/resources/assets/lang/iso_languages.csv create mode 100644 HMCLCore/src/main/resources/assets/lang/sublanguages.csv rename buildSrc/src/main/java/org/jackhuang/hmcl/gradle/{ => l10n}/CheckTranslations.java (99%) create mode 100644 buildSrc/src/main/java/org/jackhuang/hmcl/gradle/l10n/CreateLanguageList.java create mode 100644 buildSrc/src/main/java/org/jackhuang/hmcl/gradle/l10n/CreateLocaleNamesResourceBundle.java create mode 100644 buildSrc/src/main/java/org/jackhuang/hmcl/gradle/l10n/LocalizationUtils.java create mode 100644 buildSrc/src/main/java/org/jackhuang/hmcl/gradle/l10n/UpsideDownTranslate.java create mode 100644 buildSrc/src/main/resources/org/jackhuang/hmcl/gradle/l10n/LocaleNamesOverride.properties create mode 100644 buildSrc/src/main/resources/org/jackhuang/hmcl/gradle/l10n/LocaleNamesOverride_zh.properties create mode 100644 buildSrc/src/main/resources/org/jackhuang/hmcl/gradle/l10n/LocaleNamesOverride_zh_Hant.properties diff --git a/.editorconfig b/.editorconfig index 9a49455b3..46fa5e8dd 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1071,6 +1071,7 @@ ij_jsp_keep_indents_on_empty_lines = false ij_jspx_keep_indents_on_empty_lines = false [{*.markdown,*.md}] +max_line_length = 200 ij_markdown_force_one_space_after_blockquote_symbol = true ij_markdown_force_one_space_after_header_symbol = true ij_markdown_force_one_space_after_list_bullet = true diff --git a/.gitignore b/.gitignore index b5fe10e98..8880c9572 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* +*.hprof .gradle diff --git a/HMCL/build.gradle.kts b/HMCL/build.gradle.kts index dc7d87369..5b5607e9a 100644 --- a/HMCL/build.gradle.kts +++ b/HMCL/build.gradle.kts @@ -1,4 +1,7 @@ -import org.jackhuang.hmcl.gradle.CheckTranslations +import org.jackhuang.hmcl.gradle.l10n.CheckTranslations +import org.jackhuang.hmcl.gradle.l10n.CreateLanguageList +import org.jackhuang.hmcl.gradle.l10n.CreateLocaleNamesResourceBundle +import org.jackhuang.hmcl.gradle.l10n.UpsideDownTranslate import org.jackhuang.hmcl.gradle.mod.ParseModDataTask import java.net.URI import java.nio.file.FileSystems @@ -206,11 +209,20 @@ tasks.shadowJar { tasks.processResources { dependsOn(createPropertiesFile) + dependsOn(upsideDownTranslate) + dependsOn(createLocaleNamesResourceBundle) + dependsOn(createLanguageList) into("assets/") { from(hmclPropertiesFile) from(embedResources) } + + into("assets/lang") { + from(createLanguageList.map { it.outputFile }) + from(upsideDownTranslate.map { it.outputFile }) + from(createLocaleNamesResourceBundle.map { it.outputDirectory }) + } } val makeExecutables by tasks.registering { @@ -344,6 +356,29 @@ tasks.register("checkTranslations") { classicalChineseFile.set(dir.file("I18N_lzh.properties")) } +// l10n + +val generatedDir = layout.buildDirectory.dir("generated") + +val upsideDownTranslate by tasks.registering(UpsideDownTranslate::class) { + inputFile.set(layout.projectDirectory.file("src/main/resources/assets/lang/I18N.properties")) + outputFile.set(generatedDir.map { it.file("generated/i18n/I18N_en_Qabs.properties") }) +} + +val createLanguageList by tasks.registering(CreateLanguageList::class) { + resourceBundleDir.set(layout.projectDirectory.dir("src/main/resources/assets/lang")) + resourceBundleBaseName.set("I18N") + additionalLanguages.set(listOf("en-Qabs")) + outputFile.set(generatedDir.map { it.file("languages.json") }) +} + +val createLocaleNamesResourceBundle by tasks.registering(CreateLocaleNamesResourceBundle::class) { + dependsOn(createLanguageList) + + languagesFile.set(createLanguageList.flatMap { it.outputFile }) + outputDirectory.set(generatedDir.map { it.dir("generated/LocaleNames") }) +} + // mcmod data tasks.register("parseModData") { 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 4aac0c7fd..8278297c8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java @@ -80,8 +80,6 @@ public final class HMCLGameLauncher extends DefaultLauncher { } Locale locale = Locale.getDefault(); - if (LocaleUtils.isEnglish(locale)) - return; /* * 1.0 : No language option, do not set for these versions @@ -112,7 +110,7 @@ public final class HMCLGameLauncher extends DefaultLauncher { private static String normalizedLanguageTag(Locale locale, GameVersionNumber gameVersion) { String region = locale.getCountry(); - return switch (LocaleUtils.getISO1Language(locale)) { + return switch (LocaleUtils.getISO2Language(locale)) { case "es" -> "es_ES"; case "ja" -> "ja_JP"; case "ru" -> "ru_RU"; @@ -129,6 +127,13 @@ public final class HMCLGameLauncher extends DefaultLauncher { } yield "zh_CN"; } + case "en" -> { + if ("Qabs".equals(LocaleUtils.getScript(locale)) && gameVersion.compareTo("1.16") >= 0) { + yield "en_UD"; + } + + yield ""; + } default -> ""; }; } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java index 4e06560a1..24e8acfd6 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java @@ -37,8 +37,7 @@ import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; import org.jackhuang.hmcl.java.JavaRuntime; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.util.gson.*; -import org.jackhuang.hmcl.util.i18n.Locales; -import org.jackhuang.hmcl.util.i18n.Locales.SupportedLocale; +import org.jackhuang.hmcl.util.i18n.SupportedLocale; import org.jackhuang.hmcl.util.javafx.DirtyTracker; import org.jackhuang.hmcl.util.javafx.ObservableHelper; import org.jetbrains.annotations.Nullable; @@ -222,7 +221,7 @@ public final class Config implements Observable { } @SerializedName("localization") - private final ObjectProperty localization = new SimpleObjectProperty<>(Locales.DEFAULT); + private final ObjectProperty localization = new SimpleObjectProperty<>(SupportedLocale.DEFAULT); public ObjectProperty localizationProperty() { return localization; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsPage.java index 692b1378e..ccc97f26e 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsPage.java @@ -33,7 +33,7 @@ import org.jackhuang.hmcl.upgrade.UpdateChannel; import org.jackhuang.hmcl.upgrade.UpdateChecker; import org.jackhuang.hmcl.upgrade.UpdateHandler; import org.jackhuang.hmcl.util.StringUtils; -import org.jackhuang.hmcl.util.i18n.Locales; +import org.jackhuang.hmcl.util.i18n.SupportedLocale; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.io.IOUtils; import org.tukaani.xz.XZInputStream; @@ -65,7 +65,7 @@ public final class SettingsPage extends SettingsView { FXUtils.smoothScrolling(scroll); // ==== Languages ==== - cboLanguage.getItems().setAll(Locales.LOCALES); + cboLanguage.getItems().setAll(SupportedLocale.getSupportedLocales()); selectedItemPropertyFor(cboLanguage).bindBidirectional(config().localizationProperty()); disableAutoGameOptionsPane.selectedProperty().bindBidirectional(config().disableAutoGameOptionsProperty()); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsView.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsView.java index b6d31e4f9..eaf3e1900 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsView.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsView.java @@ -39,7 +39,7 @@ import org.jackhuang.hmcl.ui.construct.ComponentSublist; import org.jackhuang.hmcl.ui.construct.MultiFileItem; import org.jackhuang.hmcl.ui.construct.OptionToggleButton; import org.jackhuang.hmcl.util.i18n.I18n; -import org.jackhuang.hmcl.util.i18n.Locales.SupportedLocale; +import org.jackhuang.hmcl.util.i18n.SupportedLocale; import java.util.Arrays; 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 440b58d82..d54af4464 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 @@ -19,7 +19,6 @@ 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; @@ -34,7 +33,7 @@ public final class I18n { private I18n() { } - private static volatile SupportedLocale locale = Locales.DEFAULT; + private static volatile SupportedLocale locale = SupportedLocale.DEFAULT; public static void setLocale(SupportedLocale locale) { I18n.locale = locale; @@ -71,6 +70,11 @@ public final class I18n { else return WenyanUtils.translateGenericVersion(version.getSelfVersion()); } + + if (LocaleUtils.isEnglish(locale.getLocale()) && "Qabs".equals(LocaleUtils.getScript(locale.getLocale()))) { + return UpsideDownUtils.translate(version.getSelfVersion()); + } + return version.getSelfVersion(); } 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 deleted file mode 100644 index e0bfe99f8..000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/Locales.java +++ /dev/null @@ -1,273 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2021 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.annotations.JsonAdapter; -import com.google.gson.stream.JsonReader; -import com.google.gson.stream.JsonWriter; -import org.jackhuang.hmcl.util.StringUtils; - -import java.io.IOException; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import java.time.temporal.TemporalAccessor; -import java.util.*; - -import static org.jackhuang.hmcl.util.logging.Logger.LOG; - -public final class Locales { - private Locales() { - } - - - public static final SupportedLocale DEFAULT; - - static { - String language = System.getenv("HMCL_LANGUAGE"); - DEFAULT = new SupportedLocale(true, "def", - StringUtils.isBlank(language) ? LocaleUtils.SYSTEM_DEFAULT : Locale.forLanguageTag(language)); - } - - /** - * English - */ - public static final SupportedLocale EN = new SupportedLocale("en"); - - /** - * Spanish - */ - public static final SupportedLocale ES = new SupportedLocale("es"); - - /** - * Russian - */ - public static final SupportedLocale RU = new SupportedLocale("ru"); - - /** - * Ukrainian - */ - public static final SupportedLocale UK = new SupportedLocale("uk"); - - /** - * Japanese - */ - public static final SupportedLocale JA = new SupportedLocale("ja"); - - /** - * Chinese (Simplified) - */ - 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", LocaleUtils.LOCALE_ZH_HANT); - - /** - * Wenyan (Classical Chinese) - */ - public static final SupportedLocale WENYAN = new SupportedLocale("lzh"); - - public static final List LOCALES = List.of(DEFAULT, EN, ES, JA, RU, UK, ZH_HANS, ZH_HANT, WENYAN); - - public static SupportedLocale getLocaleByName(String name) { - if (name == null) return DEFAULT; - - for (SupportedLocale locale : LOCALES) { - if (locale.getName().equalsIgnoreCase(name)) - return locale; - } - - return DEFAULT; - } - - @JsonAdapter(SupportedLocale.TypeAdapter.class) - public static final class SupportedLocale { - private final boolean isDefault; - private final String name; - private final Locale locale; - private ResourceBundle resourceBundle; - private DateTimeFormatter dateTimeFormatter; - private List candidateLocales; - - SupportedLocale(boolean isDefault, String name, Locale locale) { - this.isDefault = isDefault; - this.name = name; - this.locale = locale; - } - - SupportedLocale(String name) { - this(false, name, Locale.forLanguageTag(name)); - } - - SupportedLocale(String name, Locale locale) { - this(false, name, locale); - } - - public boolean isDefault() { - return isDefault; - } - - public String getName() { - return name; - } - - public Locale getLocale() { - return locale; - } - - public String getDisplayName(SupportedLocale inLocale) { - if (isDefault()) { - try { - return inLocale.getResourceBundle().getString("lang.default"); - } catch (Throwable e) { - LOG.warning("Failed to get localized name for default locale", e); - return "Default"; - } - } - - Locale inJavaLocale = inLocale.getLocale(); - if (inJavaLocale.getLanguage().length() > 2) { - String iso1 = LocaleUtils.getISO1Language(inJavaLocale); - if (iso1.length() <= 2) { - Locale.Builder builder = new Locale.Builder() - .setLocale(inJavaLocale) - .setLanguage(iso1); - - if (inJavaLocale.getScript().isEmpty()) - builder.setScript(LocaleUtils.getScript(inJavaLocale)); - - inJavaLocale = builder.build(); - } - } - - if (this.locale.getLanguage().equals("lzh")) { - if (inJavaLocale.getLanguage().equals("zh")) - return "文言"; - - String name = locale.getDisplayName(inJavaLocale); - return name.equals("lzh") || name.equals("Literary Chinese") - ? "Chinese (Classical)" - : name; - } - - return locale.getDisplayName(inJavaLocale); - } - - public ResourceBundle getResourceBundle() { - ResourceBundle bundle = resourceBundle; - if (resourceBundle == null) - 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); - } catch (MissingResourceException e) { - LOG.error("Cannot find key " + key + " in resource bundle", e); - } catch (IllegalFormatException e) { - LOG.error("Illegal format string, key=" + key + ", args=" + Arrays.toString(formatArgs), e); - } - - return key + Arrays.toString(formatArgs); - } - - public String i18n(String key) { - try { - return getResourceBundle().getString(key); - } catch (MissingResourceException e) { - LOG.error("Cannot find key " + key + " in resource bundle", e); - return key; - } - } - - public String formatDateTime(TemporalAccessor time) { - DateTimeFormatter formatter = dateTimeFormatter; - 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 getFcMatchPattern() { - String language = locale.getLanguage(); - String region = locale.getCountry(); - - if (LocaleUtils.isEnglish(locale)) - return ""; - - if (LocaleUtils.isChinese(locale)) { - String lang; - String charset; - - 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; - } - - return region.isEmpty() ? language : language + "-" + region; - } - - public boolean isSameLanguage(SupportedLocale other) { - return LocaleUtils.getISO1Language(this.getLocale()) - .equals(LocaleUtils.getISO1Language(other.getLocale())); - } - - public static final class TypeAdapter extends com.google.gson.TypeAdapter { - @Override - public void write(JsonWriter out, SupportedLocale value) throws IOException { - out.value(value.getName()); - } - - @Override - public SupportedLocale read(JsonReader in) throws IOException { - return getLocaleByName(in.nextString()); - } - } - } - -} 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 af91f6891..ac1b5536e 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 @@ -27,7 +27,7 @@ public final class MinecraftWiki { private static final Pattern SNAPSHOT_PATTERN = Pattern.compile("^[0-9]{2}w[0-9]{2}.+$"); - public static String getWikiLink(Locales.SupportedLocale locale, GameRemoteVersion version) { + public static String getWikiLink(SupportedLocale locale, GameRemoteVersion version) { String wikiVersion = version.getSelfVersion(); var gameVersion = GameVersionNumber.asGameVersion(wikiVersion); @@ -41,7 +41,7 @@ public final class MinecraftWiki { translatedVersion = WenyanUtils.translateGameVersion(gameVersion); if (translatedVersion.equals(gameVersion.toString()) || gameVersion instanceof GameVersionNumber.Old) { - return getWikiLink(Locales.ZH_HANT, version); + return getWikiLink(SupportedLocale.getLocale(LocaleUtils.LOCALE_ZH_HANT), version); } else if (SNAPSHOT_PATTERN.matcher(wikiVersion).matches()) { return locale.i18n("wiki.version.game.snapshot", translatedVersion); } else { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/SupportedLocale.java b/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/SupportedLocale.java new file mode 100644 index 000000000..34d45919b --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/SupportedLocale.java @@ -0,0 +1,272 @@ +/* + * 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.annotations.JsonAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.gson.JsonUtils; + +import java.io.IOException; +import java.io.InputStream; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAccessor; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +@JsonAdapter(SupportedLocale.TypeAdapter.class) +public final class SupportedLocale { + public static final SupportedLocale DEFAULT = new SupportedLocale(); + + private static final ConcurrentMap LOCALES = new ConcurrentHashMap<>(); + + public static List getSupportedLocales() { + List list = new ArrayList<>(); + list.add(DEFAULT); + + InputStream locales = SupportedLocale.class.getResourceAsStream("/assets/lang/languages.json"); + if (locales != null) { + try (locales) { + list.addAll(JsonUtils.fromNonNullJsonFully(locales, JsonUtils.listTypeOf(SupportedLocale.class))); + } catch (Throwable e) { + LOG.warning("Failed to load languages.json", e); + } + } + return List.copyOf(list); + } + + public static SupportedLocale getLocale(Locale locale) { + return LOCALES.computeIfAbsent(locale, SupportedLocale::new); + } + + public static SupportedLocale getLocaleByName(String name) { + if (name == null || name.isBlank() || "def".equals(name) || "default".equals(name)) + return DEFAULT; + + return getLocale(Locale.forLanguageTag(name.trim().replace('_', '-'))); + } + + private final boolean isDefault; + private final String name; + private final Locale locale; + private ResourceBundle resourceBundle; + private ResourceBundle localeNamesBundle; + private DateTimeFormatter dateTimeFormatter; + private List candidateLocales; + + SupportedLocale() { + this.isDefault = true; + this.name = "def"; // TODO: Change to "default" after updating the Config format + + String language = System.getenv("HMCL_LANGUAGE"); + this.locale = StringUtils.isBlank(language) + ? LocaleUtils.SYSTEM_DEFAULT + : Locale.forLanguageTag(language); + } + + SupportedLocale(Locale locale) { + this.isDefault = false; + this.name = locale.toLanguageTag(); + this.locale = locale; + } + + public boolean isDefault() { + return isDefault; + } + + public String getName() { + return name; + } + + public Locale getLocale() { + return locale; + } + + public String getDisplayName(SupportedLocale inLocale) { + if (isDefault()) { + try { + return inLocale.getResourceBundle().getString("lang.default"); + } catch (Throwable e) { + LOG.warning("Failed to get localized name for default locale", e); + return "Default"; + } + } + + Locale currentLocale = this.getLocale(); + + String language = currentLocale.getLanguage(); + String script = currentLocale.getScript(); + + // Currently, HMCL does not support any locales with regions or variants, so they are not handled for now + // String region = currentLocale.getCountry(); + // String variant = currentLocale.getDisplayVariant(); + + ResourceBundle localeNames = inLocale.getLocaleNamesBundle(); + + String languageDisplayName = language; + try { + languageDisplayName = localeNames.getString(language); + } catch (Throwable e) { + LOG.warning("Failed to get localized name for language " + language, e); + } + + if (script.isEmpty()) { + return languageDisplayName; + } + + String scriptDisplayName = script; + + try { + scriptDisplayName = localeNames.getString(script); + } catch (Throwable e) { + LOG.warning("Failed to get localized name for script " + script, e); + } + + return languageDisplayName + " (" + scriptDisplayName + ")"; + } + + public ResourceBundle getResourceBundle() { + ResourceBundle bundle = resourceBundle; + if (resourceBundle == null) + resourceBundle = bundle = ResourceBundle.getBundle("assets.lang.I18N", locale, + DefaultResourceBundleControl.INSTANCE); + + return bundle; + } + + public ResourceBundle getLocaleNamesBundle() { + ResourceBundle bundle = localeNamesBundle; + if (localeNamesBundle == null) + localeNamesBundle = bundle = ResourceBundle.getBundle("assets.lang.LocaleNames", 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); + } catch (MissingResourceException e) { + LOG.error("Cannot find key " + key + " in resource bundle", e); + } catch (IllegalFormatException e) { + LOG.error("Illegal format string, key=" + key + ", args=" + Arrays.toString(formatArgs), e); + } + + return key + Arrays.toString(formatArgs); + } + + public String i18n(String key) { + try { + return getResourceBundle().getString(key); + } catch (MissingResourceException e) { + LOG.error("Cannot find key " + key + " in resource bundle", e); + return key; + } + } + + public String formatDateTime(TemporalAccessor time) { + DateTimeFormatter formatter = dateTimeFormatter; + if (formatter == null) { + if (LocaleUtils.isEnglish(locale) && "Qabs".equals(locale.getScript())) { + return UpsideDownUtils.formatDateTime(time); + } + + 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 getFcMatchPattern() { + String language = locale.getLanguage(); + String region = locale.getCountry(); + + if (LocaleUtils.isEnglish(locale)) + return ""; + + if (LocaleUtils.isChinese(locale)) { + String lang; + String charset; + + 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; + } + + return region.isEmpty() ? language : language + "-" + region; + } + + public boolean isSameLanguage(SupportedLocale other) { + return LocaleUtils.getISO2Language(this.getLocale()) + .equals(LocaleUtils.getISO2Language(other.getLocale())); + } + + public static final class TypeAdapter extends com.google.gson.TypeAdapter { + @Override + public void write(JsonWriter out, SupportedLocale value) throws IOException { + out.value(value.getName()); + } + + @Override + public SupportedLocale read(JsonReader in) throws IOException { + if (in.peek() == JsonToken.NULL) + return DEFAULT; + + String language = in.nextString(); + return getLocaleByName(switch (language) { + // TODO: Remove these compatibility codes after updating the Config format + case "zh_CN" -> "zh-Hans"; // For compatibility + case "zh" -> "zh-Hant"; // For compatibility + default -> language; + }); + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/UpsideDownUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/UpsideDownUtils.java new file mode 100644 index 000000000..5856436a9 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/UpsideDownUtils.java @@ -0,0 +1,68 @@ +/* + * 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.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAccessor; +import java.util.LinkedHashMap; +import java.util.Map; + +/// @author Glavo +public final class UpsideDownUtils { + private static final Map MAPPER = new LinkedHashMap<>(); + + private static void putChars(char baseChar, String upsideDownChars) { + for (int i = 0; i < upsideDownChars.length(); i++) { + MAPPER.put(baseChar + i, (int) upsideDownChars.charAt(i)); + } + } + + private static void putChars(String baseChars, String upsideDownChars) { + if (baseChars.length() != upsideDownChars.length()) { + throw new IllegalArgumentException("baseChars and upsideDownChars must have same length"); + } + + for (int i = 0; i < baseChars.length(); i++) { + MAPPER.put((int) baseChars.charAt(i), (int) upsideDownChars.charAt(i)); + } + } + + static { + putChars('a', "ɐqɔpǝɟbɥıظʞןɯuodbɹsʇnʌʍxʎz"); + putChars('A', "ⱯᗺƆᗡƎℲ⅁HIſʞꞀWNOԀὉᴚS⟘∩ΛMXʎZ"); + putChars('0', "0ƖᘔƐㄣϛ9ㄥ86"); + putChars("_,;.?!/\\'", "‾'⸵˙¿¡/\\,"); + } + + public static String translate(String str) { + StringBuilder builder = new StringBuilder(str.length()); + str.codePoints().forEach(ch -> builder.appendCodePoint(MAPPER.getOrDefault(ch, ch))); + return builder.reverse().toString(); + } + + private static DateTimeFormatter BASE_FORMATTER = DateTimeFormatter.ofPattern("MMM d, yyyy, h:mm:ss a") + .withZone(ZoneId.systemDefault()); + + public static String formatDateTime(TemporalAccessor time) { + return translate(BASE_FORMATTER.format(time)); + } + + private UpsideDownUtils() { + } +} diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 56064f3d5..5010254fe 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -692,7 +692,7 @@ input.url=The input must be a valid URL. install=New Instance install.change_version=Change Version -install.change_version.confirm=Are you sure you want to switch %s from version %s to %s? +install.change_version.confirm=Are you sure you want to switch %1$s from version %2$s to %3$s? install.change_version.process=Change Version Process install.failed=Failed to install install.failed.downloading=Failed to download some required files. @@ -702,7 +702,7 @@ install.failed.install_online=Failed to identify the provided file. If you are i install.failed.malformed=The downloaded files are corrupted. You can try resolving this problem by switching to another download source in "Settings → Download → Download Source". install.failed.optifine_conflict=Cannot install both OptiFine and Fabric on Minecraft 1.13 or later. install.failed.optifine_forge_1.17=For Minecraft 1.17.1, Forge is only compatible with OptiFine H1 pre2 or later. You can install them by checking "Snapshots" when choosing an OptiFine version in HMCL. -install.failed.version_mismatch=This loader requires the game version %s, but the installed one is %s. +install.failed.version_mismatch=This loader requires the game version %1$s, but the installed one is %2$s. install.installer.change_version=%s Incompatible install.installer.choose=Choose Your %s Version install.installer.cleanroom=Cleanroom @@ -787,7 +787,7 @@ launch.advice.forge37_0_60=Forge versions prior to 37.0.60 are not compatible wi launch.advice.java8_1_13=Minecraft 1.13 and later can only be run on Java 8 or later. Please use Java 8 or later versions. launch.advice.java8_51_1_13=Minecraft 1.13 may crash on Java 8 versions prior to 1.8.0_51. Please install the latest Java 8 version. launch.advice.java9=You cannot launch Minecraft 1.12 or earlier with Java 9 or later. Please use Java 8 instead. -launch.advice.modded_java=Some mods may not be compatible with newer Java versions. It is recommended to use Java %s to launch Minecraft %s. +launch.advice.modded_java=Some mods may not be compatible with newer Java versions. It is recommended to use Java %1$s to launch Minecraft %2$s. launch.advice.modlauncher8=The Forge version you are using is not compatible with the current Java version. Please try updating Forge. launch.advice.newer_java=You are using an older Java version to launch the game. It is recommended to update to Java 8, otherwise some mods may cause the game to crash. launch.advice.not_enough_space=You have allocated a memory size larger than the actual %d MiB of memory installed on your computer. You may experience degraded performance or even be unable to launch the game. @@ -906,7 +906,7 @@ modpack.installing=Installing modpack modpack.installing.given=Installing %s modpack modpack.introduction=Curse, Modrinth, MultiMC, and MCBBS modpacks are currently supported. modpack.invalid=Invalid modpack, you can try downloading it again. -modpack.mismatched_type=Modpack type mismatched, the current instance is a(n) %s type, but the provided one is %s type. +modpack.mismatched_type=Modpack type mismatched, the current instance is a(n) %1$s type, but the provided one is %2$s type. modpack.name=Modpack Name modpack.not_a_valid_name=Invalid modpack name. modpack.origin=Source @@ -1236,7 +1236,7 @@ search.first_page=First search.previous_page=Previous search.next_page=Next search.last_page=Last -search.page_n=%d / %s +search.page_n=%1$d / %2$s selector.choose=Choose selector.choose_file=Choose file @@ -1333,7 +1333,7 @@ settings.game.java_directory.bit=%s bit settings.game.java_directory.choose=Choose Java settings.game.java_directory.invalid=Incorrect Java path settings.game.java_directory.version=Specify Java Version -settings.game.java_directory.template=%s (%s) +settings.game.java_directory.template=%1$s (%2$s) settings.game.management=Manage settings.game.working_directory=Working Directory settings.game.working_directory.choose=Choose the working directory @@ -1479,7 +1479,7 @@ version.manage.manage=Edit Instance version.manage.manage.title=Edit Instance - %1s version.manage.redownload_assets_index=Update Game Assets version.manage.remove=Delete Instance -version.manage.remove.confirm.trash=Are you sure you want to remove the instance "%s"? You can still find its files in your recycle bin by the name of "%s". +version.manage.remove.confirm.trash=Are you sure you want to remove the instance "%1$s"? You can still find its files in your recycle bin by the name of "%2$s". version.manage.remove.confirm.independent=Since this instance is stored in an isolated directory, deleting it will also delete its saves and other data. Do you still want to delete the instance "%s"? version.manage.remove_assets=Delete All Assets version.manage.remove_libraries=Delete All Libraries 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 index 75d8880a9..6c41f3984 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/i18n/DefaultResourceBundleControl.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/i18n/DefaultResourceBundleControl.java @@ -30,7 +30,7 @@ import java.util.ResourceBundle; /// - 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 [supported][LocaleUtils#mapToISO1Language(String)] ISO 639-3 language code (such as `eng`, `zho`, `lzh`, etc.), +/// - For all [supported][LocaleUtils#mapToISO2Language(String)] ISO 639-3 language code (such as `eng`, `zho`, `lzh`, etc.), /// a candidate list with the language code replaced by the ISO 639-1 (Macro)language code is added to the end of the candidate list. /// /// @author Glavo @@ -45,4 +45,11 @@ public class DefaultResourceBundleControl extends ResourceBundle.Control { public List getCandidateLocales(String baseName, Locale locale) { return LocaleUtils.getCandidateLocales(locale); } + + @Override + public Locale getFallbackLocale(String baseName, Locale locale) { + // By default, when only the base bundle is found, it will attempt to fall back to Locale.getDefault() for further lookup. + // Since we always use the base bundle as the English resource file, we want to suppress this behavior. + return null; + } } 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 2caeba500..44d60344a 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 @@ -23,6 +23,8 @@ import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Unmodifiable; import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.*; @@ -44,6 +46,56 @@ public final class LocaleUtils { public static final String DEFAULT_LANGUAGE_KEY = "default"; + private static final Map subLanguageToParent = new HashMap<>(); + private static final Map iso3To2 = new HashMap<>(); + + static { + try (InputStream input = LocaleUtils.class.getResourceAsStream("/assets/lang/sublanguages.csv")) { + if (input != null) { + new String(input.readAllBytes()).lines() + .filter(line -> !line.startsWith("#") && !line.isBlank()) + .forEach(line -> { + String[] languages = line.split(","); + if (languages.length < 2) + LOG.warning("Invalid line in sublanguages.csv: " + line); + + String parent = languages[0]; + for (int i = 1; i < languages.length; i++) { + subLanguageToParent.put(languages[i], parent); + } + }); + } + } catch (Throwable e) { + LOG.warning("Failed to load sublanguages.csv", e); + } + + // Line Format: + // (?[a-z]{2}),(?[a-z]{3}) + try (InputStream input = LocaleUtils.class.getResourceAsStream("/assets/lang/iso_languages.csv")) { + if (input != null) { + int lineLength = 2 + 1 + 3; + + byte[] bytes = input.readAllBytes(); + for (int offset = 0; offset < bytes.length; ) { + if (offset > bytes.length - lineLength) + break; + + if (bytes[offset + 2] != ',') + throw new IOException("iso_languages.csv format invalid"); + + String iso2 = new String(bytes, offset, 2, StandardCharsets.US_ASCII); + String iso3 = new String(bytes, offset + 3, 3, StandardCharsets.US_ASCII); + + iso3To2.put(iso3, iso2); + + offset += (lineLength + 1); + } + } + } catch (Throwable e) { + LOG.warning("Failed to load iso_languages.csv", e); + } + } + private static Locale getInstance(String language, String script, String region, String variant) { Locale.Builder builder = new Locale.Builder(); @@ -64,7 +116,7 @@ public final class LocaleUtils { : locale.stripExtensions().toLanguageTag(); } - public static @NotNull String getISO1Language(Locale locale) { + public static @NotNull String getISO2Language(Locale locale) { String language = locale.getLanguage(); if (language.isEmpty()) return "en"; if (language.length() <= 2) @@ -75,7 +127,7 @@ public final class LocaleUtils { if (lang.length() <= 2) return lang; else { - String iso1 = mapToISO1Language(lang); + String iso1 = mapToISO2Language(lang); if (iso1 != null) return iso1; } @@ -88,6 +140,12 @@ public final class LocaleUtils { /// 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 (isEnglish(locale)) { + if ("UD".equals(locale.getCountry())) { + return "Qabs"; + } + } + if (isChinese(locale)) { if (CHINESE_LATN_VARIANTS.contains(locale.getVariant())) return "Latn"; @@ -130,7 +188,7 @@ public final class LocaleUtils { } else if (language.length() <= 2) { languages = List.of(language); } else { - String iso1Language = mapToISO1Language(language); + String iso1Language = mapToISO2Language(language); languages = iso1Language != null ? List.of(language, iso1Language) : List.of(language); @@ -294,37 +352,26 @@ public final class LocaleUtils { // --- - /// Map ISO 639-3 language codes to ISO 639-1 language codes. - public static @Nullable String mapToISO1Language(String iso3Language) { - return switch (iso3Language) { - case "eng" -> "en"; - case "spa" -> "es"; - case "jpa" -> "ja"; - case "rus" -> "ru"; - case "ukr" -> "uk"; - case "zho" -> "zh"; - default -> null; - }; + /// Map ISO 639 alpha-3 language codes to ISO 639 alpha-2 language codes. + public static @Nullable String mapToISO2Language(String iso3Language) { + return iso3To2.get(iso3Language); } public static @Nullable String getParentLanguage(String language) { - return switch (language) { - case "cmn", "lzh", "cdo", "cjy", "cpx", "czh", - "gan", "hak", "hsn", "mnp", "nan", "wuu", "yue" -> "zh"; - case "" -> null; - default -> ""; - }; + return !language.isEmpty() + ? subLanguageToParent.getOrDefault(language, "") + : null; } public static boolean isEnglish(Locale locale) { - return "en".equals(getISO1Language(locale)); + return "en".equals(getISO2Language(locale)); } 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 boolean isChinese(Locale locale) { - return "zh".equals(getISO1Language(locale)); + return "zh".equals(getISO2Language(locale)); } private LocaleUtils() { diff --git a/HMCLCore/src/main/resources/assets/lang/iso_languages.csv b/HMCLCore/src/main/resources/assets/lang/iso_languages.csv new file mode 100644 index 000000000..732d28d7e --- /dev/null +++ b/HMCLCore/src/main/resources/assets/lang/iso_languages.csv @@ -0,0 +1,187 @@ +aa,aar +ab,abk +ae,ave +af,afr +ak,aka +am,amh +an,arg +ar,ara +as,asm +av,ava +ay,aym +az,aze +ba,bak +be,bel +bg,bul +bh,bih +bi,bis +bm,bam +bn,ben +bo,bod +br,bre +bs,bos +ca,cat +ce,che +ch,cha +co,cos +cr,cre +cs,ces +cu,chu +cv,chv +cy,cym +da,dan +de,deu +dv,div +dz,dzo +ee,ewe +el,ell +en,eng +eo,epo +es,spa +et,est +eu,eus +fa,fas +ff,ful +fi,fin +fj,fij +fo,fao +fr,fra +fy,fry +ga,gle +gd,gla +gl,glg +gn,grn +gu,guj +gv,glv +ha,hau +he,heb +hi,hin +ho,hmo +hr,hrv +ht,hat +hu,hun +hy,hye +hz,her +ia,ina +id,ind +ie,ile +ig,ibo +ii,iii +ik,ipk +in,ind +io,ido +is,isl +it,ita +iu,iku +iw,heb +ja,jpn +ji,yid +jv,jav +ka,kat +kg,kon +ki,kik +kj,kua +kk,kaz +kl,kal +km,khm +kn,kan +ko,kor +kr,kau +ks,kas +ku,kur +kv,kom +kw,cor +ky,kir +la,lat +lb,ltz +lg,lug +li,lim +ln,lin +lo,lao +lt,lit +lu,lub +lv,lav +mg,mlg +mh,mah +mi,mri +mk,mkd +ml,mal +mn,mon +mo,mol +mr,mar +ms,msa +mt,mlt +my,mya +na,nau +nb,nob +nd,nde +ne,nep +ng,ndo +nl,nld +nn,nno +no,nor +nr,nbl +nv,nav +ny,nya +oc,oci +oj,oji +om,orm +or,ori +os,oss +pa,pan +pi,pli +pl,pol +ps,pus +pt,por +qu,que +rm,roh +rn,run +ro,ron +ru,rus +rw,kin +sa,san +sc,srd +sd,snd +se,sme +sg,sag +si,sin +sk,slk +sl,slv +sm,smo +sn,sna +so,som +sq,sqi +sr,srp +ss,ssw +st,sot +su,sun +sv,swe +sw,swa +ta,tam +te,tel +tg,tgk +th,tha +ti,tir +tk,tuk +tl,tgl +tn,tsn +to,ton +tr,tur +ts,tso +tt,tat +tw,twi +ty,tah +ug,uig +uk,ukr +ur,urd +uz,uzb +ve,ven +vi,vie +vo,vol +wa,wln +wo,wol +xh,xho +yi,yid +za,zha +zh,zho +zu,zul \ No newline at end of file diff --git a/HMCLCore/src/main/resources/assets/lang/sublanguages.csv b/HMCLCore/src/main/resources/assets/lang/sublanguages.csv new file mode 100644 index 000000000..1ed422f35 --- /dev/null +++ b/HMCLCore/src/main/resources/assets/lang/sublanguages.csv @@ -0,0 +1 @@ +zh,cmn,lzh,cdo,cjy,cpx,czh,gan,hak,hsn,mnp,nan,wuu,yue \ No newline at end of file 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 2e4678368..906e51e07 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 @@ -72,8 +72,8 @@ public final class LocaleUtilsTest { assertCandidateLocales("ja", List.of("ja", "und")); assertCandidateLocales("ja-JP", List.of("ja-JP", "ja", "und")); - assertCandidateLocales("jpa", List.of("jpa", "ja", "und")); - assertCandidateLocales("jpa-JP", List.of("jpa-JP", "ja-JP", "jpa", "ja", "und")); + assertCandidateLocales("jpn", List.of("jpn", "ja", "und")); + assertCandidateLocales("jpn-JP", List.of("jpn-JP", "ja-JP", "jpn", "ja", "und")); assertCandidateLocales("en", List.of("en", "und")); assertCandidateLocales("en-US", List.of("en-US", "en", "und")); @@ -190,4 +190,21 @@ public final class LocaleUtilsTest { LocaleUtils.findAllLocalizedFiles(testDir, "meow", Set.of("json", "toml"))); } } + + @Test + public void testMapToISO2Language() throws IOException { + assertEquals("en", LocaleUtils.mapToISO2Language("eng")); + assertEquals("es", LocaleUtils.mapToISO2Language("spa")); + assertEquals("ja", LocaleUtils.mapToISO2Language("jpn")); + assertEquals("ru", LocaleUtils.mapToISO2Language("rus")); + assertEquals("uk", LocaleUtils.mapToISO2Language("ukr")); + assertEquals("zh", LocaleUtils.mapToISO2Language("zho")); + assertEquals("zu", LocaleUtils.mapToISO2Language("zul")); + + assertNull(LocaleUtils.mapToISO2Language(null)); + assertNull(LocaleUtils.mapToISO2Language("")); + assertNull(LocaleUtils.mapToISO2Language("cmn")); + assertNull(LocaleUtils.mapToISO2Language("lzh")); + assertNull(LocaleUtils.mapToISO2Language("tlh")); + } } diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 90d7f7e4a..ad5c14669 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -10,4 +10,10 @@ dependencies { java { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 -} \ No newline at end of file +} + +tasks.processResources { + into("org/jackhuang/hmcl/gradle/l10n") { + from(projectDir.resolve("../HMCLCore/src/main/resources/assets/lang/")) + } +} diff --git a/buildSrc/src/main/java/org/jackhuang/hmcl/gradle/CheckTranslations.java b/buildSrc/src/main/java/org/jackhuang/hmcl/gradle/l10n/CheckTranslations.java similarity index 99% rename from buildSrc/src/main/java/org/jackhuang/hmcl/gradle/CheckTranslations.java rename to buildSrc/src/main/java/org/jackhuang/hmcl/gradle/l10n/CheckTranslations.java index c64384681..ad634ccb1 100644 --- a/buildSrc/src/main/java/org/jackhuang/hmcl/gradle/CheckTranslations.java +++ b/buildSrc/src/main/java/org/jackhuang/hmcl/gradle/l10n/CheckTranslations.java @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.jackhuang.hmcl.gradle; +package org.jackhuang.hmcl.gradle.l10n; import org.gradle.api.DefaultTask; import org.gradle.api.GradleException; diff --git a/buildSrc/src/main/java/org/jackhuang/hmcl/gradle/l10n/CreateLanguageList.java b/buildSrc/src/main/java/org/jackhuang/hmcl/gradle/l10n/CreateLanguageList.java new file mode 100644 index 000000000..0f2d9380a --- /dev/null +++ b/buildSrc/src/main/java/org/jackhuang/hmcl/gradle/l10n/CreateLanguageList.java @@ -0,0 +1,102 @@ +/* + * 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.gradle.l10n; + +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputDirectory; +import org.gradle.api.tasks.OutputFile; +import org.gradle.api.tasks.TaskAction; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.stream.Collectors; + +/// @author Glavo +public abstract class CreateLanguageList extends DefaultTask { + @InputDirectory + public abstract DirectoryProperty getResourceBundleDir(); + + @Input + public abstract Property<@NotNull String> getResourceBundleBaseName(); + + @Input + public abstract ListProperty<@NotNull String> getAdditionalLanguages(); + + @OutputFile + public abstract RegularFileProperty getOutputFile(); + + @TaskAction + public void run() throws IOException { + Path inputDir = getResourceBundleDir().get().getAsFile().toPath(); + if (!Files.isDirectory(inputDir)) + throw new GradleException("Input directory not exists: " + inputDir); + + + SortedSet locales = new TreeSet<>(LocalizationUtils::compareLocale); + locales.addAll(getAdditionalLanguages().getOrElse(List.of()).stream() + .map(Locale::forLanguageTag) + .toList()); + + String baseName = getResourceBundleBaseName().get(); + String suffix = ".properties"; + + try (var stream = Files.newDirectoryStream(inputDir, file -> { + String fileName = file.getFileName().toString(); + return fileName.startsWith(baseName) && fileName.endsWith(suffix); + })) { + for (Path file : stream) { + String fileName = file.getFileName().toString(); + if (fileName.length() == baseName.length() + suffix.length()) + locales.add(Locale.ENGLISH); + else if (fileName.charAt(baseName.length()) == '_') { + String localeName = fileName.substring(baseName.length() + 1, fileName.length() - suffix.length()); + + // TODO: Delete this if the I18N file naming is changed + if (baseName.equals("I18N")) { + if (localeName.equals("zh")) + locales.add(Locale.forLanguageTag("zh-Hant")); + else if (localeName.equals("zh_CN")) + locales.add(Locale.forLanguageTag("zh-Hans")); + else + locales.add(Locale.forLanguageTag(localeName.replace('_', '-'))); + } else { + if (localeName.equals("zh")) + locales.add(Locale.forLanguageTag("zh-Hans")); + else + locales.add(Locale.forLanguageTag(localeName.replace('_', '-'))); + } + } + } + } + + Path outputFile = getOutputFile().get().getAsFile().toPath(); + Files.createDirectories(outputFile.getParent()); + Files.writeString(outputFile, locales.stream().map(locale -> '"' + locale.toLanguageTag() + '"') + .collect(Collectors.joining(", ", "[", "]"))); + } + +} diff --git a/buildSrc/src/main/java/org/jackhuang/hmcl/gradle/l10n/CreateLocaleNamesResourceBundle.java b/buildSrc/src/main/java/org/jackhuang/hmcl/gradle/l10n/CreateLocaleNamesResourceBundle.java new file mode 100644 index 000000000..83ab34d03 --- /dev/null +++ b/buildSrc/src/main/java/org/jackhuang/hmcl/gradle/l10n/CreateLocaleNamesResourceBundle.java @@ -0,0 +1,298 @@ +/* + * 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.gradle.l10n; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.OutputDirectory; +import org.gradle.api.tasks.TaskAction; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.*; +import java.util.stream.Collectors; + +/// @author Glavo +public abstract class CreateLocaleNamesResourceBundle extends DefaultTask { + + @InputFile + public abstract RegularFileProperty getLanguagesFile(); + + @OutputDirectory + public abstract DirectoryProperty getOutputDirectory(); + + private static String mapToFileName(String base, String ext, Locale locale) { + if (locale.equals(Locale.ENGLISH)) + return base + "." + ext; + else if (locale.toLanguageTag().equals("zh-Hans")) + return base + "_zh." + ext; + else + return base + "_" + locale.toLanguageTag().replace('-', '_') + "." + ext; + } + + private static final ResourceBundle.Control CONTROL = new ResourceBundle.Control() { + }; + + @TaskAction + public void run() throws IOException { + Path languagesFile = getLanguagesFile().get().getAsFile().toPath(); + Path outputDir = getOutputDirectory().get().getAsFile().toPath(); + + if (Files.isDirectory(outputDir)) { + Files.walkFileTree(outputDir, new SimpleFileVisitor<>() { + @Override + public @NotNull FileVisitResult visitFile(@NotNull Path file, @NotNull BasicFileAttributes attrs) throws IOException { + Files.deleteIfExists(file); + return FileVisitResult.CONTINUE; + } + + @Override + public @NotNull FileVisitResult postVisitDirectory(@NotNull Path dir, @Nullable IOException exc) throws IOException { + if (!dir.equals(outputDir)) + Files.deleteIfExists(dir); + return FileVisitResult.CONTINUE; + } + }); + } + Files.deleteIfExists(outputDir); + Files.createDirectories(outputDir); + + List supportedLanguages; + try (var reader = Files.newBufferedReader(languagesFile)) { + supportedLanguages = new Gson().fromJson(reader, new TypeToken>() { + }).stream() + .map(Locale::forLanguageTag) + .sorted(LocalizationUtils::compareLocale) + .toList(); + } + + if (!supportedLanguages.contains(Locale.ENGLISH)) + throw new GradleException("Missing english in supported languages: " + supportedLanguages); + + // Ensure English is at the first position, this assumption will be used later + if (!supportedLanguages.get(0).equals(Locale.ENGLISH)) { + supportedLanguages = new ArrayList<>(supportedLanguages); + supportedLanguages.remove(Locale.ENGLISH); + supportedLanguages.add(0, Locale.ENGLISH); + } + + EnumMap> names = new EnumMap<>(LocaleField.class); + for (LocaleField field : LocaleField.values()) { + names.put(field, supportedLanguages.stream() + .map(field::get) + .filter(it -> !it.isBlank()) + .collect(Collectors.toCollection(() -> new TreeSet<>(field)))); + } + + Map overrides = new HashMap<>(); + for (Locale currentLanguage : supportedLanguages) { + InputStream overrideFile = CreateLocaleNamesResourceBundle.class.getResourceAsStream( + mapToFileName("LocaleNamesOverride", "properties", currentLanguage)); + Properties overrideProperties = new Properties(); + if (overrideFile != null) { + try (var reader = new InputStreamReader(overrideFile, StandardCharsets.UTF_8)) { + overrideProperties.load(reader); + } + } + overrides.put(currentLanguage, overrideProperties); + } + + Map allLocaleNames = new HashMap<>(); + + // For Upside Down English + UpsideDownTranslate.Translator upsideDownTranslator = new UpsideDownTranslate.Translator(); + for (Locale currentLocale : supportedLanguages) { + Properties currentOverrides = overrides.get(currentLocale); + if (currentLocale.getLanguage().length() > 2 && currentOverrides.isEmpty()) { + // The JDK does not provide localized texts for these languages + continue; + } + + LocaleNames currentDisplayNames = new LocaleNames(); + + for (LocaleField field : LocaleField.values()) { + SortedMap nameToDisplayName = currentDisplayNames.getNameToDisplayName(field); + + loop: + for (String name : names.get(field)) { + String displayName = currentOverrides.getProperty(name); + + getDisplayName: + if (displayName == null) { + if (currentLocale.equals(UpsideDownTranslate.EN_QABS)) { + String englishDisplayName = allLocaleNames.get(Locale.ENGLISH).getNameToDisplayName(field).get(name); + if (englishDisplayName != null) { + displayName = upsideDownTranslator.translate(englishDisplayName); + break getDisplayName; + } + } + + // Although it cannot correctly handle the inheritance relationship between languages, + // we will not apply this function to sublanguages. + List candidateLocales = CONTROL.getCandidateLocales("", currentLocale); + + for (Locale candidateLocale : candidateLocales) { + Properties candidateOverride = overrides.get(candidateLocale); + if (candidateOverride != null && candidateOverride.containsKey(name)) { + continue loop; + } + } + + displayName = field.getDisplayName(name, currentLocale); + + // JDK does not have a built-in translation + if (displayName.isBlank() || displayName.equals(name)) { + continue loop; + } + + // If it is just a duplicate of the parent content, ignored it + for (Locale candidateLocale : candidateLocales) { + LocaleNames candidateLocaleNames = allLocaleNames.get(candidateLocale); + if (candidateLocaleNames != null) { + String candidateDisplayName = candidateLocaleNames.getNameToDisplayName(field).get(name); + if (displayName.equals(candidateDisplayName)) { + continue loop; + } + break; + } + } + + // Ignore it if the JDK falls back to English when querying the display name + if (!currentLocale.equals(Locale.ENGLISH) + && displayName.equals(allLocaleNames.get(Locale.ENGLISH).getNameToDisplayName(field).get(name))) { + continue loop; + } + } + + nameToDisplayName.put(name, displayName); + } + } + + allLocaleNames.put(currentLocale, currentDisplayNames); + } + + for (Map.Entry entry : allLocaleNames.entrySet()) { + if (!entry.getValue().isEmpty()) { + Path targetFile = outputDir.resolve(mapToFileName("LocaleNames", "properties", entry.getKey())); + entry.getValue().writeTo(targetFile); + } + } + } + + private static final class LocaleNames { + private final EnumMap> displayNames = new EnumMap<>(LocaleField.class); + + LocaleNames() { + for (LocaleField field : LocaleField.values()) { + displayNames.put(field, new TreeMap<>(field)); + } + } + + boolean isEmpty() { + return displayNames.values().stream().allMatch(Map::isEmpty); + } + + SortedMap getNameToDisplayName(LocaleField field) { + return displayNames.get(field); + } + + void writeTo(Path file) throws IOException { + try (var writer = Files.newBufferedWriter(file, StandardOpenOption.CREATE_NEW)) { + boolean firstBlock = true; + + for (var entry : displayNames.entrySet()) { + LocaleField field = entry.getKey(); + SortedMap values = entry.getValue(); + + if (!values.isEmpty()) { + if (firstBlock) + firstBlock = false; + else + writer.newLine(); + + writer.write("# " + field.blockHeader + "\n"); + + for (var nameToDisplay : values.entrySet()) { + writer.write(nameToDisplay.getKey() + "=" + nameToDisplay.getValue() + "\n"); + } + } + } + } + } + } + + private enum LocaleField implements Comparator { + LANGUAGE("Languages") { + @Override + public String get(Locale locale) { + return locale.getLanguage(); + } + + @Override + public String getDisplayName(String fieldValue, Locale inLocale) { + return new Locale.Builder() + .setLanguage(fieldValue) + .build() + .getDisplayLanguage(inLocale); + } + + @Override + public int compare(String l1, String l2) { + return LocalizationUtils.compareLanguage(l1, l2); + } + }, + SCRIPT("Scripts") { + @Override + public String get(Locale locale) { + return locale.getScript(); + } + + @Override + public String getDisplayName(String fieldValue, Locale inLocale) { + return new Locale.Builder() + .setScript(fieldValue) + .build() + .getDisplayScript(inLocale); + } + + @Override + public int compare(String s1, String s2) { + return LocalizationUtils.compareScript(s1, s2); + } + }; + + final String blockHeader; + + LocaleField(String blockHeader) { + this.blockHeader = blockHeader; + } + + public abstract String get(Locale locale); + + public abstract String getDisplayName(String fieldValue, Locale inLocale); + } +} diff --git a/buildSrc/src/main/java/org/jackhuang/hmcl/gradle/l10n/LocalizationUtils.java b/buildSrc/src/main/java/org/jackhuang/hmcl/gradle/l10n/LocalizationUtils.java new file mode 100644 index 000000000..805c5f167 --- /dev/null +++ b/buildSrc/src/main/java/org/jackhuang/hmcl/gradle/l10n/LocalizationUtils.java @@ -0,0 +1,110 @@ +/* + * 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.gradle.l10n; + +import org.gradle.api.GradleException; + +import java.io.IOException; +import java.io.InputStream; +import java.util.*; + +final class LocalizationUtils { + public static final Map subLanguageToParent; + + static { + InputStream input = LocalizationUtils.class.getResourceAsStream("sublanguages.csv"); + if (input == null) + throw new GradleException("Missing sublanguages.csv file"); + + Map map = new HashMap<>(); + try (input) { + new String(input.readAllBytes()).lines() + .filter(line -> !line.startsWith("#") && !line.isBlank()) + .forEach(line -> { + String[] languages = line.split(","); + if (languages.length < 2) + throw new GradleException("Invalid line in sublanguages.csv: " + line); + + String parent = languages[0]; + for (int i = 1; i < languages.length; i++) { + map.put(languages[i], parent); + } + }); + } catch (IOException e) { + throw new GradleException("Failed to read sublanguages.csv", e); + } + subLanguageToParent = Collections.unmodifiableMap(map); + } + + private static List resolveLanguage(String language) { + List langList = new ArrayList<>(); + + String lang = language; + while (true) { + langList.add(0, lang); + + String parent = subLanguageToParent.get(lang); + if (parent != null) { + lang = parent; + } else { + return langList; + } + } + } + + public static int compareLanguage(String l1, String l2) { + var list1 = resolveLanguage(l1); + var list2 = resolveLanguage(l2); + + int n = Math.min(list1.size(), list2.size()); + for (int i = 0; i < n; i++) { + int c = list1.get(i).compareTo(list2.get(i)); + if (c != 0) + return c; + } + + return Integer.compare(list1.size(), list2.size()); + } + + public static int compareScript(String s1, String s2) { + return s1.compareTo(s2); + } + + public static int compareLocale(Locale l1, Locale l2) { + int c = compareLanguage(l1.getLanguage(), l2.getLanguage()); + if (c != 0) + return c; + + c = compareScript(l1.getScript(), l2.getScript()); + if (c != 0) + return c; + + c = l1.getCountry().compareTo(l2.getCountry()); + if (c != 0) + return c; + + c = l1.getVariant().compareTo(l2.getVariant()); + if (c != 0) + return c; + + return l1.toString().compareTo(l2.toLanguageTag()); + } + + private LocalizationUtils() { + } +} diff --git a/buildSrc/src/main/java/org/jackhuang/hmcl/gradle/l10n/UpsideDownTranslate.java b/buildSrc/src/main/java/org/jackhuang/hmcl/gradle/l10n/UpsideDownTranslate.java new file mode 100644 index 000000000..28b049a53 --- /dev/null +++ b/buildSrc/src/main/java/org/jackhuang/hmcl/gradle/l10n/UpsideDownTranslate.java @@ -0,0 +1,155 @@ +/* + * 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.gradle.l10n; + +import org.gradle.api.DefaultTask; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.OutputFile; +import org.gradle.api.tasks.TaskAction; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Properties; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/// @author Glavo +public abstract class UpsideDownTranslate extends DefaultTask { + + static final Locale EN_QABS = Locale.forLanguageTag("en-Qabs"); + + private static final Map PROPERTIES = Map.of( + "datetime.format", "MMM d, yyyy, h:mm:ss a" + ); + + @InputFile + public abstract RegularFileProperty getInputFile(); + + @OutputFile + public abstract RegularFileProperty getOutputFile(); + + @TaskAction + public void run() throws IOException { + Path inputFile = getInputFile().get().getAsFile().toPath(); + Path outputFile = getOutputFile().get().getAsFile().toPath(); + + Properties english = new Properties(); + try (var reader = Files.newBufferedReader(inputFile)) { + english.load(reader); + } + + Properties output = new Properties(); + Translator translator = new Translator(); + english.forEach((k, v) -> { + if (PROPERTIES.containsKey(k.toString())) { + output.setProperty(k.toString(), PROPERTIES.get(k.toString())); + } else { + output.put(k, translator.translate(v.toString())); + } + }); + + Files.createDirectories(outputFile.getParent()); + try (var writer = Files.newBufferedWriter(outputFile)) { + output.store(writer, "This file is automatically generated, please do not modify it manually"); + } + } + + static final class Translator { + private static final Map MAPPER = new LinkedHashMap<>(); + + private static void putChars(char baseChar, String upsideDownChars) { + for (int i = 0; i < upsideDownChars.length(); i++) { + MAPPER.put(baseChar + i, (int) upsideDownChars.charAt(i)); + } + } + + private static void putChars(String baseChars, String upsideDownChars) { + if (baseChars.length() != upsideDownChars.length()) { + throw new IllegalArgumentException("baseChars and upsideDownChars must have same length"); + } + + for (int i = 0; i < baseChars.length(); i++) { + MAPPER.put((int) baseChars.charAt(i), (int) upsideDownChars.charAt(i)); + } + } + + static { + putChars('a', "ɐqɔpǝɟbɥıظʞןɯuodbɹsʇnʌʍxʎz"); + putChars('A', "ⱯᗺƆᗡƎℲ⅁HIſʞꞀWNOԀὉᴚS⟘∩ΛMXʎZ"); + putChars('0', "0ƖᘔƐㄣϛ9ㄥ86"); + putChars("_,;.?!/\\'", "‾'⸵˙¿¡/\\,"); + } + + private static final Pattern FORMAT_PATTERN = Pattern.compile("^%(\\d\\$)?(\\d+)?(\\.\\d+)?([sdf])"); + private static final Pattern XML_TAG_PATTERN = Pattern.compile("^<(?[a-zA-Z]+)( href=\"[^\"]*\")?>"); + + private final StringBuilder resultBuilder = new StringBuilder(); + + private void appendToLineBuilder(String input) { + for (int i = 0; i < input.length(); ) { + int ch = input.codePointAt(i); + + if (ch == '%') { + Matcher matcher = FORMAT_PATTERN.matcher(input).region(i, input.length()); + if (matcher.find()) { + String formatString = matcher.group(); + resultBuilder.insert(0, formatString); + i += formatString.length(); + continue; + } + } else if (ch == '<') { + Matcher matcher = XML_TAG_PATTERN.matcher(input).region(i, input.length()); + if (matcher.find()) { + String beginTag = matcher.group(); + String endTag = ""; + + int endTagOffset = input.indexOf(endTag, i + beginTag.length()); + if (endTagOffset > 0) { + resultBuilder.insert(0, endTag); + appendToLineBuilder(input.substring(i + beginTag.length(), endTagOffset)); + resultBuilder.insert(0, beginTag); + + i = endTagOffset + endTag.length(); + continue; + } + } + } + + int udCh = MAPPER.getOrDefault(ch, ch); + if (Character.isBmpCodePoint(udCh)) { + resultBuilder.insert(0, (char) udCh); + } else { + resultBuilder.insert(0, Character.toChars(udCh)); + } + + i += Character.charCount(ch); + } + } + + String translate(String input) { + resultBuilder.setLength(0); + appendToLineBuilder(input); + return resultBuilder.toString(); + } + } +} diff --git a/buildSrc/src/main/resources/org/jackhuang/hmcl/gradle/l10n/LocaleNamesOverride.properties b/buildSrc/src/main/resources/org/jackhuang/hmcl/gradle/l10n/LocaleNamesOverride.properties new file mode 100644 index 000000000..b5cfee7c7 --- /dev/null +++ b/buildSrc/src/main/resources/org/jackhuang/hmcl/gradle/l10n/LocaleNamesOverride.properties @@ -0,0 +1,23 @@ +# +# 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 . +# + +# Languages +lzh=Classical Chinese + +# Scripts +Qabs=Upside down diff --git a/buildSrc/src/main/resources/org/jackhuang/hmcl/gradle/l10n/LocaleNamesOverride_zh.properties b/buildSrc/src/main/resources/org/jackhuang/hmcl/gradle/l10n/LocaleNamesOverride_zh.properties new file mode 100644 index 000000000..ad1b393e9 --- /dev/null +++ b/buildSrc/src/main/resources/org/jackhuang/hmcl/gradle/l10n/LocaleNamesOverride_zh.properties @@ -0,0 +1,23 @@ +# +# 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 . +# + +# Languages +lzh=文言 + +# Scripts +Qabs=颠倒 diff --git a/buildSrc/src/main/resources/org/jackhuang/hmcl/gradle/l10n/LocaleNamesOverride_zh_Hant.properties b/buildSrc/src/main/resources/org/jackhuang/hmcl/gradle/l10n/LocaleNamesOverride_zh_Hant.properties new file mode 100644 index 000000000..baa90461d --- /dev/null +++ b/buildSrc/src/main/resources/org/jackhuang/hmcl/gradle/l10n/LocaleNamesOverride_zh_Hant.properties @@ -0,0 +1,20 @@ +# +# 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 . +# + +# Languages +lzh=文言 diff --git a/docs/Localization_zh.md b/docs/Localization_zh.md index 59b821db4..9b6369375 100644 --- a/docs/Localization_zh.md +++ b/docs/Localization_zh.md @@ -10,16 +10,63 @@ HMCL 为多种语言提供本地化支持。 目前,HMCL 为这些语言提供支持: -| 语言 | 语言标签 | 首选本地化文件后缀 | 首选本地化键 | [游戏语言文件](https://minecraft.wiki/w/Language) | 支持状态 | 志愿者 | -|---------|-----------|------------|-----------|---------------------------------------------|--------|-------------------------------------------| -| 英语 | `en` | (空) | `default` | `en_us` | **主要** | [Glavo](https://github.com/Glavo) | -| 中文 (简体) | `zh-Hans` | `_zh` | `zh` | `zh_cn` | **主要** | [Glavo](https://github.com/Glavo) | -| 中文 (繁体) | `zh-Hant` | `_zh_Hant` | `zh-Hant` | `zh_tw`
`zh_hk` | **主要** | [Glavo](https://github.com/Glavo) | -| 中文 (文言) | `lzh` | `_lzh` | `lzh` | `lzh` | 次要 | | -| 日语 | `ja` | `_ja` | `ja` | `ja_jp` | 次要 | | -| 西班牙语 | `es` | `_es` | `es` | `es_es` | 次要 | [3gf8jv4dv](https://github.com/3gf8jv4dv) | -| 俄语 | `ru` | `_ru` | `ru` | `ru_ru` | 次要 | [3gf8jv4dv](https://github.com/3gf8jv4dv) | -| 乌克兰语 | `uk` | `_uk` | `uk` | `uk_ua` | 次要 | | +| 语言 | 语言标签 | 首选本地化键 | 首选本地化文件后缀 | [游戏语言文件](https://minecraft.wiki/w/Language) | 支持状态 | 志愿者 | +|---------|-----------|-----------|------------|---------------------------------------------|--------|-------------------------------------------| +| 英语 | `en` | `default` | (空) | `en_us` | **主要** | [Glavo](https://github.com/Glavo) | +| 英语 (颠倒) | `en-Qabs` | `en-Qabs` | `en_Qabs` | `en_ud` | 自动 | | +| 中文 (简体) | `zh-Hans` | `zh` | `_zh` | `zh_cn` | **主要** | [Glavo](https://github.com/Glavo) | +| 中文 (繁体) | `zh-Hant` | `zh-Hant` | `_zh_Hant` | `zh_tw`
`zh_hk` | **主要** | [Glavo](https://github.com/Glavo) | +| 中文 (文言) | `lzh` | `lzh` | `_lzh` | `lzh` | 次要 | | +| 日语 | `ja` | `ja` | `_ja` | `ja_jp` | 次要 | | +| 西班牙语 | `es` | `es` | `_es` | `es_es` | 次要 | [3gf8jv4dv](https://github.com/3gf8jv4dv) | +| 俄语 | `ru` | `ru` | `_ru` | `ru_ru` | 次要 | [3gf8jv4dv](https://github.com/3gf8jv4dv) | +| 乌克兰语 | `uk` | `uk` | `_uk` | `uk_ua` | 次要 | | + +
+关于语言标签 + +HMCL 使用符合 IETF BCP 47 规范的语言标签。 + +对于 ISO 639 标准中定义的语言,如果同时存在两字母语言代码和三字母语言代码,那么应当优先选择两字母语言代码。 + +例如,对于英语,我们使用 `en` 而不是 `eng` 作为语言代码。 + +对于 Minecraft 所定义的非标准语言,应当优先使用语言文件的 `language.code` 中定义的代码,而非游戏语言文件的名称 +(但对于存在两字母代码的语言,应当将三字母语言代码替换为对应的两字母语言代码)。 +这是因为 Minecraft 有时候会用现实中实际存在的国家/地区代码来表示虚构语言 (比如说海盗英语的语言文件为 `en_pt`,但 `PT` 其实是葡萄牙的国家代码)。 + +例如,对于颠倒的英语,我们使用 `en-Qabs` 作为语言代码,而不是 `en-UD`。 + +此外,语言代码中应当尽可能选择地区中立的语言标签。 + +例如,对于简体中文和繁体中文,我们使用 `zh-Hans`和 `zh-Hant` 作为语言代码,而不是 `zh-CN` 和 `zh-TW`。 + +
+ +
+关于本地化键和本地化文件后缀 + +本地化文件后缀和本地化键用于为[本地化资源](#本地化资源)命名。 + +通常来说,本地化键就是这份本地化资源对应的语言代码,而本地化文件后缀是将语言代码中的 `-` 替换为 `_`,并加上一个前缀下划线得到的。 + +作为特例,对于默认的资源,本地化键为 `default`,本地化文件后缀为空。 + +由于[资源回退机制](#资源回退机制)的存在。 +如果没有完全匹配当前语言环境的资源,HMCL 会根据当前环境的语言标签推导出一个搜索列表,根据该列表依次搜索资源。 + +我们建议在提供本地化资源时,总是提供默认资源 (对应 `default` 本地化键和空的本地化文件后缀), +以确保所有用户都能正常加载资源。 + +并且我们建议尽可能为本地化资源使用更宽泛的语言标签,使用户更不容易回退到默认资源上。 + +例如,如果你提供了一份简体中文的本地化资源,那么我们推荐使用 `zh` 作为本地化键,而不是更具体的 `zh-Hans`, +这样它会对于所有使用中文的用户生效,避免对于这些用户回退到默认资源上。 + +如果你想同时提供简体中文和繁体中文的资源,那么推荐对用户占比更高的资源使用更宽泛的 `zh` 作为本地化键,使其作为默认的中文资源, +而对用户占比更低的资源使用更具体的 `zh-Hans`/`zh-Hant` 作为本地化键。 + +
HMCL 会要求所有 Pull Request 在更新文档和本地化资源时同步更新所有**主要**支持的语言对应的资源。 如果 PR 作者对相关语言并不了解,那么可以直接在评论中提出翻译请求, @@ -56,7 +103,8 @@ HMCL 欢迎任何人参与翻译和贡献。但是维护更多语言的翻译需 如果你想为 HMCL 添加新的语言支持,请从翻译 [`I18N.properties`](../HMCL/src/main/resources/assets/lang/I18N.properties) 开始。 HMCL 的绝大多数文本都位于这个文件中,翻译此文件就能翻译整个界面。 -这是一个 Java Properties 文件,格式非常简单。在翻译前请先阅读该格式的介绍: [Properties 文件](https://en.wikipedia.org/wiki/.properties)。 +这是一个 Java Properties 文件,格式非常简单。 +在翻译前请先阅读该格式的介绍: [Properties 文件](https://en.wikipedia.org/wiki/.properties)。 作为翻译的第一步,请从[这张表格](https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes)中查询这个语言对应的两字母或三字母语言标签。 例如,英语的语言标签为 `en`。