1pub mod parser;
14pub mod subtitle;
15
16pub use parser::parse_languages;
17pub use subtitle::{
18 SubtitleTitleInfo, parse_basic_subtitle, parse_language_tags, parse_subtitle_language,
19 parse_subtitle_language_information,
20};
21
22#[repr(i32)]
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
37#[non_exhaustive]
38pub enum Language {
39 #[default]
40 Unknown = 0,
41 English = 1,
42 French = 2,
43 Spanish = 3,
44 German = 4,
45 Italian = 5,
46 Danish = 6,
47 Dutch = 7,
48 Japanese = 8,
49 Icelandic = 9,
50 Chinese = 10,
51 Russian = 11,
52 Polish = 12,
53 Vietnamese = 13,
54 Swedish = 14,
55 Norwegian = 15,
56 Finnish = 16,
57 Turkish = 17,
58 Portuguese = 18,
59 Flemish = 19,
60 Greek = 20,
61 Korean = 21,
62 Hungarian = 22,
63 Hebrew = 23,
64 Lithuanian = 24,
65 Czech = 25,
66 Arabic = 26,
67 Hindi = 27,
68 Bulgarian = 28,
69 Malayalam = 29,
70 Ukrainian = 30,
71 Slovak = 31,
72 Thai = 32,
73 PortugueseBrazil = 33,
74 SpanishLatino = 34,
75 Romanian = 35,
76 Latvian = 36,
77 Persian = 37,
78 Catalan = 38,
79 Croatian = 39,
80 Serbian = 40,
81 Bosnian = 41,
82 Estonian = 42,
83 Tamil = 43,
84 Indonesian = 44,
85 Macedonian = 45,
86 Slovenian = 46,
87 Original = -2,
88}
89
90impl From<Language> for i32 {
91 fn from(lang: Language) -> i32 {
92 lang as i32
93 }
94}
95
96impl Language {
97 pub fn name(self) -> &'static str {
103 match self {
104 Language::Unknown => "Unknown",
105 Language::English => "English",
106 Language::French => "French",
107 Language::Spanish => "Spanish",
108 Language::German => "German",
109 Language::Italian => "Italian",
110 Language::Danish => "Danish",
111 Language::Dutch => "Dutch",
112 Language::Japanese => "Japanese",
113 Language::Icelandic => "Icelandic",
114 Language::Chinese => "Chinese",
115 Language::Russian => "Russian",
116 Language::Polish => "Polish",
117 Language::Vietnamese => "Vietnamese",
118 Language::Swedish => "Swedish",
119 Language::Norwegian => "Norwegian",
120 Language::Finnish => "Finnish",
121 Language::Turkish => "Turkish",
122 Language::Portuguese => "Portuguese",
123 Language::Flemish => "Flemish",
124 Language::Greek => "Greek",
125 Language::Korean => "Korean",
126 Language::Hungarian => "Hungarian",
127 Language::Hebrew => "Hebrew",
128 Language::Lithuanian => "Lithuanian",
129 Language::Czech => "Czech",
130 Language::Arabic => "Arabic",
131 Language::Hindi => "Hindi",
132 Language::Bulgarian => "Bulgarian",
133 Language::Malayalam => "Malayalam",
134 Language::Ukrainian => "Ukrainian",
135 Language::Slovak => "Slovak",
136 Language::Thai => "Thai",
137 Language::PortugueseBrazil => "Portuguese (Brazil)",
138 Language::SpanishLatino => "Spanish (Latino)",
139 Language::Romanian => "Romanian",
140 Language::Latvian => "Latvian",
141 Language::Persian => "Persian",
142 Language::Catalan => "Catalan",
143 Language::Croatian => "Croatian",
144 Language::Serbian => "Serbian",
145 Language::Bosnian => "Bosnian",
146 Language::Estonian => "Estonian",
147 Language::Tamil => "Tamil",
148 Language::Indonesian => "Indonesian",
149 Language::Macedonian => "Macedonian",
150 Language::Slovenian => "Slovenian",
151 Language::Original => "Original",
152 }
153 }
154}
155
156pub mod iso_languages {
164 use super::Language;
165
166 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
170 #[non_exhaustive]
171 pub struct IsoLanguage {
172 pub two_letter_code: &'static str,
173 pub country_code: &'static str,
174 pub three_letter_code: &'static str,
175 pub language: Language,
176 }
177
178 const ALL: &[IsoLanguage] = &[
184 IsoLanguage {
185 two_letter_code: "en",
186 country_code: "",
187 three_letter_code: "eng",
188 language: Language::English,
189 },
190 IsoLanguage {
191 two_letter_code: "fr",
192 country_code: "fr",
193 three_letter_code: "fra",
194 language: Language::French,
195 },
196 IsoLanguage {
197 two_letter_code: "es",
198 country_code: "",
199 three_letter_code: "spa",
200 language: Language::Spanish,
201 },
202 IsoLanguage {
203 two_letter_code: "de",
204 country_code: "de",
205 three_letter_code: "deu",
206 language: Language::German,
207 },
208 IsoLanguage {
209 two_letter_code: "it",
210 country_code: "",
211 three_letter_code: "ita",
212 language: Language::Italian,
213 },
214 IsoLanguage {
215 two_letter_code: "da",
216 country_code: "",
217 three_letter_code: "dan",
218 language: Language::Danish,
219 },
220 IsoLanguage {
221 two_letter_code: "nl",
222 country_code: "",
223 three_letter_code: "nld",
224 language: Language::Dutch,
225 },
226 IsoLanguage {
227 two_letter_code: "ja",
228 country_code: "",
229 three_letter_code: "jpn",
230 language: Language::Japanese,
231 },
232 IsoLanguage {
233 two_letter_code: "is",
234 country_code: "",
235 three_letter_code: "isl",
236 language: Language::Icelandic,
237 },
238 IsoLanguage {
239 two_letter_code: "zh",
240 country_code: "cn",
241 three_letter_code: "zho",
242 language: Language::Chinese,
243 },
244 IsoLanguage {
245 two_letter_code: "ru",
246 country_code: "",
247 three_letter_code: "rus",
248 language: Language::Russian,
249 },
250 IsoLanguage {
251 two_letter_code: "pl",
252 country_code: "",
253 three_letter_code: "pol",
254 language: Language::Polish,
255 },
256 IsoLanguage {
257 two_letter_code: "vi",
258 country_code: "",
259 three_letter_code: "vie",
260 language: Language::Vietnamese,
261 },
262 IsoLanguage {
263 two_letter_code: "sv",
264 country_code: "",
265 three_letter_code: "swe",
266 language: Language::Swedish,
267 },
268 IsoLanguage {
269 two_letter_code: "no",
270 country_code: "",
271 three_letter_code: "nor",
272 language: Language::Norwegian,
273 },
274 IsoLanguage {
276 two_letter_code: "nb",
277 country_code: "",
278 three_letter_code: "nob",
279 language: Language::Norwegian,
280 },
281 IsoLanguage {
282 two_letter_code: "fi",
283 country_code: "",
284 three_letter_code: "fin",
285 language: Language::Finnish,
286 },
287 IsoLanguage {
288 two_letter_code: "tr",
289 country_code: "",
290 three_letter_code: "tur",
291 language: Language::Turkish,
292 },
293 IsoLanguage {
294 two_letter_code: "pt",
295 country_code: "pt",
296 three_letter_code: "por",
297 language: Language::Portuguese,
298 },
299 IsoLanguage {
301 two_letter_code: "nl",
302 country_code: "",
303 three_letter_code: "nld",
304 language: Language::Flemish,
305 },
306 IsoLanguage {
307 two_letter_code: "el",
308 country_code: "",
309 three_letter_code: "ell",
310 language: Language::Greek,
311 },
312 IsoLanguage {
313 two_letter_code: "ko",
314 country_code: "",
315 three_letter_code: "kor",
316 language: Language::Korean,
317 },
318 IsoLanguage {
319 two_letter_code: "hu",
320 country_code: "",
321 three_letter_code: "hun",
322 language: Language::Hungarian,
323 },
324 IsoLanguage {
325 two_letter_code: "he",
326 country_code: "",
327 three_letter_code: "heb",
328 language: Language::Hebrew,
329 },
330 IsoLanguage {
331 two_letter_code: "lt",
332 country_code: "",
333 three_letter_code: "lit",
334 language: Language::Lithuanian,
335 },
336 IsoLanguage {
337 two_letter_code: "cs",
338 country_code: "",
339 three_letter_code: "ces",
340 language: Language::Czech,
341 },
342 IsoLanguage {
343 two_letter_code: "ar",
344 country_code: "",
345 three_letter_code: "ara",
346 language: Language::Arabic,
347 },
348 IsoLanguage {
349 two_letter_code: "hi",
350 country_code: "",
351 three_letter_code: "hin",
352 language: Language::Hindi,
353 },
354 IsoLanguage {
355 two_letter_code: "bg",
356 country_code: "",
357 three_letter_code: "bul",
358 language: Language::Bulgarian,
359 },
360 IsoLanguage {
361 two_letter_code: "ml",
362 country_code: "",
363 three_letter_code: "mal",
364 language: Language::Malayalam,
365 },
366 IsoLanguage {
367 two_letter_code: "uk",
368 country_code: "",
369 three_letter_code: "ukr",
370 language: Language::Ukrainian,
371 },
372 IsoLanguage {
373 two_letter_code: "sk",
374 country_code: "",
375 three_letter_code: "slk",
376 language: Language::Slovak,
377 },
378 IsoLanguage {
379 two_letter_code: "th",
380 country_code: "th",
381 three_letter_code: "tha",
382 language: Language::Thai,
383 },
384 IsoLanguage {
385 two_letter_code: "pt",
386 country_code: "br",
387 three_letter_code: "por",
388 language: Language::PortugueseBrazil,
389 },
390 IsoLanguage {
391 two_letter_code: "es",
392 country_code: "mx",
393 three_letter_code: "spa",
394 language: Language::SpanishLatino,
395 },
396 IsoLanguage {
397 two_letter_code: "ro",
398 country_code: "",
399 three_letter_code: "ron",
400 language: Language::Romanian,
401 },
402 IsoLanguage {
403 two_letter_code: "lv",
404 country_code: "",
405 three_letter_code: "lav",
406 language: Language::Latvian,
407 },
408 IsoLanguage {
409 two_letter_code: "fa",
410 country_code: "",
411 three_letter_code: "fas",
412 language: Language::Persian,
413 },
414 IsoLanguage {
415 two_letter_code: "ca",
416 country_code: "",
417 three_letter_code: "cat",
418 language: Language::Catalan,
419 },
420 IsoLanguage {
421 two_letter_code: "hr",
422 country_code: "",
423 three_letter_code: "hrv",
424 language: Language::Croatian,
425 },
426 IsoLanguage {
427 two_letter_code: "sr",
428 country_code: "",
429 three_letter_code: "srp",
430 language: Language::Serbian,
431 },
432 IsoLanguage {
433 two_letter_code: "bs",
434 country_code: "",
435 three_letter_code: "bos",
436 language: Language::Bosnian,
437 },
438 IsoLanguage {
439 two_letter_code: "et",
440 country_code: "",
441 three_letter_code: "est",
442 language: Language::Estonian,
443 },
444 IsoLanguage {
445 two_letter_code: "ta",
446 country_code: "",
447 three_letter_code: "tam",
448 language: Language::Tamil,
449 },
450 IsoLanguage {
451 two_letter_code: "id",
452 country_code: "",
453 three_letter_code: "ind",
454 language: Language::Indonesian,
455 },
456 IsoLanguage {
457 two_letter_code: "mk",
458 country_code: "",
459 three_letter_code: "mkd",
460 language: Language::Macedonian,
461 },
462 IsoLanguage {
463 two_letter_code: "sl",
464 country_code: "",
465 three_letter_code: "slv",
466 language: Language::Slovenian,
467 },
468 ];
469
470 const ALTERNATE: &[(&str, Language)] = &[
476 ("cze", Language::Czech),
477 ("dut", Language::Dutch),
478 ("mac", Language::Macedonian),
479 ("rum", Language::Romanian),
480 ("yue", Language::Chinese),
481 ("zhtw", Language::Chinese),
482 ];
483
484 const B_TO_T_MAP: &[(&str, &str)] = &[
491 ("alb", "sqi"),
492 ("arm", "hye"),
493 ("baq", "eus"),
494 ("bur", "mya"),
495 ("chi", "zho"),
496 ("cze", "ces"),
497 ("dut", "nld"),
498 ("fre", "fra"),
499 ("geo", "kat"),
500 ("ger", "deu"),
501 ("gre", "ell"),
502 ("ice", "isl"),
503 ("mac", "mkd"),
504 ("mao", "mri"),
505 ("may", "msa"),
506 ("per", "fas"),
507 ("rum", "ron"),
508 ("slo", "slk"),
509 ("tib", "bod"),
510 ("wel", "cym"),
511 ];
512
513 pub fn find(code: &str) -> Option<Language> {
539 find_iso(code).map(|iso| iso.language)
540 }
541
542 pub fn find_iso(code: &str) -> Option<IsoLanguage> {
546 let lowered = code.to_ascii_lowercase();
547 let (lang_code, country_code) = match lowered.split_once('-') {
548 Some((l, c)) => (l, Some(c)),
549 None => (lowered.as_str(), None),
550 };
551
552 match lang_code.len() {
553 2 => find_two_letter(lang_code, country_code),
554 3 => {
555 if let Some(lang) = alternate_lookup(&lowered) {
559 return canonical_row(lang);
560 }
561 find_three_letter(lang_code)
562 }
563 _ => alternate_lookup(&lowered).and_then(canonical_row),
566 }
567 }
568
569 fn find_two_letter(lang_code: &str, country_code: Option<&str>) -> Option<IsoLanguage> {
570 let matches: Vec<&IsoLanguage> = ALL
571 .iter()
572 .filter(|l| l.two_letter_code == lang_code)
573 .collect();
574
575 if matches.is_empty() {
576 return None;
577 }
578
579 match country_code {
580 None => matches.first().copied().copied(),
581 Some(country) => {
582 let any_country_match = matches.iter().any(|l| l.country_code == country);
584 if any_country_match {
585 matches
586 .iter()
587 .find(|l| l.country_code == country)
588 .copied()
589 .copied()
590 } else {
591 matches
593 .iter()
594 .find(|l| l.country_code.is_empty())
595 .copied()
596 .copied()
597 }
598 }
599 }
600 }
601
602 fn find_three_letter(lang_code: &str) -> Option<IsoLanguage> {
603 let mapped = B_TO_T_MAP
604 .iter()
605 .find_map(|(b, t)| (*b == lang_code).then_some(*t))
606 .unwrap_or(lang_code);
607 ALL.iter().find(|l| l.three_letter_code == mapped).copied()
608 }
609
610 fn alternate_lookup(lowered: &str) -> Option<Language> {
611 ALTERNATE
612 .iter()
613 .find_map(|(code, lang)| (*code == lowered).then_some(*lang))
614 }
615
616 fn canonical_row(language: Language) -> Option<IsoLanguage> {
632 ALL.iter().find(|l| l.language == language).copied()
633 }
634}
635
636#[cfg(test)]
637mod tests {
638 use super::*;
639
640 #[test]
643 fn language_constants_have_expected_ids() {
644 assert_eq!(Language::Unknown as i32, 0);
645 assert_eq!(Language::English as i32, 1);
646 assert_eq!(Language::SpanishLatino as i32, 34);
647 assert_eq!(Language::Slovenian as i32, 46);
648 assert_eq!(Language::Original as i32, -2);
649 }
650
651 #[test]
652 fn iso_languages_finds_eng() {
653 assert_eq!(iso_languages::find("eng"), Some(Language::English));
654 }
655
656 #[test]
657 fn iso_languages_finds_fre() {
658 assert_eq!(iso_languages::find("fre"), Some(Language::French));
660 }
661
662 #[test]
663 fn iso_languages_unknown_for_garbage() {
664 assert_eq!(iso_languages::find("zzz"), None);
665 }
666
667 #[test]
670 fn english_resolves_via_two_letter_three_letter_and_locale() {
671 for code in ["en", "eng", "en-US", "en-GB"] {
672 assert_eq!(
673 iso_languages::find(code),
674 Some(Language::English),
675 "code = {code}"
676 );
677 }
678 }
679
680 #[test]
681 fn unknown_or_invalid_codes_return_none() {
682 for code in ["enus", "enusa", "wo", "fr-CA"] {
684 assert_eq!(iso_languages::find(code), None, "code = {code}");
685 }
686 }
687
688 #[test]
689 fn portuguese_resolves_via_two_letter_three_letter_and_locale() {
690 for code in ["pt", "por", "pt-PT"] {
691 assert_eq!(
692 iso_languages::find(code),
693 Some(Language::Portuguese),
694 "code = {code}"
695 );
696 }
697 }
698
699 #[test]
700 fn de_au_returns_none_because_german_requires_de_country_code() {
701 assert_eq!(iso_languages::find("de-AU"), None);
704 }
705
706 #[test]
707 fn cze_resolves_via_alternate_iso_code_mapping() {
708 assert_eq!(iso_languages::find("cze"), Some(Language::Czech));
709 }
710
711 #[test]
714 fn pt_br_resolves_to_portuguese_brazil() {
715 assert_eq!(
716 iso_languages::find("pt-br"),
717 Some(Language::PortugueseBrazil)
718 );
719 assert_eq!(
721 iso_languages::find("pt-BR"),
722 Some(Language::PortugueseBrazil)
723 );
724 }
725
726 #[test]
727 fn es_mx_resolves_to_spanish_latino_but_bare_es_to_spanish() {
728 assert_eq!(iso_languages::find("es"), Some(Language::Spanish));
729 assert_eq!(iso_languages::find("es-mx"), Some(Language::SpanishLatino));
730 }
731
732 #[test]
733 fn bare_nl_resolves_to_dutch_not_flemish() {
734 assert_eq!(iso_languages::find("nl"), Some(Language::Dutch));
737 assert_eq!(iso_languages::find("nld"), Some(Language::Dutch));
738 }
739
740 #[test]
741 fn b_to_t_map_resolves_old_ger_form_to_german() {
742 assert_eq!(iso_languages::find("ger"), Some(Language::German));
743 assert_eq!(iso_languages::find("deu"), Some(Language::German));
744 }
745
746 #[test]
747 fn alternate_zhtw_resolves_to_chinese() {
748 assert_eq!(iso_languages::find("zhtw"), Some(Language::Chinese));
751 }
752
753 #[test]
754 fn nb_two_letter_resolves_to_norwegian() {
755 assert_eq!(iso_languages::find("nb"), Some(Language::Norwegian));
757 assert_eq!(iso_languages::find("nob"), Some(Language::Norwegian));
758 }
759
760 #[test]
761 fn case_is_normalised() {
762 assert_eq!(iso_languages::find("ENG"), Some(Language::English));
763 assert_eq!(iso_languages::find("Fr"), Some(Language::French));
764 }
765
766 #[test]
767 fn empty_and_oversized_codes_return_none() {
768 assert_eq!(iso_languages::find(""), None);
769 assert_eq!(iso_languages::find("english"), None);
770 }
771
772 #[test]
773 fn language_default_is_unknown() {
774 assert_eq!(Language::default(), Language::Unknown);
775 }
776
777 #[test]
778 fn language_name_round_trips_for_brazil_and_latino() {
779 assert_eq!(Language::PortugueseBrazil.name(), "Portuguese (Brazil)");
780 assert_eq!(Language::SpanishLatino.name(), "Spanish (Latino)");
781 assert_eq!(Language::Original.name(), "Original");
782 }
783
784 #[test]
785 fn find_iso_returns_full_record_for_canonical_codes() {
786 let iso = iso_languages::find_iso("pt-br").expect("pt-br must resolve");
787 assert_eq!(iso.language, Language::PortugueseBrazil);
788 assert_eq!(iso.two_letter_code, "pt");
789 assert_eq!(iso.country_code, "br");
790 assert_eq!(iso.three_letter_code, "por");
791 }
792
793 #[test]
794 fn find_iso_alternate_hit_returns_canonical_row_not_synthetic() {
795 let cze = iso_languages::find_iso("cze").expect("alternate hit");
799 assert_eq!(cze.language, Language::Czech);
800 assert_eq!(cze.three_letter_code, "ces"); assert_eq!(cze.two_letter_code, "cs"); }
803
804 #[test]
805 fn from_language_to_i32_preserves_id() {
806 assert_eq!(i32::from(Language::English), 1);
807 assert_eq!(i32::from(Language::Original), -2);
808 assert_eq!(i32::from(Language::Slovenian), 46);
809 }
810
811 #[test]
820 fn enum_has_exactly_48_variants_with_contiguous_ids() {
821 let all: &[Language] = &[
825 Language::Unknown,
826 Language::English,
827 Language::French,
828 Language::Spanish,
829 Language::German,
830 Language::Italian,
831 Language::Danish,
832 Language::Dutch,
833 Language::Japanese,
834 Language::Icelandic,
835 Language::Chinese,
836 Language::Russian,
837 Language::Polish,
838 Language::Vietnamese,
839 Language::Swedish,
840 Language::Norwegian,
841 Language::Finnish,
842 Language::Turkish,
843 Language::Portuguese,
844 Language::Flemish,
845 Language::Greek,
846 Language::Korean,
847 Language::Hungarian,
848 Language::Hebrew,
849 Language::Lithuanian,
850 Language::Czech,
851 Language::Arabic,
852 Language::Hindi,
853 Language::Bulgarian,
854 Language::Malayalam,
855 Language::Ukrainian,
856 Language::Slovak,
857 Language::Thai,
858 Language::PortugueseBrazil,
859 Language::SpanishLatino,
860 Language::Romanian,
861 Language::Latvian,
862 Language::Persian,
863 Language::Catalan,
864 Language::Croatian,
865 Language::Serbian,
866 Language::Bosnian,
867 Language::Estonian,
868 Language::Tamil,
869 Language::Indonesian,
870 Language::Macedonian,
871 Language::Slovenian,
872 Language::Original,
873 ];
874 assert_eq!(all.len(), 48);
875 for (idx, lang) in all.iter().enumerate() {
878 if idx < 47 {
879 assert_eq!(*lang as i32, idx as i32, "variant at index {idx}");
880 } else {
881 assert_eq!(*lang as i32, -2, "Original sentinel last");
882 }
883 }
884 }
885}