Skip to main content

text_typeset/font/
writing_system.rs

1//! Writing-system (script) classification for installed fonts.
2//!
3//! Mirrors Qt's `QFontDatabase::WritingSystem`: a font is classified by
4//! the scripts it can render. Detection reads the OS/2 table's
5//! `ulUnicodeRange` (128 script-coverage bits) for the script-level
6//! systems, its `ulCodePageRange` for the CJK-language + Vietnamese
7//! distinction (Simplified vs Traditional Chinese share codepoints, so
8//! they cannot be told apart from Unicode coverage alone — Qt uses the
9//! same code-page heuristic), and a cmap sample-codepoint cross-check for
10//! fonts whose OS/2 ranges are absent or wrong (common in subset/older
11//! fonts).
12//!
13//! `ttf-parser` types `ulUnicodeRange` but not `ulCodePageRange`, so the
14//! code-page word is read straight from the raw OS/2 table bytes. Every
15//! function here takes owned font bytes, so it is `Send` and runs on the
16//! background index thread (see [`super::writing_system_index`]), never on
17//! the UI thread.
18
19use ttf_parser::{Face, Tag};
20
21/// A script / writing system a font can render.
22///
23/// The set mirrors Qt's `QFontDatabase::WritingSystem` (minus the
24/// redundant `Other`, which Qt documents as an alias of [`Symbol`]).
25///
26/// [`Symbol`]: WritingSystem::Symbol
27#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
28#[repr(u8)]
29pub enum WritingSystem {
30    Latin = 0,
31    Greek,
32    Cyrillic,
33    Armenian,
34    Hebrew,
35    Arabic,
36    Syriac,
37    Thaana,
38    Devanagari,
39    Bengali,
40    Gurmukhi,
41    Gujarati,
42    Oriya,
43    Tamil,
44    Telugu,
45    Kannada,
46    Malayalam,
47    Sinhala,
48    Thai,
49    Lao,
50    Tibetan,
51    Myanmar,
52    Georgian,
53    Khmer,
54    SimplifiedChinese,
55    TraditionalChinese,
56    Japanese,
57    Korean,
58    Vietnamese,
59    Symbol,
60    Ogham,
61    Runic,
62    Nko,
63}
64
65impl WritingSystem {
66    /// Every writing system, in declaration order.
67    pub const ALL: [WritingSystem; 33] = [
68        WritingSystem::Latin,
69        WritingSystem::Greek,
70        WritingSystem::Cyrillic,
71        WritingSystem::Armenian,
72        WritingSystem::Hebrew,
73        WritingSystem::Arabic,
74        WritingSystem::Syriac,
75        WritingSystem::Thaana,
76        WritingSystem::Devanagari,
77        WritingSystem::Bengali,
78        WritingSystem::Gurmukhi,
79        WritingSystem::Gujarati,
80        WritingSystem::Oriya,
81        WritingSystem::Tamil,
82        WritingSystem::Telugu,
83        WritingSystem::Kannada,
84        WritingSystem::Malayalam,
85        WritingSystem::Sinhala,
86        WritingSystem::Thai,
87        WritingSystem::Lao,
88        WritingSystem::Tibetan,
89        WritingSystem::Myanmar,
90        WritingSystem::Georgian,
91        WritingSystem::Khmer,
92        WritingSystem::SimplifiedChinese,
93        WritingSystem::TraditionalChinese,
94        WritingSystem::Japanese,
95        WritingSystem::Korean,
96        WritingSystem::Vietnamese,
97        WritingSystem::Symbol,
98        WritingSystem::Ogham,
99        WritingSystem::Runic,
100        WritingSystem::Nko,
101    ];
102
103    /// Stable machine identifier (settings keys, i18n label lookup, debug).
104    /// Never localized and never changes across releases.
105    pub const fn id(self) -> &'static str {
106        match self {
107            WritingSystem::Latin => "latin",
108            WritingSystem::Greek => "greek",
109            WritingSystem::Cyrillic => "cyrillic",
110            WritingSystem::Armenian => "armenian",
111            WritingSystem::Hebrew => "hebrew",
112            WritingSystem::Arabic => "arabic",
113            WritingSystem::Syriac => "syriac",
114            WritingSystem::Thaana => "thaana",
115            WritingSystem::Devanagari => "devanagari",
116            WritingSystem::Bengali => "bengali",
117            WritingSystem::Gurmukhi => "gurmukhi",
118            WritingSystem::Gujarati => "gujarati",
119            WritingSystem::Oriya => "oriya",
120            WritingSystem::Tamil => "tamil",
121            WritingSystem::Telugu => "telugu",
122            WritingSystem::Kannada => "kannada",
123            WritingSystem::Malayalam => "malayalam",
124            WritingSystem::Sinhala => "sinhala",
125            WritingSystem::Thai => "thai",
126            WritingSystem::Lao => "lao",
127            WritingSystem::Tibetan => "tibetan",
128            WritingSystem::Myanmar => "myanmar",
129            WritingSystem::Georgian => "georgian",
130            WritingSystem::Khmer => "khmer",
131            WritingSystem::SimplifiedChinese => "simplified-chinese",
132            WritingSystem::TraditionalChinese => "traditional-chinese",
133            WritingSystem::Japanese => "japanese",
134            WritingSystem::Korean => "korean",
135            WritingSystem::Vietnamese => "vietnamese",
136            WritingSystem::Symbol => "symbol",
137            WritingSystem::Ogham => "ogham",
138            WritingSystem::Runic => "runic",
139            WritingSystem::Nko => "nko",
140        }
141    }
142
143    /// An untranslated English display name — a convenience for hosts that
144    /// don't localize the writing-system list. The GUI layer normally maps
145    /// [`id`](Self::id) to a translated label instead.
146    pub const fn english_name(self) -> &'static str {
147        match self {
148            WritingSystem::Latin => "Latin",
149            WritingSystem::Greek => "Greek",
150            WritingSystem::Cyrillic => "Cyrillic",
151            WritingSystem::Armenian => "Armenian",
152            WritingSystem::Hebrew => "Hebrew",
153            WritingSystem::Arabic => "Arabic",
154            WritingSystem::Syriac => "Syriac",
155            WritingSystem::Thaana => "Thaana",
156            WritingSystem::Devanagari => "Devanagari",
157            WritingSystem::Bengali => "Bengali",
158            WritingSystem::Gurmukhi => "Gurmukhi",
159            WritingSystem::Gujarati => "Gujarati",
160            WritingSystem::Oriya => "Oriya",
161            WritingSystem::Tamil => "Tamil",
162            WritingSystem::Telugu => "Telugu",
163            WritingSystem::Kannada => "Kannada",
164            WritingSystem::Malayalam => "Malayalam",
165            WritingSystem::Sinhala => "Sinhala",
166            WritingSystem::Thai => "Thai",
167            WritingSystem::Lao => "Lao",
168            WritingSystem::Tibetan => "Tibetan",
169            WritingSystem::Myanmar => "Myanmar",
170            WritingSystem::Georgian => "Georgian",
171            WritingSystem::Khmer => "Khmer",
172            WritingSystem::SimplifiedChinese => "Simplified Chinese",
173            WritingSystem::TraditionalChinese => "Traditional Chinese",
174            WritingSystem::Japanese => "Japanese",
175            WritingSystem::Korean => "Korean",
176            WritingSystem::Vietnamese => "Vietnamese",
177            WritingSystem::Symbol => "Symbol",
178            WritingSystem::Ogham => "Ogham",
179            WritingSystem::Runic => "Runic",
180            WritingSystem::Nko => "N'Ko",
181        }
182    }
183
184    /// A short sample string in this writing system, for previewing a font
185    /// in a picker. Mirrors Qt's `QFontDatabase::writingSystemSample`.
186    pub const fn sample_text(self) -> &'static str {
187        match self {
188            WritingSystem::Latin => "Aa Bb Yy Zz",
189            WritingSystem::Greek => "Αα Ββ Γγ",
190            WritingSystem::Cyrillic => "Аа Бб Вв",
191            WritingSystem::Armenian => "Աա Բբ Գգ",
192            WritingSystem::Hebrew => "אבּג",
193            WritingSystem::Arabic => "أبجد",
194            WritingSystem::Syriac => "ܐܒܓ",
195            WritingSystem::Thaana => "ހށނ",
196            WritingSystem::Devanagari => "अआइ",
197            WritingSystem::Bengali => "অআই",
198            WritingSystem::Gurmukhi => "ਅਆਇ",
199            WritingSystem::Gujarati => "અઆઇ",
200            WritingSystem::Oriya => "ଅଆଇ",
201            WritingSystem::Tamil => "அஆஇ",
202            WritingSystem::Telugu => "అఆఇ",
203            WritingSystem::Kannada => "ಅಆಇ",
204            WritingSystem::Malayalam => "അആഇ",
205            WritingSystem::Sinhala => "අආඇ",
206            WritingSystem::Thai => "กขค",
207            WritingSystem::Lao => "ກຂຄ",
208            WritingSystem::Tibetan => "ཀཁག",
209            WritingSystem::Myanmar => "ကခဂ",
210            WritingSystem::Georgian => "აბგ",
211            WritingSystem::Khmer => "កខគ",
212            WritingSystem::SimplifiedChinese => "汉字样本",
213            WritingSystem::TraditionalChinese => "漢字樣本",
214            WritingSystem::Japanese => "あア漢字",
215            WritingSystem::Korean => "한국어",
216            WritingSystem::Vietnamese => "Tiếng Việt",
217            WritingSystem::Symbol => "★ ♦ ♣ ♠",
218            WritingSystem::Ogham => "ᚁᚂᚃ",
219            WritingSystem::Runic => "ᚠᚢᚦ",
220            WritingSystem::Nko => "ߊߋߌ",
221        }
222    }
223}
224
225/// A compact set of [`WritingSystem`]s, backed by a `u128` bitset (33
226/// variants ≪ 128 bits). Hand-rolled to avoid a `bitflags` dependency
227/// (the workspace uses none).
228#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Hash)]
229pub struct WritingSystemSet(u128);
230
231impl WritingSystemSet {
232    /// The empty set.
233    pub const fn new() -> Self {
234        Self(0)
235    }
236
237    /// Add a writing system.
238    pub fn insert(&mut self, ws: WritingSystem) {
239        self.0 |= 1u128 << (ws as u8);
240    }
241
242    /// True if `ws` is present.
243    pub const fn contains(self, ws: WritingSystem) -> bool {
244        self.0 & (1u128 << (ws as u8)) != 0
245    }
246
247    /// True if no writing system is present.
248    pub const fn is_empty(self) -> bool {
249        self.0 == 0
250    }
251
252    /// Number of writing systems present.
253    pub const fn len(self) -> u32 {
254        self.0.count_ones()
255    }
256
257    /// The union of two sets.
258    pub const fn union(self, other: Self) -> Self {
259        Self(self.0 | other.0)
260    }
261
262    /// Iterate the present writing systems in [`WritingSystem::ALL`] order.
263    pub fn iter(self) -> impl Iterator<Item = WritingSystem> {
264        WritingSystem::ALL
265            .into_iter()
266            .filter(move |&ws| self.contains(ws))
267    }
268}
269
270// OS/2 `ulUnicodeRange` bit → script-level writing system. Bit numbers per
271// the Microsoft OS/2 spec (matches Qt's `requiredUnicodeBits`). CJK
272// ideographs (bit 59) are handled specially below — the language cannot be
273// read from the Unicode range alone.
274const UNICODE_RANGE_BITS: &[(u8, WritingSystem)] = &[
275    (0, WritingSystem::Latin),       // Basic Latin
276    (7, WritingSystem::Greek),       // Greek and Coptic
277    (9, WritingSystem::Cyrillic),    // Cyrillic
278    (10, WritingSystem::Armenian),   // Armenian
279    (11, WritingSystem::Hebrew),     // Hebrew
280    (13, WritingSystem::Arabic),     // Arabic
281    (14, WritingSystem::Nko),        // NKo
282    (15, WritingSystem::Devanagari), // Devanagari
283    (16, WritingSystem::Bengali),    // Bengali
284    (17, WritingSystem::Gurmukhi),   // Gurmukhi
285    (18, WritingSystem::Gujarati),   // Gujarati
286    (19, WritingSystem::Oriya),      // Oriya
287    (20, WritingSystem::Tamil),      // Tamil
288    (21, WritingSystem::Telugu),     // Telugu
289    (22, WritingSystem::Kannada),    // Kannada
290    (23, WritingSystem::Malayalam),  // Malayalam
291    (24, WritingSystem::Thai),       // Thai
292    (25, WritingSystem::Lao),        // Lao
293    (26, WritingSystem::Georgian),   // Georgian
294    (28, WritingSystem::Korean),     // Hangul Jamo
295    (49, WritingSystem::Japanese),   // Hiragana
296    (50, WritingSystem::Japanese),   // Katakana
297    (52, WritingSystem::Korean),     // Hangul Compatibility Jamo
298    (56, WritingSystem::Korean),     // Hangul Syllables
299    (70, WritingSystem::Tibetan),    // Tibetan
300    (71, WritingSystem::Syriac),     // Syriac
301    (72, WritingSystem::Thaana),     // Thaana
302    (73, WritingSystem::Sinhala),    // Sinhala
303    (74, WritingSystem::Myanmar),    // Myanmar
304    (78, WritingSystem::Ogham),      // Ogham
305    (79, WritingSystem::Runic),      // Runic
306    (80, WritingSystem::Khmer),      // Khmer
307];
308
309// OS/2 `ulCodePageRange1` bit → writing system. The code-page word is the
310// only reliable signal for the CJK-language + Vietnamese distinction.
311const CODEPAGE_RANGE1_BITS: &[(u8, WritingSystem)] = &[
312    (8, WritingSystem::Vietnamese),          // 1258 Vietnamese
313    (16, WritingSystem::Thai),               // 874  Thai
314    (17, WritingSystem::Japanese),           // 932  JIS/Japan
315    (18, WritingSystem::SimplifiedChinese),  // 936  Chinese Simplified
316    (19, WritingSystem::Korean),             // 949  Korean Wansung
317    (20, WritingSystem::TraditionalChinese), // 950  Chinese Traditional
318    (21, WritingSystem::Korean),             // 1361 Korean Johab
319];
320
321// cmap sample codepoints per writing system — the cross-check for fonts
322// whose OS/2 ranges are absent or wrong. Every listed codepoint must be
323// covered for the system to be asserted. Simplified/Traditional Chinese are
324// intentionally absent (undecidable from codepoints) and handled via Han
325// below.
326const CMAP_PROBES: &[(WritingSystem, &[char])] = &[
327    (WritingSystem::Latin, &['A', 'z']),
328    (WritingSystem::Greek, &['Α', 'ω']),
329    (WritingSystem::Cyrillic, &['А', 'я']),
330    (WritingSystem::Armenian, &['Ա', 'ֆ']),
331    (WritingSystem::Hebrew, &['א', 'ת']),
332    (WritingSystem::Arabic, &['ا', 'ي']),
333    (WritingSystem::Syriac, &['ܐ']),
334    (WritingSystem::Thaana, &['ހ']),
335    (WritingSystem::Devanagari, &['अ', 'ह']),
336    (WritingSystem::Bengali, &['অ']),
337    (WritingSystem::Gurmukhi, &['ਅ']),
338    (WritingSystem::Gujarati, &['અ']),
339    (WritingSystem::Oriya, &['ଅ']),
340    (WritingSystem::Tamil, &['அ']),
341    (WritingSystem::Telugu, &['అ']),
342    (WritingSystem::Kannada, &['ಅ']),
343    (WritingSystem::Malayalam, &['അ']),
344    (WritingSystem::Sinhala, &['අ']),
345    (WritingSystem::Thai, &['ก', 'ฮ']),
346    (WritingSystem::Lao, &['ກ']),
347    (WritingSystem::Tibetan, &['ཀ']),
348    (WritingSystem::Myanmar, &['က']),
349    (WritingSystem::Georgian, &['ა', 'ჰ']),
350    (WritingSystem::Khmer, &['ក']),
351    (WritingSystem::Japanese, &['あ', 'ア']),
352    (WritingSystem::Korean, &['가', '한']),
353    (WritingSystem::Vietnamese, &['ế', 'ệ']),
354    (WritingSystem::Ogham, &['ᚁ']),
355    (WritingSystem::Runic, &['ᚠ']),
356    (WritingSystem::Nko, &['ߊ']),
357];
358
359/// OS/2 unicode-range bit 59 = CJK Unified Ideographs (Han).
360const OS2_HAN_BIT: u8 = 59;
361
362/// Classify the writing systems a single font face can render.
363///
364/// `bytes` are the whole font file; `index` selects the face within a font
365/// collection (`.ttc`). Returns an empty set for unparsable data; returns
366/// `{Symbol}` for a font that parses but covers none of the known scripts
367/// (dingbat / icon fonts). Pure and `Send` — safe to call off-thread.
368pub fn writing_systems_for_face(bytes: &[u8], index: u32) -> WritingSystemSet {
369    let mut set = WritingSystemSet::new();
370    let Ok(face) = Face::parse(bytes, index) else {
371        return set;
372    };
373
374    // (a) OS/2 ulUnicodeRange → script-level systems.
375    let unicode_bits = face.tables().os2.map(|t| t.unicode_ranges().0).unwrap_or(0);
376    for &(bit, ws) in UNICODE_RANGE_BITS {
377        if unicode_bits & (1u128 << bit) != 0 {
378            set.insert(ws);
379        }
380    }
381    let has_han = unicode_bits & (1u128 << OS2_HAN_BIT) != 0;
382
383    // (b) OS/2 ulCodePageRange1 → CJK-language + Vietnamese distinction.
384    // ttf-parser doesn't type the code-page word, so read it raw.
385    if let Some(cp1) = raw_code_page_range1(&face) {
386        for &(bit, ws) in CODEPAGE_RANGE1_BITS {
387            if cp1 & (1u32 << bit) != 0 {
388                set.insert(ws);
389            }
390        }
391    }
392
393    // Han coverage with no code-page disambiguation: Simplified and
394    // Traditional share codepoints, so mark both (Qt behaves the same when
395    // the code-page bits are silent).
396    if has_han && !has_any_cjk_language(set) {
397        set.insert(WritingSystem::SimplifiedChinese);
398        set.insert(WritingSystem::TraditionalChinese);
399    }
400
401    // (c) cmap cross-check for scripts the OS/2 tables didn't assert.
402    for &(ws, probes) in CMAP_PROBES {
403        if !set.contains(ws) && probes.iter().all(|&c| face.glyph_index(c).is_some()) {
404            set.insert(ws);
405        }
406    }
407    // cmap fallback for Han when OS/2 was silent about it entirely.
408    if !has_any_cjk_language(set) && ['中', '文'].iter().all(|&c| face.glyph_index(c).is_some()) {
409        set.insert(WritingSystem::SimplifiedChinese);
410        set.insert(WritingSystem::TraditionalChinese);
411    }
412
413    // A font that parses but covers none of the known scripts is a
414    // symbol/dingbat font (Wingdings, icon fonts, …).
415    if set.is_empty() {
416        set.insert(WritingSystem::Symbol);
417    }
418
419    set
420}
421
422/// True if any CJK-language system (which the code-page bits distinguish)
423/// is already present.
424const fn has_any_cjk_language(set: WritingSystemSet) -> bool {
425    set.contains(WritingSystem::SimplifiedChinese)
426        || set.contains(WritingSystem::TraditionalChinese)
427        || set.contains(WritingSystem::Japanese)
428        || set.contains(WritingSystem::Korean)
429}
430
431/// Read OS/2 `ulCodePageRange1` (32 bits) from the raw table bytes.
432///
433/// The code-page ranges were added in OS/2 version 1 and sit at byte
434/// offset 78. `ttf-parser` exposes the typed unicode ranges but not the
435/// code-page word, so read it directly. Returns `None` when the table is
436/// absent, too short, or version 0 (no code-page fields).
437fn raw_code_page_range1(face: &Face) -> Option<u32> {
438    let data = face.raw_face().table(Tag::from_bytes(b"OS/2"))?;
439    // Need the version (offset 0) and the u32 at offset 78..82.
440    if data.len() < 82 {
441        return None;
442    }
443    let version = u16::from_be_bytes([data[0], data[1]]);
444    if version < 1 {
445        return None; // code-page ranges only exist from OS/2 v1.
446    }
447    Some(u32::from_be_bytes([data[78], data[79], data[80], data[81]]))
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453
454    #[test]
455    fn set_insert_contains_iter() {
456        let mut set = WritingSystemSet::new();
457        assert!(set.is_empty());
458        set.insert(WritingSystem::Latin);
459        set.insert(WritingSystem::Arabic);
460        assert!(set.contains(WritingSystem::Latin));
461        assert!(set.contains(WritingSystem::Arabic));
462        assert!(!set.contains(WritingSystem::Greek));
463        assert_eq!(set.len(), 2);
464        let collected: Vec<_> = set.iter().collect();
465        assert_eq!(collected, vec![WritingSystem::Latin, WritingSystem::Arabic]);
466    }
467
468    #[test]
469    fn all_ids_unique_and_stable() {
470        let mut ids: Vec<&str> = WritingSystem::ALL.iter().map(|w| w.id()).collect();
471        let n = ids.len();
472        ids.sort_unstable();
473        ids.dedup();
474        assert_eq!(ids.len(), n, "writing-system ids must be unique");
475        assert_eq!(n, 33);
476    }
477
478    #[test]
479    fn every_system_has_a_sample() {
480        for ws in WritingSystem::ALL {
481            assert!(!ws.sample_text().is_empty(), "{ws:?} has no sample text");
482            assert!(!ws.english_name().is_empty());
483        }
484    }
485}