Add default plural rules

This makes the PO loader correctly handle the situation where the optional
`Plural-Forms` header field does not exist.

The `Translation` class and its subclasses always have access to valid plural
rules via `_get_plural_rules()`. Plural rules are prioritized:

1. `Translation.plural_rules_override`
2. `TranslationServer.get_plural_rules(locale)`
3. The English plural rules: `nplurals=2; plurals=(n != 1)`

Co-Authored-By: Pāvels Nadtočajevs <7645683+bruvzg@users.noreply.github.com>
This commit is contained in:
Haoyu Qiu 2025-07-21 17:59:14 +08:00
parent ebb96e2303
commit e882e42e1b
11 changed files with 202 additions and 61 deletions

View file

@ -112,7 +112,7 @@ Ref<Resource> TranslationLoaderPO::load_translation(Ref<FileAccess> f, Error *r_
int p_start = config.find("Plural-Forms");
if (p_start != -1) {
int p_end = config.find_char('\n', p_start);
translation->set_plural_rule(config.substr(p_start, p_end - p_start));
translation->set_plural_rules_override(config.substr(p_start, p_end - p_start));
}
} else {
uint32_t str_start = 0;
@ -228,7 +228,7 @@ Ref<Resource> TranslationLoaderPO::load_translation(Ref<FileAccess> f, Error *r_
int p_start = config.find("Plural-Forms");
if (p_start != -1) {
int p_end = config.find_char('\n', p_start);
translation->set_plural_rule(config.substr(p_start, p_end - p_start));
translation->set_plural_rules_override(config.substr(p_start, p_end - p_start));
plural_forms = translation->get_plural_forms();
}
}

View file

@ -1194,3 +1194,50 @@ static const char *script_list[][2] = {
{ "Zanabazar Square", "Zanb" },
{ nullptr, nullptr }
};
// Plural rules.
// Reference:
// - https://github.com/unicode-org/cldr/blob/main/common/supplemental/plurals.xml
static const char *plural_rules[][2] = {
{ "bm bo dz hnj id ig ii in ja jbo jv jw kde kea km ko lkt lo ms my nqo osa root sah ses sg su th to tpi vi wo yo yue zh", "nplurals=1; plural=0;" },
{ "am as bn doi fa gu hi kn pcm zu", "nplurals=2; plural=(n==0 || n==1);" },
{ "ff hy kab", "nplurals=2; plural=(n > 1);" },
{ "ast de en et fi fy gl ia io ji lij nl sc sv sw ur yi", "nplurals=2; plural=(n != 1);" },
{ "si", "nplurals=2; plural=(n > 1);" },
{ "ak bho csw guw ln mg nso pa ti wa", "nplurals=2; plural=(n > 1);" },
{ "tzm", "nplurals=2; plural=(n<=1 || (n>=11 && n<=99));" },
{ "af an asa az bal bem bez bg brx ce cgg chr ckb dv ee el eo eu fo fur gsw ha haw hu jgo jmc ka kaj kcg kk kkj kl ks ksb ku ky lb lg mas mgo ml mn mr nah nb nd ne nn nnh no nr ny nyn om or os pap ps rm rof rwk saq sd sdh seh sn so sq ss ssy st syr ta te teo tig tk tn tr ts ug uz ve vo vun wae xh xog", "nplurals=2; plural=(n != 1);" },
{ "da", "nplurals=2; plural=(n != 1);" },
{ "is", "nplurals=2; plural=(n%10==1 && n%100!=11);" },
{ "mk", "nplurals=2; plural=(n%10==1 && n%100!=11);" },
{ "ceb fil tl", "nplurals=2; plural=(n==1 || n==2 || n==3 || (n%10!=4 && n%10!=6 && n%10!=9));" },
{ "lv prg", "nplurals=3; plural=(n%10==0 || (n%100>=11 && n%100<=19) ? 0 : n%10==1 && n%100!=11 ? 1 : 2);" },
{ "lag", "nplurals=3; plural=(n==0 ? 0 : (n==0 || n==1) && n!=0 ? 1 : 2);" },
{ "blo", "nplurals=3; plural=(n==0 ? 0 : n==1 ? 1 : 2);" },
{ "ksh", "nplurals=3; plural=(n==0 ? 0 : n==1 ? 1 : 2);" },
{ "he iw", "nplurals=3; plural=(n==1 ? 0 : n==2 ? 1 : 2);" },
{ "iu naq sat se sma smi smj smn sms", "nplurals=3; plural=(n==1 ? 0 : n==2 ? 1 : 2);" },
{ "shi", "nplurals=3; plural=(n==0 || n==1 ? 0 : n>=2 && n<=10 ? 1 : 2);" },
{ "mo ro", "nplurals=3; plural=(n==1 ? 0 : n==0 || (n!=1 && n%100>=1 && n%100<=19) ? 1 : 2);" },
{ "bs hr sh sr", "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);" },
{ "fr", "nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2);" },
{ "pt", "nplurals=3; plural=((n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2);" },
{ "ca it lld pt_PT scn vec", "nplurals=3; plural=(n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2);" },
{ "es", "nplurals=3; plural=(n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2);" },
{ "gd", "nplurals=4; plural=(n==1 || n==11 ? 0 : n==2 || n==12 ? 1 : (n>=3 && n<=10) || (n>=13 && n<=19) ? 2 : 3);" },
{ "sl", "nplurals=4; plural=(n%100==1 ? 0 : n%100==2 ? 1 : n%100>=3 && n%100<=4 ? 2 : 3);" },
{ "dsb hsb", "nplurals=4; plural=(n%100==1 ? 0 : n%100==2 ? 1 : n%100>=3 && n%100<=4 ? 2 : 3);" },
{ "cs sk", "nplurals=3; plural=(n==1 ? 0 : n>=2 && n<=4 ? 1 : 2);" },
{ "pl", "nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);" },
{ "be", "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);" },
{ "lt", "nplurals=3; plural=(n%10==1 && (n%100<11 || n%100>19) ? 0 : n%10>=2 && n%10<=9 && (n%100<11 || n%100>19) ? 1 : 2);" },
{ "ru uk", "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);" },
{ "br", "nplurals=5; plural=(n%10==1 && n%100!=11 && n%100!=71 && n%100!=91 ? 0 : n%10==2 && n%100!=12 && n%100!=72 && n%100!=92 ? 1 : ((n%10>=3 && n%10<=4) || n%10==9) && (n%100<10 || n%100>19) && (n%100<70 || n%100>79) && (n%100<90 || n%100>99) ? 2 : n!=0 && n%1000000==0 ? 3 : 4);" },
{ "mt", "nplurals=5; plural=(n==1 ? 0 : n==2 ? 1 : n==0 || (n%100>=3 && n%100<=10) ? 2 : n%100>=11 && n%100<=19 ? 3 : 4);" },
{ "ga", "nplurals=5; plural=(n==1 ? 0 : n==2 ? 1 : n>=3 && n<=6 ? 2 : n>=7 && n<=10 ? 3 : 4);" },
{ "gv", "nplurals=4; plural=(n%10==1 ? 0 : n%10==2 ? 1 : n%100==0 || n%100==20 || n%100==40 || n%100==60 || n%100==80 ? 2 : 3);" },
{ "kw", "nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n%100==2 || n%100==22 || n%100==42 || n%100==62 || n%100==82 || (n%1000==0 && ((n%100000>=1000 && n%100000<=20000) || n%100000==40000 || n%100000==60000 || n%100000==80000)) || (n!=0 && n%1000000==100000) ? 2 : n%100==3 || n%100==23 || n%100==43 || n%100==63 || n%100==83 ? 3 : n!=1 && (n%100==1 || n%100==21 || n%100==41 || n%100==61 || n%100==81) ? 4 : 5);" },
{ "ar ars", "nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5);" },
{ "cy", "nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n==3 ? 3 : n==6 ? 4 : 5);" },
{ nullptr, nullptr },
};

View file

@ -31,6 +31,7 @@
#include "translation.h"
#include "core/os/thread.h"
#include "core/string/plural_rules.h"
#include "core/string/translation_server.h"
Dictionary Translation::_get_messages() const {
@ -73,6 +74,11 @@ void Translation::_set_messages(const Dictionary &p_messages) {
void Translation::set_locale(const String &p_locale) {
locale = TranslationServer::get_singleton()->standardize_locale(p_locale);
if (plural_rules_cache && plural_rules_override.is_empty()) {
memdelete(plural_rules_cache);
plural_rules_cache = nullptr;
}
}
void Translation::add_message(const StringName &p_src_text, const StringName &p_xlated_text, const StringName &p_context) {
@ -131,6 +137,44 @@ int Translation::get_message_count() const {
return translation_map.size();
}
PluralRules *Translation::_get_plural_rules() const {
if (plural_rules_cache) {
return plural_rules_cache;
}
if (!plural_rules_override.is_empty()) {
plural_rules_cache = PluralRules::parse(plural_rules_override);
}
if (!plural_rules_cache) {
// Locale's default plural rules.
const String &default_rule = TranslationServer::get_singleton()->get_plural_rules(locale);
if (!default_rule.is_empty()) {
plural_rules_cache = PluralRules::parse(default_rule);
}
// Use English plural rules as a fallback.
if (!plural_rules_cache) {
plural_rules_cache = PluralRules::parse("nplurals=2; plural=(n != 1);");
}
}
DEV_ASSERT(plural_rules_cache != nullptr);
return plural_rules_cache;
}
void Translation::set_plural_rules_override(const String &p_rules) {
plural_rules_override = p_rules;
if (plural_rules_cache) {
memdelete(plural_rules_cache);
plural_rules_cache = nullptr;
}
}
String Translation::get_plural_rules_override() const {
return plural_rules_override;
}
void Translation::_bind_methods() {
ClassDB::bind_method(D_METHOD("set_locale", "locale"), &Translation::set_locale);
ClassDB::bind_method(D_METHOD("get_locale"), &Translation::get_locale);
@ -144,10 +188,20 @@ void Translation::_bind_methods() {
ClassDB::bind_method(D_METHOD("get_message_count"), &Translation::get_message_count);
ClassDB::bind_method(D_METHOD("_set_messages", "messages"), &Translation::_set_messages);
ClassDB::bind_method(D_METHOD("_get_messages"), &Translation::_get_messages);
ClassDB::bind_method(D_METHOD("set_plural_rules_override", "rules"), &Translation::set_plural_rules_override);
ClassDB::bind_method(D_METHOD("get_plural_rules_override"), &Translation::get_plural_rules_override);
GDVIRTUAL_BIND(_get_plural_message, "src_message", "src_plural_message", "n", "context");
GDVIRTUAL_BIND(_get_message, "src_message", "context");
ADD_PROPERTY(PropertyInfo(Variant::DICTIONARY, "messages", PROPERTY_HINT_NONE, "", PROPERTY_USAGE_NO_EDITOR | PROPERTY_USAGE_INTERNAL), "_set_messages", "_get_messages");
ADD_PROPERTY(PropertyInfo(Variant::STRING, "locale"), "set_locale", "get_locale");
ADD_PROPERTY(PropertyInfo(Variant::STRING, "plural_rules_override"), "set_plural_rules_override", "get_plural_rules_override");
}
Translation::~Translation() {
if (plural_rules_cache) {
memdelete(plural_rules_cache);
plural_rules_cache = nullptr;
}
}

View file

@ -33,6 +33,8 @@
#include "core/io/resource.h"
#include "core/object/gdvirtual.gen.inc"
class PluralRules;
class Translation : public Resource {
GDCLASS(Translation, Resource);
OBJ_SAVE_TYPE(Translation);
@ -41,6 +43,9 @@ class Translation : public Resource {
String locale = "en";
HashMap<StringName, StringName> translation_map;
mutable PluralRules *plural_rules_cache = nullptr;
String plural_rules_override;
virtual Vector<String> _get_message_list() const;
virtual Dictionary _get_messages() const;
virtual void _set_messages(const Dictionary &p_messages);
@ -48,6 +53,8 @@ class Translation : public Resource {
protected:
static void _bind_methods();
PluralRules *_get_plural_rules() const;
GDVIRTUAL2RC(StringName, _get_message, StringName, StringName);
GDVIRTUAL4RC(StringName, _get_plural_message, StringName, StringName, int, StringName);
@ -64,5 +71,8 @@ public:
virtual int get_message_count() const;
virtual Vector<String> get_translated_message_list() const;
Translation() {}
void set_plural_rules_override(const String &p_rules);
String get_plural_rules_override() const;
~Translation();
};

View file

@ -131,13 +131,6 @@ Vector<String> TranslationPO::_get_message_list() const {
return v;
}
void TranslationPO::set_plural_rule(const String &p_plural_rule) {
if (plural_rules) {
memdelete(plural_rules);
}
plural_rules = PluralRules::parse(p_plural_rule);
}
void TranslationPO::add_message(const StringName &p_src_text, const StringName &p_xlated_text, const StringName &p_context) {
HashMap<StringName, Vector<StringName>> &map_id_str = translation_map[p_context];
@ -150,8 +143,7 @@ void TranslationPO::add_message(const StringName &p_src_text, const StringName &
}
void TranslationPO::add_plural_message(const StringName &p_src_text, const Vector<String> &p_plural_xlated_texts, const StringName &p_context) {
ERR_FAIL_NULL_MSG(plural_rules, "Plural rules are not set. Please call set_plural_rule() before calling add_plural_message().");
ERR_FAIL_COND_MSG(p_plural_xlated_texts.size() != plural_rules->get_nplurals(), vformat("Trying to add plural texts that don't match the required number of plural forms for locale \"%s\".", get_locale()));
ERR_FAIL_COND_MSG(p_plural_xlated_texts.size() != _get_plural_rules()->get_nplurals(), vformat("Trying to add plural texts that don't match the required number of plural forms for locale \"%s\".", get_locale()));
HashMap<StringName, Vector<StringName>> &map_id_str = translation_map[p_context];
@ -166,11 +158,11 @@ void TranslationPO::add_plural_message(const StringName &p_src_text, const Vecto
}
int TranslationPO::get_plural_forms() const {
return plural_rules ? plural_rules->get_nplurals() : 0;
return _get_plural_rules()->get_nplurals();
}
String TranslationPO::get_plural_rule() const {
return plural_rules ? plural_rules->get_plural() : String();
return _get_plural_rules()->get_plural();
}
StringName TranslationPO::get_message(const StringName &p_src_text, const StringName &p_context) const {
@ -184,14 +176,13 @@ StringName TranslationPO::get_message(const StringName &p_src_text, const String
StringName TranslationPO::get_plural_message(const StringName &p_src_text, const StringName &p_plural_text, int p_n, const StringName &p_context) const {
ERR_FAIL_COND_V_MSG(p_n < 0, StringName(), "N passed into translation to get a plural message should not be negative. For negative numbers, use singular translation please. Search \"gettext PO Plural Forms\" online for the documentation on translating negative numbers.");
ERR_FAIL_NULL_V_MSG(plural_rules, StringName(), "Plural rules are not set. Please call set_plural_rule() before calling get_plural_message().");
if (!translation_map.has(p_context) || !translation_map[p_context].has(p_src_text)) {
return StringName();
}
ERR_FAIL_COND_V_MSG(translation_map[p_context][p_src_text].is_empty(), StringName(), vformat("Source text \"%s\" is registered but doesn't have a translation. Please report this bug.", String(p_src_text)));
int plural_index = plural_rules->evaluate(p_n);
int plural_index = _get_plural_rules()->evaluate(p_n);
ERR_FAIL_COND_V_MSG(plural_index < 0 || translation_map[p_context][p_src_text].size() < plural_index + 1, StringName(), "Plural index returned or number of plural translations is not valid. Please report this bug.");
return translation_map[p_context][p_src_text][plural_index];
@ -234,10 +225,3 @@ void TranslationPO::_bind_methods() {
ClassDB::bind_method(D_METHOD("get_plural_forms"), &TranslationPO::get_plural_forms);
ClassDB::bind_method(D_METHOD("get_plural_rule"), &TranslationPO::get_plural_rule);
}
TranslationPO::~TranslationPO() {
if (plural_rules) {
memdelete(plural_rules);
plural_rules = nullptr;
}
}

View file

@ -34,8 +34,6 @@
#include "core/string/translation.h"
class PluralRules;
class TranslationPO : public Translation {
GDCLASS(TranslationPO, Translation);
@ -46,8 +44,6 @@ class TranslationPO : public Translation {
// Strings without context have "" as first key.
HashMap<StringName, HashMap<StringName, Vector<StringName>>> translation_map;
PluralRules *plural_rules = nullptr;
Vector<String> _get_message_list() const override;
Dictionary _get_messages() const override;
void _set_messages(const Dictionary &p_messages) override;
@ -65,13 +61,10 @@ public:
StringName get_plural_message(const StringName &p_src_text, const StringName &p_plural_text, int p_n, const StringName &p_context = "") const override;
void erase_message(const StringName &p_src_text, const StringName &p_context = "") override;
void set_plural_rule(const String &p_plural_rule);
int get_plural_forms() const;
String get_plural_rule() const;
#ifdef DEBUG_TRANSLATION_PO
void print_translation_map();
#endif
~TranslationPO();
};

View file

@ -36,15 +36,6 @@
#include "core/os/os.h"
#include "core/string/locales.h"
Vector<TranslationServer::LocaleScriptInfo> TranslationServer::locale_script_info;
HashMap<String, String> TranslationServer::language_map;
HashMap<String, String> TranslationServer::script_map;
HashMap<String, String> TranslationServer::locale_rename_map;
HashMap<String, String> TranslationServer::country_name_map;
HashMap<String, String> TranslationServer::variant_map;
HashMap<String, String> TranslationServer::country_rename_map;
void TranslationServer::init_locale_info() {
// Init locale info.
language_map.clear();
@ -113,6 +104,18 @@ void TranslationServer::init_locale_info() {
}
idx++;
}
// Init plural rules.
plural_rules_map.clear();
idx = 0;
while (plural_rules[idx][0] != nullptr) {
const Vector<String> rule_locs = String(plural_rules[idx][0]).split(" ");
const String rule = String(plural_rules[idx][1]);
for (const String &l : rule_locs) {
plural_rules_map[l] = rule;
}
idx++;
}
}
TranslationServer::Locale::operator String() const {
@ -305,6 +308,26 @@ String TranslationServer::get_locale_name(const String &p_locale) const {
return name;
}
String TranslationServer::get_plural_rules(const String &p_locale) const {
const String *rule = plural_rules_map.getptr(p_locale);
if (rule) {
return *rule;
}
Locale l = Locale(*this, p_locale, false);
if (!l.country.is_empty()) {
rule = plural_rules_map.getptr(l.language + "_" + l.country);
if (rule) {
return *rule;
}
}
rule = plural_rules_map.getptr(l.language);
if (rule) {
return *rule;
}
return String();
}
Vector<String> TranslationServer::get_all_languages() const {
Vector<String> languages;
@ -584,6 +607,7 @@ void TranslationServer::_bind_methods() {
ClassDB::bind_method(D_METHOD("get_country_name", "country"), &TranslationServer::get_country_name);
ClassDB::bind_method(D_METHOD("get_locale_name", "locale"), &TranslationServer::get_locale_name);
ClassDB::bind_method(D_METHOD("get_plural_rules", "locale"), &TranslationServer::get_plural_rules);
ClassDB::bind_method(D_METHOD("translate", "message", "context"), &TranslationServer::translate, DEFVAL(StringName()));
ClassDB::bind_method(D_METHOD("translate_plural", "message", "plural_message", "n", "context"), &TranslationServer::translate_plural, DEFVAL(StringName()));

View file

@ -62,7 +62,7 @@ class TranslationServer : public Object {
String default_country;
HashSet<String> supported_countries;
};
static Vector<LocaleScriptInfo> locale_script_info;
static inline Vector<LocaleScriptInfo> locale_script_info;
struct Locale {
String language;
@ -82,12 +82,13 @@ class TranslationServer : public Object {
Locale(const TranslationServer &p_server, const String &p_locale, bool p_add_defaults);
};
static HashMap<String, String> language_map;
static HashMap<String, String> script_map;
static HashMap<String, String> locale_rename_map;
static HashMap<String, String> country_name_map;
static HashMap<String, String> country_rename_map;
static HashMap<String, String> variant_map;
static inline HashMap<String, String> language_map;
static inline HashMap<String, String> script_map;
static inline HashMap<String, String> locale_rename_map;
static inline HashMap<String, String> country_name_map;
static inline HashMap<String, String> country_rename_map;
static inline HashMap<String, String> variant_map;
static inline HashMap<String, String> plural_rules_map;
void init_locale_info();
@ -113,6 +114,7 @@ public:
String get_country_name(const String &p_country) const;
String get_locale_name(const String &p_locale) const;
String get_plural_rules(const String &p_locale) const;
PackedStringArray get_loaded_locales() const;

View file

@ -102,5 +102,9 @@
<member name="locale" type="String" setter="set_locale" getter="get_locale" default="&quot;en&quot;">
The locale of the translation.
</member>
<member name="plural_rules_override" type="String" setter="set_plural_rules_override" getter="get_plural_rules_override" default="&quot;&quot;">
The plural rules string to enforce. See [url=https://www.gnu.org/software/gettext/manual/html_node/Plural-forms.html]GNU gettext[/url] for examples and more info.
If empty or invalid, default plural rules from [method TranslationServer.get_plural_rules] are used. The English plural rules are used as a fallback.
</member>
</members>
</class>

View file

@ -92,6 +92,13 @@
Returns the translation domain with the specified name. An empty translation domain will be created and added if it does not exist.
</description>
</method>
<method name="get_plural_rules" qualifiers="const">
<return type="String" />
<param index="0" name="locale" type="String" />
<description>
Returns the default plural rules for the [param locale].
</description>
</method>
<method name="get_script_name" qualifiers="const">
<return type="String" />
<param index="0" name="script" type="String" />

View file

@ -112,10 +112,25 @@ TEST_CASE("[TranslationPO] Messages with context") {
}
TEST_CASE("[TranslationPO] Plural messages") {
{
Ref<TranslationPO> translation = memnew(TranslationPO);
translation->set_locale("fr");
translation->set_plural_rule("Plural-Forms: nplurals=2; plural=(n >= 2);");
CHECK(translation->get_plural_forms() == 3);
CHECK(translation->get_plural_rule() == "(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2)");
}
{
Ref<TranslationPO> translation = memnew(TranslationPO);
translation->set_locale("invalid");
CHECK(translation->get_plural_forms() == 2);
CHECK(translation->get_plural_rule() == "(n != 1)");
}
{
Ref<TranslationPO> translation = memnew(TranslationPO);
translation->set_plural_rules_override("Plural-Forms: nplurals=2; plural=(n >= 2);");
CHECK(translation->get_plural_forms() == 2);
CHECK(translation->get_plural_rule() == "(n >= 2)");
PackedStringArray plurals;
plurals.push_back("Il y a %d pomme");
@ -129,6 +144,7 @@ TEST_CASE("[TranslationPO] Plural messages") {
CHECK(vformat(translation->get_plural_message("There are %d apples", "", 1), 1) == "Il y a 1 pomme");
CHECK(vformat(translation->get_plural_message("There are %d apples", "", 2), 2) == "Il y a 2 pommes");
}
}
TEST_CASE("[TranslationPO] Plural rules parsing") {
ERR_PRINT_OFF;