super-tts-app/src/ui/languages.rs
Line | Count | Source |
1 | | // SPDX-License-Identifier: GPL-3.0-only |
2 | | //! Human-friendly names for BCP-47 language tags. |
3 | | //! |
4 | | //! Display only. The wire form is always the tag itself, and *which* tags exist |
5 | | //! is never decided here: both pickers list what the daemon says they may offer |
6 | | //! — the global setting's own vocabulary, and a model's own tags — because that |
7 | | //! set changes as backends are installed and removed, and which of `en` and |
8 | | //! `en-US` a model answers to is a rule only the daemon's resolver knows. A |
9 | | //! curated table once filled the global picker and is gone for exactly that |
10 | | //! reason: it offered tags no installed model served, and silently never |
11 | | //! learned the ones a new backend brought. |
12 | | //! |
13 | | //! Names are composed at render time from a base-language table, a script table |
14 | | //! and a region table, so any tag either endpoint returns renders — including |
15 | | //! ones no table anticipated, which keep their raw subtags rather than |
16 | | //! disappearing. |
17 | | |
18 | | /// Script subtag (ISO 15924) → display name. Names the `Hans` / `Hant` Chinese |
19 | | /// distinction (a script, not a region) and the Latin / Cyrillic / Arabic |
20 | | /// variants some languages carry. Unrecognized scripts keep their raw subtag. |
21 | | const SCRIPTS: &[(&str, &str)] = &[ |
22 | | ("Hans", "Simplified"), |
23 | | ("Hant", "Traditional"), |
24 | | ("Latn", "Latin"), |
25 | | ("Cyrl", "Cyrillic"), |
26 | | ("Arab", "Arabic"), |
27 | | ("Deva", "Devanagari"), |
28 | | ("Mong", "Mongolian"), |
29 | | ]; |
30 | | |
31 | | /// Base language subtag (ISO 639-1, plus a few ISO 639-3) → English name. |
32 | | const BASE_LANGUAGES: &[(&str, &str)] = &[ |
33 | | ("af", "Afrikaans"), |
34 | | ("am", "Amharic"), |
35 | | ("ar", "Arabic"), |
36 | | ("as", "Assamese"), |
37 | | ("az", "Azerbaijani"), |
38 | | ("ba", "Bashkir"), |
39 | | ("be", "Belarusian"), |
40 | | ("bg", "Bulgarian"), |
41 | | ("bn", "Bengali"), |
42 | | ("bo", "Tibetan"), |
43 | | ("br", "Breton"), |
44 | | ("bs", "Bosnian"), |
45 | | ("ca", "Catalan"), |
46 | | ("cs", "Czech"), |
47 | | ("cy", "Welsh"), |
48 | | ("da", "Danish"), |
49 | | ("de", "German"), |
50 | | ("el", "Greek"), |
51 | | ("en", "English"), |
52 | | ("es", "Spanish"), |
53 | | ("et", "Estonian"), |
54 | | ("eu", "Basque"), |
55 | | ("fa", "Persian"), |
56 | | ("fi", "Finnish"), |
57 | | ("fo", "Faroese"), |
58 | | ("fr", "French"), |
59 | | ("gl", "Galician"), |
60 | | ("gu", "Gujarati"), |
61 | | ("ha", "Hausa"), |
62 | | ("haw", "Hawaiian"), |
63 | | ("he", "Hebrew"), |
64 | | ("hi", "Hindi"), |
65 | | ("hr", "Croatian"), |
66 | | ("ht", "Haitian Creole"), |
67 | | ("hu", "Hungarian"), |
68 | | ("hy", "Armenian"), |
69 | | ("id", "Indonesian"), |
70 | | ("is", "Icelandic"), |
71 | | ("it", "Italian"), |
72 | | ("ja", "Japanese"), |
73 | | ("jw", "Javanese"), |
74 | | ("ka", "Georgian"), |
75 | | ("kk", "Kazakh"), |
76 | | ("km", "Khmer"), |
77 | | ("kn", "Kannada"), |
78 | | ("ko", "Korean"), |
79 | | ("la", "Latin"), |
80 | | ("lb", "Luxembourgish"), |
81 | | ("ln", "Lingala"), |
82 | | ("lo", "Lao"), |
83 | | ("lt", "Lithuanian"), |
84 | | ("lv", "Latvian"), |
85 | | ("mg", "Malagasy"), |
86 | | ("mi", "Maori"), |
87 | | ("mk", "Macedonian"), |
88 | | ("ml", "Malayalam"), |
89 | | ("mn", "Mongolian"), |
90 | | ("mr", "Marathi"), |
91 | | ("ms", "Malay"), |
92 | | ("mt", "Maltese"), |
93 | | ("my", "Burmese"), |
94 | | ("ne", "Nepali"), |
95 | | ("nl", "Dutch"), |
96 | | ("nn", "Norwegian Nynorsk"), |
97 | | ("no", "Norwegian"), |
98 | | ("oc", "Occitan"), |
99 | | ("pa", "Punjabi"), |
100 | | ("pl", "Polish"), |
101 | | ("ps", "Pashto"), |
102 | | ("pt", "Portuguese"), |
103 | | ("ro", "Romanian"), |
104 | | ("ru", "Russian"), |
105 | | ("sa", "Sanskrit"), |
106 | | ("sd", "Sindhi"), |
107 | | ("si", "Sinhala"), |
108 | | ("sk", "Slovak"), |
109 | | ("sl", "Slovenian"), |
110 | | ("sn", "Shona"), |
111 | | ("so", "Somali"), |
112 | | ("sq", "Albanian"), |
113 | | ("sr", "Serbian"), |
114 | | ("su", "Sundanese"), |
115 | | ("sv", "Swedish"), |
116 | | ("sw", "Swahili"), |
117 | | ("ta", "Tamil"), |
118 | | ("te", "Telugu"), |
119 | | ("tg", "Tajik"), |
120 | | ("th", "Thai"), |
121 | | ("tk", "Turkmen"), |
122 | | ("tl", "Tagalog"), |
123 | | ("tr", "Turkish"), |
124 | | ("tt", "Tatar"), |
125 | | ("uk", "Ukrainian"), |
126 | | ("ur", "Urdu"), |
127 | | ("uz", "Uzbek"), |
128 | | ("vi", "Vietnamese"), |
129 | | ("yi", "Yiddish"), |
130 | | ("yo", "Yoruba"), |
131 | | ("yue", "Cantonese"), |
132 | | ("zh", "Chinese"), |
133 | | ]; |
134 | | |
135 | | /// Region subtag → English name: ISO 3166-1 alpha-2 country codes and UN M.49 |
136 | | /// area codes (`419` = Latin America). |
137 | | const REGIONS: &[(&str, &str)] = &[ |
138 | | ("AE", "United Arab Emirates"), |
139 | | ("AR", "Argentina"), |
140 | | ("AT", "Austria"), |
141 | | ("AU", "Australia"), |
142 | | ("BD", "Bangladesh"), |
143 | | ("BE", "Belgium"), |
144 | | ("BG", "Bulgaria"), |
145 | | ("BO", "Bolivia"), |
146 | | ("BR", "Brazil"), |
147 | | ("BY", "Belarus"), |
148 | | ("CA", "Canada"), |
149 | | ("CH", "Switzerland"), |
150 | | ("CL", "Chile"), |
151 | | ("CN", "China"), |
152 | | ("CO", "Colombia"), |
153 | | ("CR", "Costa Rica"), |
154 | | ("CU", "Cuba"), |
155 | | ("CY", "Cyprus"), |
156 | | ("CZ", "Czechia"), |
157 | | ("DE", "Germany"), |
158 | | ("DK", "Denmark"), |
159 | | ("DO", "Dominican Republic"), |
160 | | ("EC", "Ecuador"), |
161 | | ("EE", "Estonia"), |
162 | | ("EG", "Egypt"), |
163 | | ("ES", "Spain"), |
164 | | ("FI", "Finland"), |
165 | | ("FR", "France"), |
166 | | ("GB", "United Kingdom"), |
167 | | ("GE", "Georgia"), |
168 | | ("GR", "Greece"), |
169 | | ("GT", "Guatemala"), |
170 | | ("HK", "Hong Kong"), |
171 | | ("HR", "Croatia"), |
172 | | ("HU", "Hungary"), |
173 | | ("ID", "Indonesia"), |
174 | | ("IE", "Ireland"), |
175 | | ("IL", "Israel"), |
176 | | ("IN", "India"), |
177 | | ("IQ", "Iraq"), |
178 | | ("IR", "Iran"), |
179 | | ("IS", "Iceland"), |
180 | | ("IT", "Italy"), |
181 | | ("JP", "Japan"), |
182 | | ("KE", "Kenya"), |
183 | | ("KR", "South Korea"), |
184 | | ("KZ", "Kazakhstan"), |
185 | | ("LK", "Sri Lanka"), |
186 | | ("LT", "Lithuania"), |
187 | | ("LU", "Luxembourg"), |
188 | | ("LV", "Latvia"), |
189 | | ("MA", "Morocco"), |
190 | | ("MX", "Mexico"), |
191 | | ("MY", "Malaysia"), |
192 | | ("NG", "Nigeria"), |
193 | | ("NL", "Netherlands"), |
194 | | ("NO", "Norway"), |
195 | | ("NP", "Nepal"), |
196 | | ("NZ", "New Zealand"), |
197 | | ("PA", "Panama"), |
198 | | ("PE", "Peru"), |
199 | | ("PH", "Philippines"), |
200 | | ("PK", "Pakistan"), |
201 | | ("PL", "Poland"), |
202 | | ("PT", "Portugal"), |
203 | | ("PY", "Paraguay"), |
204 | | ("RO", "Romania"), |
205 | | ("RS", "Serbia"), |
206 | | ("RU", "Russia"), |
207 | | ("SA", "Saudi Arabia"), |
208 | | ("SE", "Sweden"), |
209 | | ("SG", "Singapore"), |
210 | | ("SI", "Slovenia"), |
211 | | ("SK", "Slovakia"), |
212 | | ("SV", "El Salvador"), |
213 | | ("TH", "Thailand"), |
214 | | ("TR", "Turkey"), |
215 | | ("TW", "Taiwan"), |
216 | | ("UA", "Ukraine"), |
217 | | ("US", "United States"), |
218 | | ("UY", "Uruguay"), |
219 | | ("VE", "Venezuela"), |
220 | | ("VN", "Vietnam"), |
221 | | ("ZA", "South Africa"), |
222 | | ("419", "Latin America"), |
223 | | ]; |
224 | | |
225 | | /// Case-insensitive lookup in a `(code, name)` table. |
226 | 31 | fn lookup(table: &[(&'static str, &'static str)], key: &str) -> Option<&'static str> { |
227 | 31 | table |
228 | 31 | .iter() |
229 | 1.63k | .find31 (|&&(code, _)| code.eq_ignore_ascii_case(key)) |
230 | 31 | .map(|&(_, name)| name) |
231 | 31 | } |
232 | | |
233 | | /// Whether a BCP-47 subtag has script shape: 4 ASCII letters (e.g. `Hans`). |
234 | 15 | fn is_script(sub: &str) -> bool { |
235 | 24 | sub.len() == 415 && sub.chars()6 .all6 (|c| c.is_ascii_alphabetic()) |
236 | 15 | } |
237 | | |
238 | | /// Whether a BCP-47 subtag has region shape: a 2-letter country code or a |
239 | | /// 3-digit UN M.49 area code (e.g. `US`, `419`). |
240 | 9 | fn is_region(sub: &str) -> bool { |
241 | 14 | (sub.len() == 29 && sub.chars()7 .all7 (|c| c.is_ascii_alphabetic())) |
242 | 4 | || (sub.len() == 32 && sub.chars()2 .all2 (|c| c.is_ascii_digit())) |
243 | 9 | } |
244 | | |
245 | | /// Friendly display name for any BCP-47 tag. |
246 | | /// |
247 | | /// `auto` becomes "Auto-detect". Otherwise the tag is split into subtags, each |
248 | | /// classified by shape: the first is the language, a 4-letter subtag is a |
249 | | /// script, and a 2-letter or 3-digit subtag is a region. Recognized subtags |
250 | | /// render via their table; unrecognized ones keep their raw code (regions |
251 | | /// upper-cased). The result is "Language (qualifier, …)" — so `en-US` renders |
252 | | /// "English (United States)", `zh-Hans` renders "Chinese (Simplified)", |
253 | | /// `zh-Hans-CN` renders "Chinese (Simplified, China)", `en-XYZ` renders |
254 | | /// "English (XYZ)", and a bare unknown `xx` renders "xx". |
255 | | #[must_use] |
256 | 18 | pub fn friendly_name(tag: &str) -> String { |
257 | 18 | if tag.eq_ignore_ascii_case("auto") { |
258 | 1 | return "Auto-detect".to_string(); |
259 | 17 | } |
260 | 17 | let mut subtags = tag.split('-'); |
261 | 17 | let lang = subtags.next().unwrap_or(tag); |
262 | 17 | let name = match lookup(BASE_LANGUAGES, lang) { |
263 | 16 | Some(n) => n.to_string(), |
264 | 1 | None => lang.to_string(), |
265 | | }; |
266 | 17 | let mut qualifiers: Vec<String> = Vec::new(); |
267 | 17 | for sub in subtags { |
268 | 17 | if sub.is_empty() { |
269 | 2 | continue; // tolerate a trailing/double hyphen without empty parens |
270 | 15 | } |
271 | 15 | if is_script(sub) { |
272 | 6 | qualifiers.push(match lookup(SCRIPTS, sub) { |
273 | 6 | Some(n) => n.to_string(), |
274 | 0 | None => sub.to_string(), |
275 | | }); |
276 | 9 | } else if is_region(sub) { |
277 | 8 | qualifiers.push(match lookup(REGIONS, sub) { |
278 | 8 | Some(n) => n.to_string(), |
279 | 0 | None => sub.to_uppercase(), |
280 | | }); |
281 | 1 | } else { |
282 | 1 | qualifiers.push(sub.to_string()); |
283 | 1 | } |
284 | | } |
285 | 17 | if qualifiers.is_empty() { |
286 | 4 | name |
287 | | } else { |
288 | 13 | format!("{name} ({})", qualifiers.join(", ")) |
289 | | } |
290 | 18 | } |
291 | | |
292 | | #[cfg(test)] |
293 | | mod tests { |
294 | | use super::friendly_name; |
295 | | |
296 | | #[test] |
297 | 1 | fn auto_and_bare_language() { |
298 | 1 | assert_eq!(friendly_name("auto"), "Auto-detect"); |
299 | 1 | assert_eq!(friendly_name("en"), "English"); |
300 | 1 | assert_eq!(friendly_name("zh"), "Chinese"); |
301 | 1 | } |
302 | | |
303 | | #[test] |
304 | 1 | fn language_region() { |
305 | 1 | assert_eq!(friendly_name("en-US"), "English (United States)"); |
306 | 1 | assert_eq!(friendly_name("es-419"), "Spanish (Latin America)"); |
307 | 1 | assert_eq!(friendly_name("zh-CN"), "Chinese (China)"); |
308 | 1 | assert_eq!(friendly_name("zh-TW"), "Chinese (Taiwan)"); |
309 | 1 | } |
310 | | |
311 | | #[test] |
312 | 1 | fn language_script() { |
313 | 1 | assert_eq!(friendly_name("zh-Hans"), "Chinese (Simplified)"); |
314 | 1 | assert_eq!(friendly_name("zh-Hant"), "Chinese (Traditional)"); |
315 | 1 | assert_eq!(friendly_name("sr-Latn"), "Serbian (Latin)"); |
316 | 1 | } |
317 | | |
318 | | #[test] |
319 | 1 | fn language_script_region() { |
320 | | // Three subtags (two hyphens) — the script must not be mistaken for the |
321 | | // region, and both qualifiers appear. |
322 | 1 | assert_eq!(friendly_name("zh-Hans-CN"), "Chinese (Simplified, China)"); |
323 | 1 | assert_eq!(friendly_name("zh-Hant-TW"), "Chinese (Traditional, Taiwan)"); |
324 | 1 | } |
325 | | |
326 | | #[test] |
327 | 1 | fn case_insensitive() { |
328 | 1 | assert_eq!(friendly_name("EN-us"), "English (United States)"); |
329 | 1 | assert_eq!(friendly_name("zh-hans"), "Chinese (Simplified)"); |
330 | 1 | } |
331 | | |
332 | | #[test] |
333 | 1 | fn unknown_subtags_kept_raw() { |
334 | 1 | assert_eq!(friendly_name("en-XYZ"), "English (XYZ)"); |
335 | 1 | assert_eq!(friendly_name("xx"), "xx"); |
336 | 1 | } |
337 | | |
338 | | #[test] |
339 | 1 | fn empty_subtags_dropped() { |
340 | 1 | assert_eq!(friendly_name("en-"), "English"); |
341 | 1 | assert_eq!(friendly_name("en--US"), "English (United States)"); |
342 | 1 | } |
343 | | } |