Skip to content

Commit 68a3699

Browse files
committed
Implement plurals support for Qt
1 parent 07f72ed commit 68a3699

5 files changed

Lines changed: 319 additions & 12 deletions

File tree

scripts/extract-plural-forms-qt.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
#!/usr/bin/env python3
2+
# Update plural forms expressions used by Qt, from lupdate tool
3+
4+
import os.path
5+
import re
6+
import subprocess
7+
import sys
8+
9+
MARKER_BEGIN = "// Code generated with scripts/extract-plural-forms-qt.py begins here"
10+
MARKER_END = "// Code generated with scripts/extract-plural-forms-qt.py ends here"
11+
12+
13+
def extract_qt_plural_forms():
14+
"""Extract plural forms from Qt's lupdate tool."""
15+
try:
16+
# Run lupdate -list-languages
17+
result = subprocess.run(['lupdate', '-list-languages'],
18+
stdout=subprocess.PIPE,
19+
stderr=subprocess.PIPE,
20+
text=True)
21+
22+
if result.returncode != 0:
23+
print(f"Error running lupdate: {result.stderr}", file=sys.stderr)
24+
return {}
25+
26+
# pre-filled hard-coded corrections for lupdate output wrongness:
27+
plural_forms = {
28+
'en': 'nplurals=2; plural=(n != 1);',
29+
'pt-BR': 'nplurals=2; plural=(n > 1);',
30+
'pt': 'nplurals=2; plural=(n > 1);',
31+
'pt-PT': 'nplurals=2; plural=(n != 1);',
32+
'fil': 'nplurals=2; plural=(n > 1);',
33+
}
34+
35+
# Parse each line of output
36+
for line in result.stdout.strip().split('\n'):
37+
if not line.strip():
38+
continue
39+
40+
# Pattern to match: "Language Name [Country] lang_CODE nplurals=N; plural=expression;"
41+
match = re.search(r'\s+([a-z]{2,3}(?:_[A-Z]{2})?)\s+(nplurals=\d+;\s*plural=.+?;)\s*$', line)
42+
43+
if match:
44+
locale = match.group(1).replace('_', '-') # full language code (e.g., 'cs_CZ' or 'cs')
45+
lang = locale.split('-')[0] # basic language code (e.g., 'cs')
46+
plural_expr = match.group(2)
47+
48+
# Filter out Qt crap:
49+
if lang == 'en' and lang != 'en-US':
50+
continue
51+
if lang in ['pt', 'fil']:
52+
continue
53+
54+
if lang in plural_forms and plural_forms[lang] != plural_expr:
55+
print(f"Warning: Duplicate plural forms found for {lang}: '{plural_forms[lang]}' vs '{plural_expr}'.", file=sys.stderr)
56+
57+
plural_forms[lang] = plural_expr
58+
59+
return plural_forms
60+
61+
except FileNotFoundError:
62+
print("Error: lupdate command not found. Make sure Qt development tools are installed.", file=sys.stderr)
63+
return {}
64+
except Exception as e:
65+
print(f"Error: {e}", file=sys.stderr)
66+
return {}
67+
68+
69+
def main():
70+
plural_forms = extract_qt_plural_forms()
71+
72+
if not plural_forms:
73+
print("No plural forms extracted.", file=sys.stderr)
74+
return 1
75+
76+
if os.path.isfile("src/catalog_qt_plurals.h"):
77+
outfname = "src/catalog_qt_plurals.h"
78+
elif os.path.isfile("../src/catalog_qt_plurals.h"):
79+
outfname = "../src/catalog_qt_plurals.h"
80+
else:
81+
raise RuntimeError("run this script from root or from scripts/ directory")
82+
83+
with open(outfname, 'rt') as f:
84+
orig_content = f.read()
85+
86+
output = f'{MARKER_BEGIN}\n\n'
87+
for lang in sorted(plural_forms.keys()):
88+
expr = plural_forms[lang]
89+
output += f' {{ "{lang}", "{expr}" }},\n'
90+
output += f'\n{MARKER_END}\n'
91+
92+
content = re.sub('%s(.*?)%s' % (MARKER_BEGIN, MARKER_END),
93+
output,
94+
orig_content,
95+
count=0,
96+
flags=re.DOTALL)
97+
98+
with open(outfname, 'wt') as f:
99+
f.write(content)
100+
101+
return 0
102+
103+
104+
if __name__ == "__main__":
105+
sys.exit(main())

src/Makefile.am

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ poedit_SOURCES = \
2626
catalog.cpp catalog.h \
2727
catalog_po.cpp catalog_po.h \
2828
catalog_json.cpp catalog_json.h \
29-
catalog_qt.cpp catalog_qt.h \
29+
catalog_qt.cpp catalog_qt.h catalog_qt_plurals.h \
3030
catalog_resx.cpp catalog_resx.h \
3131
catalog_xcloc.cpp catalog_xcloc.h \
3232
catalog_xliff.cpp catalog_xliff.h \

src/catalog_qt.cpp

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,14 @@ QtLinguistCatalogItem::QtLinguistCatalogItem(QtLinguistCatalog& owner, int itemI
6969
m_moreFlags = ", qt-format";
7070
}
7171

72+
auto numerus = node.attribute("numerus").value();
73+
if (strcmp(numerus, "yes") == 0)
74+
{
75+
// In Qt translations, same string is used for singular and plural
76+
// (necessitating English translation file); this also sets m_hasPlural
77+
SetPluralString(m_string);
78+
}
79+
7280
auto oldsource = node.child("oldsource");
7381
if (oldsource)
7482
{
@@ -78,15 +86,29 @@ QtLinguistCatalogItem::QtLinguistCatalogItem(QtLinguistCatalog& owner, int itemI
7886
auto translation = node.child("translation");
7987
if (translation)
8088
{
81-
auto trans_text = str::to_wx(get_node_text(translation));
82-
m_translations.push_back(trans_text);
83-
m_isTranslated = !trans_text.empty();
84-
8589
auto type = translation.attribute("type").value();
86-
if (m_isTranslated && strcmp(type, "unfinished") == 0)
90+
bool isUnfinished = (strcmp(type, "unfinished") == 0);
91+
92+
if (HasPlural())
93+
{
94+
m_isTranslated = true;
95+
96+
for (auto form: translation.children("numerusform"))
97+
{
98+
auto trans_text = str::to_wx(get_node_text(form));
99+
m_translations.push_back(trans_text);
100+
if (trans_text.empty())
101+
m_isTranslated = false;
102+
}
103+
}
104+
else
87105
{
88-
m_isFuzzy = true;
106+
auto trans_text = str::to_wx(get_node_text(translation));
107+
m_translations.push_back(trans_text);
108+
m_isTranslated = !trans_text.empty();
89109
}
110+
111+
m_isFuzzy = m_isTranslated && isUnfinished;
90112
}
91113
else
92114
{
@@ -163,7 +185,18 @@ void QtLinguistCatalogItem::UpdateInternalRepresentation()
163185
translation.remove_attribute("type");
164186
}
165187

166-
set_node_text(translation, str::to_utf8(GetTranslation()));
188+
if (HasPlural())
189+
{
190+
remove_all_children(translation);
191+
for (auto& t: m_translations)
192+
{
193+
translation.append_child("numerusform").text().set(str::to_utf8(t).c_str());
194+
}
195+
}
196+
else
197+
{
198+
set_node_text(translation, str::to_utf8(GetTranslation()));
199+
}
167200

168201
if (HasComment())
169202
{
@@ -246,10 +279,6 @@ void QtLinguistCatalog::ParseSubtree(int& id, pugi::xml_node root, [[maybe_unuse
246279

247280
for (auto message : root.children("message"))
248281
{
249-
auto numerus = message.attribute("numerus").value();
250-
if (strcmp(numerus, "yes") == 0)
251-
continue; // FIXME: not implemented yet
252-
253282
auto type = message.child("translation").attribute("type").value();
254283
if (strcmp(type, "vanished") == 0 || strcmp(type, "obsolete") == 0)
255284
{
@@ -306,6 +335,24 @@ void QtLinguistCatalog::SetLanguage(Language lang)
306335
}
307336

308337

338+
PluralFormsExpr QtLinguistCatalog::GetPluralForms() const
339+
{
340+
static const std::unordered_map<std::string, std::string> forms = {
341+
#include "catalog_qt_plurals.h"
342+
};
343+
344+
auto it = forms.find(m_language.LanguageTag());
345+
if (it != forms.end())
346+
return PluralFormsExpr(it->second);
347+
348+
it = forms.find(m_language.Lang());
349+
if (it != forms.end())
350+
return PluralFormsExpr(it->second);
351+
352+
return PluralFormsExpr::English();
353+
}
354+
355+
309356
void QtLinguistCatalog::RemoveDeletedItems()
310357
{
311358
std::lock_guard<std::mutex> lock(m_documentMutex);

src/catalog_qt.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ class QtLinguistCatalog : public Catalog
9797
Language GetLanguage() const override { return m_language; }
9898
void SetLanguage(Language lang) override;
9999

100+
PluralFormsExpr GetPluralForms() const override;
101+
100102
bool HasDeletedItems() const override { return m_hasDeletedItems; }
101103
void RemoveDeletedItems() override;
102104

src/catalog_qt_plurals.h

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
2+
//
3+
// This file contains list of supported languages and their plural forms for Qt tools.
4+
// Produced via `lupdate -list-languages`, with some manual corrections.
5+
//
6+
7+
// Code generated with scripts/extract-plural-forms-qt.py begins here
8+
9+
{ "aa", "nplurals=2; plural=(n != 1);" },
10+
{ "ab", "nplurals=2; plural=(n != 1);" },
11+
{ "af", "nplurals=2; plural=(n != 1);" },
12+
{ "am", "nplurals=2; plural=(n != 1);" },
13+
{ "ar", "nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : (n%100>=3 && n%100<=10) ? 3 : n%100>=11 ? 4 : 5);" },
14+
{ "as", "nplurals=2; plural=(n != 1);" },
15+
{ "az", "nplurals=2; plural=(n != 1);" },
16+
{ "ba", "nplurals=2; plural=(n != 1);" },
17+
{ "be", "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);" },
18+
{ "bg", "nplurals=2; plural=(n != 1);" },
19+
{ "bn", "nplurals=2; plural=(n != 1);" },
20+
{ "bo", "nplurals=1; plural=0;" },
21+
{ "br", "nplurals=2; plural=(n > 1);" },
22+
{ "bs", "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);" },
23+
{ "ca", "nplurals=2; plural=(n != 1);" },
24+
{ "co", "nplurals=2; plural=(n != 1);" },
25+
{ "cs", "nplurals=3; plural=((n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2);" },
26+
{ "cy", "nplurals=5; plural=(n==0 ? 0 : n==1 ? 1 : (n>=2 && n<=5) ? 2 : n==6 ? 3 : 4);" },
27+
{ "da", "nplurals=2; plural=(n != 1);" },
28+
{ "de", "nplurals=2; plural=(n != 1);" },
29+
{ "dv", "nplurals=3; plural=(n==1 ? 0 : n==2 ? 1 : 2);" },
30+
{ "dz", "nplurals=1; plural=0;" },
31+
{ "el", "nplurals=2; plural=(n != 1);" },
32+
{ "en", "nplurals=2; plural=(n != 1);" },
33+
{ "es", "nplurals=2; plural=(n != 1);" },
34+
{ "et", "nplurals=2; plural=(n != 1);" },
35+
{ "eu", "nplurals=2; plural=(n != 1);" },
36+
{ "fa", "nplurals=1; plural=0;" },
37+
{ "fi", "nplurals=2; plural=(n != 1);" },
38+
{ "fil", "nplurals=2; plural=(n > 1);" },
39+
{ "fo", "nplurals=2; plural=(n != 1);" },
40+
{ "fr", "nplurals=2; plural=(n > 1);" },
41+
{ "fur", "nplurals=2; plural=(n != 1);" },
42+
{ "fy", "nplurals=2; plural=(n != 1);" },
43+
{ "ga", "nplurals=3; plural=(n==1 ? 0 : n==2 ? 1 : 2);" },
44+
{ "gd", "nplurals=4; plural=(n==1 || n==11) ? 0 : (n==2 || n==12) ? 1 : (n > 2 && n < 20) ? 2 : 3;" },
45+
{ "gl", "nplurals=2; plural=(n != 1);" },
46+
{ "gn", "nplurals=1; plural=0;" },
47+
{ "gu", "nplurals=2; plural=(n != 1);" },
48+
{ "gv", "nplurals=3; plural=(n==1 ? 0 : n==2 ? 1 : 2);" },
49+
{ "ha", "nplurals=2; plural=(n != 1);" },
50+
{ "he", "nplurals=2; plural=(n != 1);" },
51+
{ "hi", "nplurals=2; plural=(n != 1);" },
52+
{ "hr", "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);" },
53+
{ "hu", "nplurals=1; plural=0;" },
54+
{ "hy", "nplurals=2; plural=(n > 1);" },
55+
{ "id", "nplurals=1; plural=0;" },
56+
{ "ie", "nplurals=2; plural=(n != 1);" },
57+
{ "is", "nplurals=2; plural=(n%10==1 && n%100!=11 ? 0 : 1);" },
58+
{ "it", "nplurals=2; plural=(n != 1);" },
59+
{ "iu", "nplurals=3; plural=(n==1 ? 0 : n==2 ? 1 : 2);" },
60+
{ "ja", "nplurals=1; plural=0;" },
61+
{ "jv", "nplurals=1; plural=0;" },
62+
{ "ka", "nplurals=2; plural=(n != 1);" },
63+
{ "kk", "nplurals=2; plural=(n != 1);" },
64+
{ "kl", "nplurals=2; plural=(n != 1);" },
65+
{ "km", "nplurals=2; plural=(n != 1);" },
66+
{ "kn", "nplurals=2; plural=(n != 1);" },
67+
{ "ko", "nplurals=1; plural=0;" },
68+
{ "ks", "nplurals=2; plural=(n != 1);" },
69+
{ "ku", "nplurals=2; plural=(n != 1);" },
70+
{ "kw", "nplurals=2; plural=(n != 1);" },
71+
{ "ky", "nplurals=2; plural=(n != 1);" },
72+
{ "la", "nplurals=2; plural=(n != 1);" },
73+
{ "lb", "nplurals=2; plural=(n != 1);" },
74+
{ "lg", "nplurals=2; plural=(n != 1);" },
75+
{ "ln", "nplurals=2; plural=(n != 1);" },
76+
{ "lo", "nplurals=2; plural=(n != 1);" },
77+
{ "lt", "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && (n%100<10 || n%100>=20) ? 1 : 2);" },
78+
{ "lv", "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n != 0 ? 1 : 2);" },
79+
{ "mg", "nplurals=2; plural=(n != 1);" },
80+
{ "mi", "nplurals=3; plural=(n==1 ? 0 : n==2 ? 1 : 2);" },
81+
{ "mk", "nplurals=3; plural=(n%100==1 ? 0 : n%100==2 ? 1 : 2);" },
82+
{ "ml", "nplurals=2; plural=(n != 1);" },
83+
{ "mn", "nplurals=2; plural=(n != 1);" },
84+
{ "mr", "nplurals=2; plural=(n != 1);" },
85+
{ "ms", "nplurals=1; plural=0;" },
86+
{ "mt", "nplurals=4; plural=(n==1 ? 0 : (n==0 || (n%100>=1 && n%100<=10)) ? 1 : (n%100>=11 && n%100<=19) ? 2 : 3);" },
87+
{ "my", "nplurals=1; plural=0;" },
88+
{ "nb", "nplurals=2; plural=(n != 1);" },
89+
{ "ne", "nplurals=2; plural=(n != 1);" },
90+
{ "nl", "nplurals=2; plural=(n != 1);" },
91+
{ "nn", "nplurals=2; plural=(n != 1);" },
92+
{ "nso", "nplurals=2; plural=(n != 1);" },
93+
{ "oc", "nplurals=2; plural=(n != 1);" },
94+
{ "om", "nplurals=1; plural=0;" },
95+
{ "or", "nplurals=2; plural=(n != 1);" },
96+
{ "pa", "nplurals=2; plural=(n != 1);" },
97+
{ "pl", "nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);" },
98+
{ "ps", "nplurals=2; plural=(n != 1);" },
99+
{ "pt", "nplurals=2; plural=(n > 1);" },
100+
{ "pt-BR", "nplurals=2; plural=(n > 1);" },
101+
{ "pt-PT", "nplurals=2; plural=(n != 1);" },
102+
{ "qu", "nplurals=2; plural=(n != 1);" },
103+
{ "rm", "nplurals=2; plural=(n != 1);" },
104+
{ "rn", "nplurals=2; plural=(n != 1);" },
105+
{ "ro", "nplurals=3; plural=(n==1 ? 0 : (n==0 || (n%100 > 0 && n%100 < 20)) ? 1 : 2);" },
106+
{ "ru", "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);" },
107+
{ "rw", "nplurals=2; plural=(n != 1);" },
108+
{ "sa", "nplurals=3; plural=(n==1 ? 0 : n==2 ? 1 : 2);" },
109+
{ "sd", "nplurals=2; plural=(n != 1);" },
110+
{ "se", "nplurals=3; plural=(n==1 ? 0 : n==2 ? 1 : 2);" },
111+
{ "si", "nplurals=2; plural=(n != 1);" },
112+
{ "sk", "nplurals=3; plural=((n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2);" },
113+
{ "sl", "nplurals=4; plural=(n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n%100==4 ? 2 : 3);" },
114+
{ "sn", "nplurals=2; plural=(n != 1);" },
115+
{ "so", "nplurals=2; plural=(n != 1);" },
116+
{ "sq", "nplurals=2; plural=(n != 1);" },
117+
{ "sr", "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);" },
118+
{ "ss", "nplurals=2; plural=(n != 1);" },
119+
{ "st", "nplurals=2; plural=(n != 1);" },
120+
{ "su", "nplurals=1; plural=0;" },
121+
{ "sv", "nplurals=2; plural=(n != 1);" },
122+
{ "sw", "nplurals=2; plural=(n != 1);" },
123+
{ "ta", "nplurals=2; plural=(n != 1);" },
124+
{ "te", "nplurals=2; plural=(n != 1);" },
125+
{ "tg", "nplurals=2; plural=(n != 1);" },
126+
{ "th", "nplurals=1; plural=0;" },
127+
{ "ti", "nplurals=2; plural=(n > 1);" },
128+
{ "tk", "nplurals=2; plural=(n != 1);" },
129+
{ "tn", "nplurals=2; plural=(n != 1);" },
130+
{ "to", "nplurals=2; plural=(n != 1);" },
131+
{ "tr", "nplurals=1; plural=0;" },
132+
{ "ts", "nplurals=2; plural=(n != 1);" },
133+
{ "tt", "nplurals=1; plural=0;" },
134+
{ "ug", "nplurals=2; plural=(n != 1);" },
135+
{ "uk", "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);" },
136+
{ "ur", "nplurals=2; plural=(n != 1);" },
137+
{ "uz", "nplurals=2; plural=(n != 1);" },
138+
{ "vi", "nplurals=1; plural=0;" },
139+
{ "wa", "nplurals=2; plural=(n > 1);" },
140+
{ "wo", "nplurals=2; plural=(n != 1);" },
141+
{ "xh", "nplurals=2; plural=(n != 1);" },
142+
{ "yi", "nplurals=2; plural=(n != 1);" },
143+
{ "yo", "nplurals=1; plural=0;" },
144+
{ "za", "nplurals=1; plural=0;" },
145+
{ "zh", "nplurals=1; plural=0;" },
146+
{ "zu", "nplurals=2; plural=(n != 1);" },
147+
148+
// Code generated with scripts/extract-plural-forms-qt.py ends here
149+
150+
151+
152+
153+

0 commit comments

Comments
 (0)