Skip to main content

azul_layout/text3/
script.rs

1//! Unicode script detection and language identification for text shaping
2//!
3// Taken from: https://github.com/greyblake/whatlang-rs/blob/master/src/scripts/detect.rs
4//
5// See: https://github.com/greyblake/whatlang-rs/pull/67
6
7// License:
8//
9// (The MIT License)
10//
11// Copyright (c) 2017 Sergey Potapov <blake131313@gmail.com>
12// Copyright (c) 2014 Titus Wormer <tituswormer@gmail.com>
13// Copyright (c) 2008 Kent S Johnson
14// Copyright (c) 2006 Jacob R Rideout <kde@jacobrideout.net>
15// Copyright (c) 2004 Maciej Ceglowski
16//
17// Permission is hereby granted, free of charge, to any person obtaining
18// a copy of this software and associated documentation files (the
19// 'Software'), to deal in the Software without restriction, including
20// without limitation the rights to use, copy, modify, merge, publish,
21// distribute, sublicense, and/or sell copies of the Software, and to
22// permit persons to whom the Software is furnished to do so, subject to
23// the following conditions:
24//
25// The above copyright notice and this permission notice shall be
26// included in all copies or substantial portions of the Software.
27//
28// THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
29// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
30// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
31// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
32// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
33// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
34// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
35
36#[cfg(feature = "text_layout_hyphenation")]
37use hyphenation::Language as HyphenationLanguage;
38#[cfg(feature = "text_layout_hyphenation")]
39pub use hyphenation::Language;
40
41/// Stub Language enum for when hyphenation is not enabled.
42/// This mirrors the variants used in script detection functions.
43#[cfg(not(feature = "text_layout_hyphenation"))]
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
45#[allow(dead_code)]
46pub enum Language {
47    // Latin script languages
48    EnglishUS,
49    French,
50    German1996,
51    Spanish,
52    Portuguese,
53    Estonian,
54    Hungarian,
55    Polish,
56    Czech,
57    Slovak,
58    Latvian,
59    Lithuanian,
60    Romanian,
61    Turkish,
62    Croatian,
63    Icelandic,
64    Welsh,
65    NorwegianBokmal,
66    Swedish,
67    // Cyrillic script languages
68    Russian,
69    Ukrainian,
70    Belarusian,
71    Bulgarian,
72    Macedonian,
73    SerbianCyrillic,
74    Mongolian,
75    SlavonicChurch,
76    // Greek script languages
77    GreekMono,
78    GreekPoly,
79    Coptic,
80    // Indic script languages
81    Hindi,
82    Bengali,
83    Assamese,
84    Marathi,
85    Sanskrit,
86    Gujarati,
87    Panjabi,
88    Kannada,
89    Malayalam,
90    Oriya,
91    Tamil,
92    Telugu,
93    // Other scripts
94    Georgian,
95    Ethiopic,
96    Thai,
97    Chinese,
98}
99
100#[derive(PartialEq, Eq, Debug, Clone, Copy)]
101pub enum Script {
102    // Keep this in alphabetic order (for C bindings)
103    Arabic,
104    Bengali,
105    Cyrillic,
106    Devanagari,
107    Ethiopic,
108    Georgian,
109    Greek,
110    Gujarati,
111    Gurmukhi,
112    Hangul,
113    Hebrew,
114    Hiragana,
115    Kannada,
116    Katakana,
117    Khmer,
118    Latin,
119    Malayalam,
120    Mandarin,
121    Myanmar,
122    Oriya,
123    Sinhala,
124    Tamil,
125    Telugu,
126    Thai,
127}
128
129// Is it space, punctuation or digit?
130// Stop character is a character that does not give any value for script
131// or language detection.
132#[inline]
133#[must_use] pub const fn is_stop_char(ch: char) -> bool {
134    matches!(ch, '\u{0000}'..='\u{0040}' | '\u{005B}'..='\u{0060}' | '\u{007B}'..='\u{007E}')
135}
136
137type ScriptChecker = (Script, fn(char) -> bool);
138type ScriptCounter = (Script, fn(char) -> bool, usize);
139
140const SCRIPT_CHECKERS: [ScriptChecker; 24] = [
141    (Script::Latin, is_latin),
142    (Script::Cyrillic, is_cyrillic),
143    (Script::Arabic, is_arabic),
144    (Script::Mandarin, is_mandarin),
145    (Script::Devanagari, is_devanagari),
146    (Script::Hebrew, is_hebrew),
147    (Script::Ethiopic, is_ethiopic),
148    (Script::Georgian, is_georgian),
149    (Script::Bengali, is_bengali),
150    (Script::Hangul, is_hangul),
151    (Script::Hiragana, is_hiragana),
152    (Script::Katakana, is_katakana),
153    (Script::Greek, is_greek),
154    (Script::Kannada, is_kannada),
155    (Script::Tamil, is_tamil),
156    (Script::Thai, is_thai),
157    (Script::Gujarati, is_gujarati),
158    (Script::Gurmukhi, is_gurmukhi),
159    (Script::Telugu, is_telugu),
160    (Script::Malayalam, is_malayalam),
161    (Script::Oriya, is_oriya),
162    (Script::Myanmar, is_myanmar),
163    (Script::Sinhala, is_sinhala),
164    (Script::Khmer, is_khmer),
165];
166
167/// Detect only a script by a given text
168/// # Panics
169///
170/// Panics only if the internal script-counter table were empty, which cannot happen (it is a fixed-size array).
171pub fn detect_script(text: &str) -> Option<Script> {
172    let mut script_counters: [ScriptCounter; 24] = SCRIPT_CHECKERS.map(|(s, f)| (s, f, 0));
173
174    let half = text.chars().count() / 2;
175
176    for ch in text.chars() {
177        if is_stop_char(ch) {
178            continue;
179        }
180
181        // For performance reasons, we need to mutate script_counters by calling
182        // `swap` function, it would not be possible to do using normal iterator.
183        for i in 0..script_counters.len() {
184            let found = {
185                let (script, check_fn, ref mut count) = script_counters[i];
186                if check_fn(ch) {
187                    *count += 1;
188                    if *count > half {
189                        return Some(script);
190                    }
191                    true
192                } else {
193                    false
194                }
195            };
196            // Have to let borrow of count fall out of scope before doing swapping, or we could
197            // do this above.
198            if found {
199                // If script was found, move it closer to the front.
200                // If the text contains largely 1 or 2 scripts, this will
201                // cause these scripts to be eventually checked first.
202                if i > 0 {
203                    script_counters.swap(i - 1, i);
204                }
205                break;
206            }
207        }
208    }
209
210    let (script, _, count) = script_counters
211        .iter()
212        .copied()
213        .max_by_key(|&(_, _, count)| count)
214        .unwrap();
215    if count != 0 {
216        Some(script)
217    } else {
218        None
219    }
220}
221
222#[must_use] pub fn detect_char_script(ch: char) -> Option<Script> {
223    for &(script, check_fn) in &SCRIPT_CHECKERS {
224        if check_fn(ch) {
225            return Some(script);
226        }
227    }
228    None
229}
230
231/// Iterates through the text once and returns as soon as an Assamese-specific character is found.
232fn detect_bengali_language(text: &str) -> Language {
233    for c in text.chars() {
234        // These characters are specific to Assamese in the Bengali script block.
235        // We can return immediately as this is the highest priority check.
236        if matches!(c, '\u{09F0}' | '\u{09F1}') {
237            // ৰ, ৱ
238            return Language::Assamese;
239        }
240    }
241    // If we finish the loop without finding any Assamese characters, it's Bengali.
242    Language::Bengali
243}
244
245fn detect_cyrillic_language(text: &str) -> Language {
246    for c in text.chars() {
247        match c {
248            // Highest priority: Old Cyrillic characters for Slavonic Church. Return immediately.
249            '\u{0460}'..='\u{047F}' => return Language::SlavonicChurch,
250            // Set flags for other languages. We don't return yet because a higher-priority
251            // character (like the one above) could still appear.
252            'ѓ' | 'ќ' | 'ѕ' => return Language::Macedonian,
253            'ў' => return Language::Belarusian,
254            'є' | 'і' | 'ї' | 'ґ' => return Language::Ukrainian,
255            'ө' | 'ү' | 'һ' => return Language::Mongolian,
256            'ј' | 'љ' | 'њ' | 'ћ' | 'ђ' | 'џ' => return Language::SerbianCyrillic,
257            // Bulgarian 'ъ' is also in Russian, but 'щ' is a stronger indicator.
258            // The logic implies that if either is present, it might be Bulgarian.
259            'щ' => return Language::Bulgarian,
260            _ => {}
261        }
262    }
263
264    Language::Russian
265}
266
267fn detect_devanagari_language(text: &str) -> Language {
268    for c in text.chars() {
269        match c {
270            // Marathi has higher priority in the original logic. Return immediately.
271            '\u{0933}' => return Language::Marathi, // ळ
272            // Flag for Sanskrit Vedic extensions.
273            '\u{1CD0}'..='\u{1CFF}' => return Language::Sanskrit,
274            _ => (),
275        }
276    }
277
278    Language::Hindi
279}
280
281fn detect_greek_language(text: &str) -> Language {
282    for c in text.chars() {
283        match c {
284            // Coptic has higher priority. Return immediately.
285            '\u{2C80}'..='\u{2CFF}' => return Language::Coptic,
286            // Flag for Greek Extended (Polytonic) characters.
287            '\u{1F00}'..='\u{1FFF}' => return Language::GreekPoly,
288            _ => {}
289        }
290    }
291
292    Language::GreekMono
293}
294
295#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
296fn detect_latin_language(text: &str) -> Language {
297    // Flags for languages checked near the end of the original if-else chain.
298    let mut has_french_c = false;
299    let mut has_portuguese_o = false;
300    let mut has_portuguese_a = false;
301
302    for c in text.chars() {
303        match c {
304            // --- Early Return Cases (in order of priority) ---
305            'ß' => return Language::German1996,
306            'ő' | 'ű' => return Language::Hungarian,
307            'ł' => return Language::Polish,
308            'ř' | 'ů' => return Language::Czech,
309            'ľ' | 'ĺ' | 'ŕ' => return Language::Slovak,
310            'ā' | 'ē' | 'ģ' | 'ī' | 'ķ' | 'ļ' | 'ņ' | 'ō' | 'ū' => {
311                return Language::Latvian
312            }
313            'ą' | 'ę' | 'ė' | 'į' | 'ų' => return Language::Lithuanian,
314            'ă' | 'ș' | 'ț' => return Language::Romanian,
315            'ğ' | 'ı' | 'ş' => return Language::Turkish,
316            'đ' => return Language::Croatian, /* Also used in Vietnamese, but Croatian is the */
317            // original's intent
318            'þ' | 'ð' => return Language::Icelandic,
319            'ŵ' | 'ŷ' => return Language::Welsh,
320            'æ' | 'ø' => return Language::NorwegianBokmal, // And Danish
321            'å' => return Language::Swedish,               // And Norwegian, Finnish
322            'ñ' => return Language::Spanish,
323            'ä' | 'ö' | 'ü' => return Language::German1996,
324
325            // NOTE: 'õ' is used by both Estonian and Portuguese
326            // Since Estonian is checked first, it takes precedence.
327            'õ' => has_portuguese_o = true,
328            'ã' => has_portuguese_a = true,
329
330            // --- Flag-setting Cases ---
331            'ç' => has_french_c = true, // Also in Portuguese
332            'á' | 'é' | 'í' | 'ó' | 'ú' => return Language::Spanish,
333
334            _ => (),
335        }
336    }
337
338    // decide between portuguese, estonian and french
339
340    if has_french_c && !has_portuguese_o && !has_portuguese_a {
341        return Language::French;
342    }
343
344    if has_portuguese_o && !has_french_c && !has_portuguese_a {
345        return Language::Estonian;
346    }
347
348    if has_portuguese_o || has_portuguese_a || has_french_c {
349        return Language::Portuguese;
350    }
351
352    Language::EnglishUS
353}
354
355#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
356#[must_use] pub fn script_to_language(script: Script, text: &str) -> Language {
357    match script {
358        Script::Ethiopic => Language::Ethiopic,
359        Script::Georgian => Language::Georgian,
360        Script::Gujarati => Language::Gujarati,
361        Script::Gurmukhi => Language::Panjabi,
362        Script::Kannada => Language::Kannada,
363        Script::Malayalam => Language::Malayalam,
364        Script::Mandarin => Language::Chinese,
365        Script::Oriya => Language::Oriya,
366        Script::Tamil => Language::Tamil,
367        Script::Telugu => Language::Telugu,
368        Script::Thai => Language::Thai,
369        Script::Bengali => detect_bengali_language(text),
370        Script::Cyrillic => detect_cyrillic_language(text),
371        Script::Devanagari => detect_devanagari_language(text),
372        Script::Greek => detect_greek_language(text),
373        Script::Latin => detect_latin_language(text),
374
375        // not directly matchable
376        Script::Myanmar => Language::Thai,
377        Script::Khmer => Language::Thai,
378        Script::Sinhala => Language::Hindi,
379
380        // no classical hyphenation behaviour
381        Script::Arabic => Language::Chinese,
382        Script::Hebrew => Language::Chinese,
383        Script::Hangul => Language::Chinese,
384        Script::Hiragana => Language::Chinese,
385        Script::Katakana => Language::Chinese,
386    }
387}
388
389#[must_use] pub const fn is_cyrillic(ch: char) -> bool {
390    matches!(ch,
391        '\u{0400}'..='\u{0484}'
392        | '\u{0487}'..='\u{052F}'
393        | '\u{2DE0}'..='\u{2DFF}'
394        | '\u{A640}'..='\u{A69D}'
395        | '\u{1D2B}'
396        | '\u{1D78}'
397        | '\u{A69F}'
398    )
399}
400
401// https://en.wikipedia.org/wiki/Latin_script_in_Unicode
402#[must_use] pub const fn is_latin(ch: char) -> bool {
403    matches!(ch,
404        'a'..='z'
405        | 'A'..='Z'
406        | '\u{0080}'..='\u{00FF}'
407        | '\u{0100}'..='\u{017F}'
408        | '\u{0180}'..='\u{024F}'
409        | '\u{0250}'..='\u{02AF}'
410        | '\u{1D00}'..='\u{1D7F}'
411        | '\u{1D80}'..='\u{1DBF}'
412        | '\u{1E00}'..='\u{1EFF}'
413        | '\u{2100}'..='\u{214F}'
414        | '\u{2C60}'..='\u{2C7F}'
415        | '\u{A720}'..='\u{A7FF}'
416        | '\u{AB30}'..='\u{AB6F}'
417    )
418}
419
420// Based on https://en.wikipedia.org/wiki/Arabic_script_in_Unicode
421#[must_use] pub const fn is_arabic(ch: char) -> bool {
422    matches!(ch,
423        '\u{0600}'..='\u{06FF}'
424        | '\u{0750}'..='\u{07FF}'
425        | '\u{08A0}'..='\u{08FF}'
426        | '\u{FB50}'..='\u{FDFF}'
427        | '\u{FE70}'..='\u{FEFF}'
428        | '\u{10E60}'..='\u{10E7F}'
429        | '\u{1EE00}'..='\u{1EEFF}'
430    )
431}
432
433// Based on https://en.wikipedia.org/wiki/Devanagari#Unicode
434#[must_use] pub const fn is_devanagari(ch: char) -> bool {
435    matches!(ch, '\u{0900}'..='\u{097F}' | '\u{A8E0}'..='\u{A8FF}' | '\u{1CD0}'..='\u{1CFF}')
436}
437
438// Based on https://www.key-shortcut.com/en/writing-systems/ethiopian-script/
439#[must_use] pub const fn is_ethiopic(ch: char) -> bool {
440    matches!(ch, '\u{1200}'..='\u{139F}' | '\u{2D80}'..='\u{2DDF}' | '\u{AB00}'..='\u{AB2F}')
441}
442
443// Based on https://en.wikipedia.org/wiki/Hebrew_(Unicode_block)
444#[must_use] pub const fn is_hebrew(ch: char) -> bool {
445    matches!(ch, '\u{0590}'..='\u{05FF}')
446}
447
448#[must_use] pub const fn is_georgian(ch: char) -> bool {
449    matches!(ch, '\u{10A0}'..='\u{10FF}')
450}
451
452#[must_use] pub const fn is_mandarin(ch: char) -> bool {
453    matches!(ch,
454        '\u{2E80}'..='\u{2E99}'
455        | '\u{2E9B}'..='\u{2EF3}'
456        | '\u{2F00}'..='\u{2FD5}'
457        | '\u{3005}'
458        | '\u{3007}'
459        | '\u{3021}'..='\u{3029}'
460        | '\u{3038}'..='\u{303B}'
461        | '\u{3400}'..='\u{4DB5}'
462        | '\u{4E00}'..='\u{9FCC}'
463        | '\u{F900}'..='\u{FA6D}'
464        | '\u{FA70}'..='\u{FAD9}'
465    )
466}
467
468#[must_use] pub const fn is_bengali(ch: char) -> bool {
469    matches!(ch, '\u{0980}'..='\u{09FF}')
470}
471
472#[must_use] pub const fn is_hiragana(ch: char) -> bool {
473    matches!(ch, '\u{3040}'..='\u{309F}')
474}
475
476#[must_use] pub const fn is_katakana(ch: char) -> bool {
477    matches!(ch,
478        '\u{30A0}'..='\u{30FF}'
479        // Halfwidth Katakana (part of the Halfwidth and Fullwidth Forms block).
480        // U+FF66..=FF9F are katakana; U+FF61..=FF65 are halfwidth CJK punctuation.
481        | '\u{FF66}'..='\u{FF9F}'
482    )
483}
484
485// Hangul is Korean Alphabet. Unicode ranges are taken from: https://en.wikipedia.org/wiki/Hangul
486#[must_use] pub const fn is_hangul(ch: char) -> bool {
487    matches!(ch,
488        '\u{AC00}'..='\u{D7AF}'
489        | '\u{1100}'..='\u{11FF}'
490        | '\u{3130}'..='\u{318F}'
491        | '\u{A960}'..='\u{A97F}'
492        | '\u{D7B0}'..='\u{D7FF}'
493        // Halfwidth Hangul variants only. The rest of the Halfwidth and Fullwidth
494        // Forms block (U+FF00..=FF60 fullwidth ASCII/Latin, U+FF61..=FF9F halfwidth
495        // katakana/punct, U+FFE0..=FFEF fullwidth/halfwidth symbols) and Enclosed CJK
496        // Letters and Months (U+3200..=32FF) are NOT Hangul and were previously
497        // swallowed here, misclassifying halfwidth kana and fullwidth Latin as Hangul.
498        | '\u{FFA0}'..='\u{FFDC}'
499    )
500}
501
502// Taken from: https://en.wikipedia.org/wiki/Greek_and_Coptic
503#[must_use] pub const fn is_greek(ch: char) -> bool {
504    matches!(ch, '\u{0370}'..='\u{03FF}')
505}
506
507// Based on: https://en.wikipedia.org/wiki/Kannada_(Unicode_block)
508#[must_use] pub const fn is_kannada(ch: char) -> bool {
509    matches!(ch, '\u{0C80}'..='\u{0CFF}')
510}
511
512// Based on: https://en.wikipedia.org/wiki/Tamil_(Unicode_block)
513#[must_use] pub const fn is_tamil(ch: char) -> bool {
514    matches!(ch, '\u{0B80}'..='\u{0BFF}')
515}
516
517// Based on: https://en.wikipedia.org/wiki/Thai_(Unicode_block)
518#[must_use] pub const fn is_thai(ch: char) -> bool {
519    matches!(ch, '\u{0E00}'..='\u{0E7F}')
520}
521
522// Based on: https://en.wikipedia.org/wiki/Gujarati_(Unicode_block)
523#[must_use] pub const fn is_gujarati(ch: char) -> bool {
524    matches!(ch, '\u{0A80}'..='\u{0AFF}')
525}
526
527// Gurmukhi is the script for Punjabi language.
528// Based on: https://en.wikipedia.org/wiki/Gurmukhi_(Unicode_block)
529#[must_use] pub const fn is_gurmukhi(ch: char) -> bool {
530    matches!(ch, '\u{0A00}'..='\u{0A7F}')
531}
532
533#[must_use] pub const fn is_telugu(ch: char) -> bool {
534    matches!(ch, '\u{0C00}'..='\u{0C7F}')
535}
536
537// Based on: https://en.wikipedia.org/wiki/Malayalam_(Unicode_block)
538#[must_use] pub const fn is_malayalam(ch: char) -> bool {
539    matches!(ch, '\u{0D00}'..='\u{0D7F}')
540}
541
542// Based on: https://en.wikipedia.org/wiki/Oriya_(Unicode_block)
543#[must_use] pub const fn is_oriya(ch: char) -> bool {
544    matches!(ch, '\u{0B00}'..='\u{0B7F}')
545}
546
547// Based on: https://en.wikipedia.org/wiki/Myanmar_(Unicode_block)
548#[must_use] pub const fn is_myanmar(ch: char) -> bool {
549    matches!(ch, '\u{1000}'..='\u{109F}')
550}
551
552// Based on: https://en.wikipedia.org/wiki/Sinhala_(Unicode_block)
553#[must_use] pub const fn is_sinhala(ch: char) -> bool {
554    matches!(ch, '\u{0D80}'..='\u{0DFF}')
555}
556
557// Based on: https://en.wikipedia.org/wiki/Khmer_alphabet
558#[must_use] pub const fn is_khmer(ch: char) -> bool {
559    matches!(ch, '\u{1780}'..='\u{17FF}' | '\u{19E0}'..='\u{19FF}')
560}
561
562#[cfg(test)]
563mod script_class_tests {
564    use super::{detect_script, is_hangul, is_katakana, Script};
565
566    #[test]
567    fn halfwidth_katakana_is_not_hangul() {
568        // U+FF71..FF73 = halfwidth katakana アイウ — must classify as Katakana, not Hangul.
569        for ch in ['\u{FF71}', '\u{FF72}', '\u{FF73}'] {
570            assert!(!is_hangul(ch), "{ch:?} wrongly matched is_hangul");
571            assert!(is_katakana(ch), "{ch:?} should match is_katakana");
572        }
573        assert_eq!(detect_script("\u{FF71}\u{FF72}\u{FF73}"), Some(Script::Katakana));
574    }
575
576    #[test]
577    fn fullwidth_latin_is_not_hangul() {
578        // U+FF21..FF23 = fullwidth ABC — must not be classified as Hangul.
579        for ch in ['\u{FF21}', '\u{FF22}', '\u{FF23}'] {
580            assert!(!is_hangul(ch), "{ch:?} wrongly matched is_hangul");
581        }
582        assert_ne!(detect_script("\u{FF21}\u{FF22}\u{FF23}"), Some(Script::Hangul));
583    }
584
585    #[test]
586    fn real_hangul_still_detected() {
587        assert!(is_hangul('\u{AC00}')); // 가
588        assert_eq!(detect_script("\u{AC00}\u{AC01}"), Some(Script::Hangul));
589    }
590}