支持颠倒的英语 (#4527)

This commit is contained in:
Glavo
2025-09-22 22:07:06 +08:00
committed by GitHub
parent cbe6554390
commit 36d71bd14e
28 changed files with 1488 additions and 332 deletions

View File

@@ -15,7 +15,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.gradle;
package org.jackhuang.hmcl.gradle.l10n;
import org.gradle.api.DefaultTask;
import org.gradle.api.GradleException;

View File

@@ -0,0 +1,102 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
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<Locale> 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(", ", "[", "]")));
}
}

View File

@@ -0,0 +1,298 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
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<Locale> supportedLanguages;
try (var reader = Files.newBufferedReader(languagesFile)) {
supportedLanguages = new Gson().fromJson(reader, new TypeToken<List<String>>() {
}).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<LocaleField, SortedSet<String>> 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<Locale, Properties> 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<Locale, LocaleNames> 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<String, String> 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<Locale> 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<Locale, LocaleNames> 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<LocaleField, SortedMap<String, String>> 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<String, String> 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<String, String> 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<String> {
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);
}
}

View File

@@ -0,0 +1,110 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
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<String, String> subLanguageToParent;
static {
InputStream input = LocalizationUtils.class.getResourceAsStream("sublanguages.csv");
if (input == null)
throw new GradleException("Missing sublanguages.csv file");
Map<String, String> 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<String> resolveLanguage(String language) {
List<String> 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() {
}
}

View File

@@ -0,0 +1,155 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
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<String, String> 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<Integer, Integer> 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("^<(?<tag>[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 = "</" + matcher.group(1) + ">";
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();
}
}
}

View File

@@ -0,0 +1,23 @@
#
# Hello Minecraft! Launcher
# Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
#
# Languages
lzh=Classical Chinese
# Scripts
Qabs=Upside down

View File

@@ -0,0 +1,23 @@
#
# Hello Minecraft! Launcher
# Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
#
# Languages
lzh=文言
# Scripts
Qabs=颠倒

View File

@@ -0,0 +1,20 @@
#
# Hello Minecraft! Launcher
# Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
#
# Languages
lzh=文言