Skip to main content

repose_text/
fallback.rs

1// Ported from Compose
2
3use std::collections::{HashMap, HashSet};
4
5use crate::fallback_data::{
6    ENCODED_NOTO_FONT_SET_RANGES, ENCODED_NOTO_FONT_SETS, NOTO_FONTS, NotoFont,
7};
8
9// constants matching the Kotlin source
10const PREFIX_DIGIT_0: u32 = 48;
11const PREFIX_RADIX: u32 = 10;
12const FONT_INDEX_DIGIT_0: u32 = 97; // 'a'
13const FONT_INDEX_RADIX: u32 = 26;
14const RANGE_SIZE_DIGIT_0: u32 = 97; // 'a'
15const RANGE_SIZE_RADIX: u32 = 26;
16const RANGE_VALUE_DIGIT_0: u32 = 65; // 'A'
17const RANGE_VALUE_RADIX: u32 = 26;
18const MAX_CODE_POINT: u32 = 0x10FFFF;
19
20#[cfg(target_arch = "wasm32")]
21fn direct_font_url(font: &NotoFont) -> &'static str {
22    font.url
23}
24
25pub struct IndexedNotoFont {
26    pub index: usize,
27    pub font: &'static NotoFont,
28    pub cover_count: usize,
29    pub cover_components: Vec<usize>, // indices into components vec
30}
31
32pub struct FallbackFontComponent {
33    pub fonts: Vec<usize>, // indices into indexed fonts arena
34    pub cover_count: usize,
35}
36
37static EMPTY_FALLBACK_COMPONENT: FallbackFontComponent = FallbackFontComponent {
38    fonts: Vec::new(),
39    cover_count: 0,
40};
41
42pub struct UnicodePropertyLookup {
43    boundaries: Vec<u32>,
44    // values[i] corresponds to range [boundaries[i-1]..boundaries[i])? Actually Kotlin logic:
45    // boundaries holds end-exclusive start of next range, values is parallel.
46    // lookup via binary search on boundaries (upper bound).
47    values: Vec<FallbackFontComponent>,
48}
49
50impl UnicodePropertyLookup {
51    pub fn lookup(&self, value: u32) -> &FallbackFontComponent {
52        self.try_lookup(value).unwrap_or(&EMPTY_FALLBACK_COMPONENT)
53    }
54
55    pub fn try_lookup(&self, value: u32) -> Option<&FallbackFontComponent> {
56        if value > MAX_CODE_POINT {
57            return None;
58        }
59        let mut start: usize = 0;
60        let mut end: usize = self.boundaries.len();
61        loop {
62            if start == end {
63                return self.values.get(start);
64            }
65            let mid = start + (end - start) / 2;
66            if value >= self.boundaries[mid] {
67                start = mid + 1;
68            } else {
69                end = mid;
70            }
71        }
72    }
73
74    pub fn create() -> Self {
75        // Decode font components from ENCODED_NOTO_FONT_SETS
76        let property_enum_values = decode_font_components();
77
78        // Decode boundaries / values from ENCODED_NOTO_FONT_SET_RANGES (packedData)
79        let packed_data = ENCODED_NOTO_FONT_SET_RANGES;
80
81        let mut boundaries: Vec<u32> = Vec::new();
82        let mut values: Vec<FallbackFontComponent> = Vec::new();
83
84        let mut start: u32 = 0;
85        let mut prefix: u32 = 0;
86        let mut size: u32 = 1;
87
88        for ch in packed_data.chars() {
89            let code = ch as u32;
90            if (RANGE_VALUE_DIGIT_0..RANGE_VALUE_DIGIT_0 + RANGE_VALUE_RADIX).contains(&code) {
91                let idx = (prefix * RANGE_VALUE_RADIX + (code - RANGE_VALUE_DIGIT_0)) as usize;
92                // property_enum_values is Vec<FallbackFontComponent-template>; need clone
93                let template = &property_enum_values[idx];
94                // Clone fonts list for new component
95                let comp = FallbackFontComponent {
96                    fonts: template.fonts.clone(),
97                    cover_count: 0,
98                };
99                start += size;
100                boundaries.push(start);
101                values.push(comp);
102                prefix = 0;
103                size = 1;
104            } else if (RANGE_SIZE_DIGIT_0..RANGE_SIZE_DIGIT_0 + RANGE_SIZE_RADIX).contains(&code) {
105                size = prefix * RANGE_SIZE_RADIX + (code - RANGE_SIZE_DIGIT_0) + 2;
106                prefix = 0;
107            } else if (PREFIX_DIGIT_0..PREFIX_DIGIT_0 + PREFIX_RADIX).contains(&code) {
108                prefix = prefix * PREFIX_RADIX + (code - PREFIX_DIGIT_0);
109            } else {
110                panic!("Unexpected encoded range character: {}", ch);
111            }
112        }
113
114        assert_eq!(
115            start,
116            MAX_CODE_POINT + 1,
117            "Bad fallback map size: {}",
118            start
119        );
120
121        Self { boundaries, values }
122    }
123}
124
125fn decode_font_components() -> Vec<FallbackFontComponent> {
126    ENCODED_NOTO_FONT_SETS
127        .split(',')
128        .map(|component_data| {
129            let fonts = decode_font_set(component_data);
130            FallbackFontComponent {
131                fonts,
132                cover_count: 0,
133            }
134        })
135        .collect()
136}
137
138fn decode_font_set(data: &str) -> Vec<usize> {
139    let mut result: Vec<usize> = Vec::new();
140    let mut previous_index: i32 = -1;
141    let mut prefix: u32 = 0;
142    for ch in data.chars() {
143        let code = ch as u32;
144        if (FONT_INDEX_DIGIT_0..FONT_INDEX_DIGIT_0 + FONT_INDEX_RADIX).contains(&code) {
145            let delta = (prefix * FONT_INDEX_RADIX + (code - FONT_INDEX_DIGIT_0)) as i32;
146            let index = previous_index + delta + 1;
147            result.push(index as usize);
148            previous_index = index;
149            prefix = 0;
150        } else if (PREFIX_DIGIT_0..PREFIX_DIGIT_0 + PREFIX_RADIX).contains(&code) {
151            prefix = prefix * PREFIX_RADIX + (code - PREFIX_DIGIT_0);
152        } else {
153            panic!("Unexpected encoded font-set char: {}", ch);
154        }
155    }
156    result
157}
158
159// NotoFontDownloader - port of getFontsToDownload logic
160pub struct NotoFontDownloader {
161    code_points_with_no_known_font: HashSet<u32>,
162    lookup: UnicodePropertyLookup,
163    // arena for IndexedNotoFont - created fresh per call in Kotlin via decoding, but we share lookup's fonts?
164    // Kotlin creates IndexedNotoFont per decode (for each component). For efficiency we create per call.
165}
166
167impl Default for NotoFontDownloader {
168    fn default() -> Self {
169        Self::new()
170    }
171}
172
173impl NotoFontDownloader {
174    pub fn new() -> Self {
175        Self {
176            code_points_with_no_known_font: HashSet::new(),
177            lookup: UnicodePropertyLookup::create(),
178        }
179    }
180
181    pub fn get_codepoints_with_no_known_font(&self) -> &HashSet<u32> {
182        &self.code_points_with_no_known_font
183    }
184
185    /// Port of `getFontsToDownload` - returns list of NotoFonts to fetch.
186    /// `language` is navigator.language like "ja", "zh-CN", etc.
187    pub fn get_fonts_to_download(
188        &mut self,
189        codepoints: &HashSet<u32>,
190        language: &str,
191    ) -> Vec<&'static NotoFont> {
192        if codepoints.is_empty() {
193            return Vec::new();
194        }
195
196        let mut cover_counts: HashMap<usize, usize> = HashMap::new();
197        let mut font_index_to_obj: HashMap<usize, IndexedNotoFont> = HashMap::new();
198
199        fn lookup_idx(boundaries: &[u32], value: u32) -> Option<usize> {
200            if value > MAX_CODE_POINT {
201                return None;
202            }
203            let mut start = 0usize;
204            let mut end = boundaries.len();
205            loop {
206                if start == end {
207                    return Some(start);
208                }
209                let mid = start + (end - start) / 2;
210                if value >= boundaries[mid] {
211                    start = mid + 1;
212                } else {
213                    end = mid;
214                }
215            }
216        }
217
218        let mut required_component_indices: Vec<usize> = Vec::new();
219        let mut codepoints: Vec<u32> = codepoints.iter().copied().collect();
220        codepoints.sort_unstable();
221
222        for cp in codepoints {
223            if self.code_points_with_no_known_font.contains(&cp) || cp > MAX_CODE_POINT {
224                continue;
225            }
226            let Some(idx) = lookup_idx(&self.lookup.boundaries, cp) else {
227                continue;
228            };
229            let Some(comp) = self.lookup.values.get(idx) else {
230                continue;
231            };
232            if comp.fonts.is_empty() {
233                self.code_points_with_no_known_font.insert(cp);
234            } else {
235                let count = cover_counts.entry(idx).or_insert(0);
236                if *count == 0 {
237                    required_component_indices.push(idx);
238                }
239                *count += 1;
240            }
241        }
242
243        if required_component_indices.is_empty() {
244            return Vec::new();
245        }
246
247        // Ensure font objects exist for all fonts referenced in required components
248        for &comp_idx in &required_component_indices {
249            for &font_idx in &self.lookup.values[comp_idx].fonts {
250                font_index_to_obj
251                    .entry(font_idx)
252                    .or_insert_with(|| IndexedNotoFont {
253                        index: font_idx,
254                        font: &NOTO_FONTS[font_idx],
255                        cover_count: 0,
256                        cover_components: Vec::new(),
257                    });
258            }
259        }
260
261        // Populate candidateFonts: for each required component, for each font in it, update coverCount
262        let mut candidate_font_indices: HashSet<usize> = HashSet::new();
263
264        for &comp_idx in &required_component_indices {
265            let comp_cover = cover_counts.get(&comp_idx).copied().unwrap_or(0);
266            for &font_idx in &self.lookup.values[comp_idx].fonts {
267                let font_obj = font_index_to_obj.get_mut(&font_idx).unwrap();
268                if font_obj.cover_count == 0 {
269                    candidate_font_indices.insert(font_idx);
270                }
271                font_obj.cover_count += comp_cover;
272                font_obj.cover_components.push(comp_idx);
273            }
274        }
275
276        // Convert candidate set to vec for iteration
277        let mut candidate_vec: Vec<usize> = candidate_font_indices.into_iter().collect();
278        candidate_vec.sort_unstable();
279
280        let mut selected: Vec<&'static NotoFont> = Vec::new();
281
282        // Greedy selection loop
283        while !candidate_vec.is_empty() {
284            // select best font among candidates
285            let best_idx = select_font(&candidate_vec, &font_index_to_obj, language);
286            let best_font = font_index_to_obj.get(&best_idx).unwrap();
287            selected.push(best_font.font);
288            let covered_components = {
289                let best_font = font_index_to_obj.get_mut(&best_idx).unwrap();
290                std::mem::take(&mut best_font.cover_components)
291            };
292            for comp_idx in covered_components {
293                let comp_cover = cover_counts.get(&comp_idx).copied().unwrap_or(0);
294                for &f_idx in &self.lookup.values[comp_idx].fonts {
295                    if let Some(fobj) = font_index_to_obj.get_mut(&f_idx) {
296                        fobj.cover_count = fobj.cover_count.saturating_sub(comp_cover);
297                        // remove component from its cover list
298                        fobj.cover_components.retain(|&c| c != comp_idx);
299                    }
300                }
301                cover_counts.remove(&comp_idx);
302            }
303
304            candidate_vec.retain(|fid| {
305                font_index_to_obj
306                    .get(fid)
307                    .map(|f| f.cover_count != 0)
308                    .unwrap_or(false)
309            });
310        }
311
312        // distinctBy index already guaranteed by set
313        selected
314    }
315}
316
317#[cfg(any(target_arch = "wasm32", test))]
318#[derive(Default)]
319struct FontDownloadProgress {
320    installed_urls: HashSet<String>,
321}
322
323#[cfg(any(target_arch = "wasm32", test))]
324impl FontDownloadProgress {
325    fn needs_fetch(&self, url: &str) -> bool {
326        !self.installed_urls.contains(url)
327    }
328
329    fn record_success(&mut self, url: String) {
330        self.installed_urls.insert(url);
331    }
332}
333
334fn is_cjk_font(font: &NotoFont) -> bool {
335    is_noto_sans_sc(font)
336        || is_noto_sans_tc(font)
337        || is_noto_sans_hk(font)
338        || is_noto_sans_jp(font)
339        || is_noto_sans_kr(font)
340}
341fn is_noto_sans_sc(f: &NotoFont) -> bool {
342    f.name.starts_with("Noto Sans SC")
343}
344fn is_noto_sans_tc(f: &NotoFont) -> bool {
345    f.name.starts_with("Noto Sans TC")
346}
347fn is_noto_sans_hk(f: &NotoFont) -> bool {
348    f.name.starts_with("Noto Sans HK")
349}
350fn is_noto_sans_jp(f: &NotoFont) -> bool {
351    f.name.starts_with("Noto Sans JP")
352}
353fn is_noto_sans_kr(f: &NotoFont) -> bool {
354    f.name.starts_with("Noto Sans KR")
355}
356fn is_noto_color_emoji(f: &NotoFont) -> bool {
357    f.name.starts_with("Noto Color Emoji")
358}
359fn is_noto_sans_symbols(f: &NotoFont) -> bool {
360    f.name.starts_with("Noto Sans Symbols")
361}
362
363fn select_font(
364    candidates: &[usize],
365    arena: &HashMap<usize, IndexedNotoFont>,
366    language: &str,
367) -> usize {
368    // Find max coverCount
369    let mut max_covered = -1i32;
370    let mut best_fonts: Vec<usize> = Vec::new();
371    let mut best: Option<usize> = None;
372    for &fid in candidates {
373        let f = &arena[&fid];
374        let cc = f.cover_count as i32;
375        if cc > max_covered {
376            best_fonts.clear();
377            best_fonts.push(fid);
378            best = Some(fid);
379            max_covered = cc;
380        } else if cc == max_covered {
381            best_fonts.push(fid);
382            if best.map(|b| fid < b).unwrap_or(true) {
383                best = Some(fid);
384            }
385        }
386    }
387
388    // Language tie-break
389    if best_fonts.len() > 1 {
390        // check if all best are cjk
391        let all_cjk = best_fonts.iter().all(|&fid| is_cjk_font(arena[&fid].font));
392        if all_cjk {
393            if let Some(idx) = select_best_for_language(&best_fonts, arena, language) {
394                return idx;
395            }
396            if let Some(idx) = select_best_for_language(candidates, arena, language) {
397                return idx;
398            }
399        } else {
400            // emoji/symbols preference
401            if let Some(&fid) = best_fonts
402                .iter()
403                .find(|&&fid| is_noto_color_emoji(arena[&fid].font))
404            {
405                return fid;
406            }
407            if let Some(&fid) = best_fonts
408                .iter()
409                .find(|&&fid| is_noto_sans_symbols(arena[&fid].font))
410            {
411                return fid;
412            }
413            if let Some(&fid) = best_fonts
414                .iter()
415                .find(|&&fid| is_noto_sans_sc(arena[&fid].font))
416            {
417                return fid;
418            }
419        }
420    }
421
422    best.expect("No fallback font selected")
423}
424
425fn select_best_for_language(
426    candidates: &[usize],
427    arena: &HashMap<usize, IndexedNotoFont>,
428    language: &str,
429) -> Option<usize> {
430    match language {
431        "zh-Hans" | "zh-CN" | "zh-SG" | "zh-MY" => candidates
432            .iter()
433            .find(|&&fid| is_noto_sans_sc(arena[&fid].font))
434            .copied(),
435        "zh-Hant" | "zh-TW" | "zh-MO" => candidates
436            .iter()
437            .find(|&&fid| is_noto_sans_tc(arena[&fid].font))
438            .copied(),
439        "zh-HK" => candidates
440            .iter()
441            .find(|&&fid| is_noto_sans_hk(arena[&fid].font))
442            .copied(),
443        "ja" => candidates
444            .iter()
445            .find(|&&fid| is_noto_sans_jp(arena[&fid].font))
446            .copied(),
447        "ko" => candidates
448            .iter()
449            .find(|&&fid| is_noto_sans_kr(arena[&fid].font))
450            .copied(),
451        _ => None,
452    }
453}
454
455// WASM download + registry (mirrors WebFallbackFontDownloader)
456
457#[cfg(target_arch = "wasm32")]
458pub mod wasm_fallback {
459    use super::*;
460    use std::cell::RefCell;
461    use std::collections::{HashSet, VecDeque};
462    use std::rc::Rc;
463    use wasm_bindgen::JsCast;
464    use wasm_bindgen::prelude::*;
465    use wasm_bindgen_futures::JsFuture;
466    use web_sys::{Request, Response};
467
468    const MAX_BATCH_SIZE: usize = 10;
469
470    thread_local! {
471        static GLOBAL: Rc<RefCell<WebFallbackFontDownloader>> = Rc::new(RefCell::new(WebFallbackFontDownloader::new()));
472        static INSTALLED: RefCell<bool> = const { RefCell::new(false) };
473        static LISTENER_KEEP: RefCell<Option<std::sync::Arc<dyn crate::unresolved::UnresolvedListener>>> = const { RefCell::new(None) };
474    }
475
476    struct WebFallbackFontDownloader {
477        downloader: NotoFontDownloader,
478        queued: VecDeque<HashSet<u32>>,
479        is_running: bool,
480        error_count: u32,
481        progress: FontDownloadProgress,
482    }
483
484    impl WebFallbackFontDownloader {
485        fn new() -> Self {
486            Self {
487                downloader: NotoFontDownloader::new(),
488                queued: VecDeque::new(),
489                is_running: false,
490                error_count: 0,
491                progress: FontDownloadProgress::default(),
492            }
493        }
494
495        fn submit(&mut self, codepoints: HashSet<u32>) {
496            if codepoints.is_empty() {
497                return;
498            }
499            self.queued.push_back(codepoints);
500        }
501    }
502
503    fn start_worker(global: Rc<RefCell<WebFallbackFontDownloader>>) {
504        let should_start = {
505            let mut state = global.borrow_mut();
506            if state.is_running {
507                false
508            } else {
509                state.is_running = true;
510                true
511            }
512        };
513        if should_start {
514            wasm_bindgen_futures::spawn_local(async move {
515                run_worker(global).await;
516            });
517        }
518    }
519
520    fn enqueue(codepoints: HashSet<u32>) {
521        let global = GLOBAL.with(|global| {
522            let mut state = global.borrow_mut();
523            if let Some(queued) = state.queued.back_mut() {
524                queued.extend(codepoints);
525            } else {
526                state.submit(codepoints);
527            }
528            global.clone()
529        });
530        start_worker(global);
531    }
532
533    pub fn submit_unresolved(codepoints: Vec<u32>) {
534        if codepoints.is_empty() {
535            return;
536        }
537        crate::unresolved::web_unresolved_registry().add_unresolved_codepoints(&codepoints);
538        let set: HashSet<u32> = codepoints.into_iter().collect();
539        let installed = INSTALLED.with(|installed| *installed.borrow());
540        if !installed {
541            enqueue(set);
542        }
543    }
544
545    pub fn install_fallback_font_downloader() {
546        let already = INSTALLED.with(|v| {
547            let mut b = v.borrow_mut();
548            if *b {
549                true
550            } else {
551                *b = true;
552                false
553            }
554        });
555        if already {
556            return;
557        }
558
559        // Create listener that forwards registry -> downloader channel (mirrors UnresolvedSymbolsRegistry.Listener)
560        struct RegistryListener;
561        impl crate::unresolved::UnresolvedListener for RegistryListener {
562            fn on_unresolved_codepoints(&self, codepoints: &HashSet<u32>) {
563                enqueue(codepoints.clone());
564            }
565            fn on_new_font_installed(&self) {
566                // In compose ParagraphLayouter invalidates paragraph on new font
567                // Here global caches already cleared via register_font_data
568            }
569        }
570
571        let listener: std::sync::Arc<dyn crate::unresolved::UnresolvedListener> =
572            std::sync::Arc::new(RegistryListener);
573        crate::unresolved::web_unresolved_registry().add_listener(listener.clone());
574        // so Weak in registry doesn't die (was a bug prev.: listener dropped after fn)
575        LISTENER_KEEP.with(|c| *c.borrow_mut() = Some(listener));
576        let pending = crate::unresolved::web_unresolved_registry().snapshot();
577        if !pending.is_empty() {
578            enqueue(pending);
579        }
580    }
581
582    async fn run_worker(global: Rc<RefCell<WebFallbackFontDownloader>>) {
583        loop {
584            let batch = {
585                let mut state = global.borrow_mut();
586                if state.queued.is_empty() {
587                    state.is_running = false;
588                    return;
589                }
590                let Some(mut batch) = state.queued.pop_front() else {
591                    continue;
592                };
593                let mut count = 1;
594                while count < MAX_BATCH_SIZE && !state.queued.is_empty() {
595                    if let Some(next) = state.queued.pop_front() {
596                        batch.extend(next);
597                    }
598                    count += 1;
599                }
600                batch
601            };
602
603            if batch.is_empty() {
604                continue;
605            }
606
607            let fonts_to_download: Vec<&'static NotoFont> = {
608                let mut state = global.borrow_mut();
609                let language = web_sys::window()
610                    .and_then(|window| window.navigator().language())
611                    .unwrap_or_else(|| "en".to_string());
612                state.downloader.get_fonts_to_download(&batch, &language)
613            };
614
615            if fonts_to_download.is_empty() {
616                continue;
617            }
618
619            let mut any_success = false;
620            let mut failed = false;
621            let mut seen_urls: HashSet<&'static str> = HashSet::new();
622            for font in &fonts_to_download {
623                let url = direct_font_url(font);
624                if !seen_urls.insert(url) {
625                    continue;
626                }
627                let needs_fetch = global.borrow().progress.needs_fetch(&url);
628                if !needs_fetch {
629                    continue;
630                }
631                match fetch_bytes(&url).await {
632                    Ok(bytes) => {
633                        if crate::register_font_data_if_usable(&bytes) {
634                            global.borrow_mut().progress.record_success(url.to_owned());
635                            any_success = true;
636                        } else {
637                            failed = true;
638                        }
639                    }
640                    Err(_) => failed = true,
641                }
642            }
643
644            if any_success {
645                crate::unresolved::web_unresolved_registry().on_new_font_installed();
646            }
647
648            if failed {
649                let backoff = {
650                    let mut state = global.borrow_mut();
651                    let pause = state.error_count.saturating_mul(5).min(60);
652                    state.error_count = state.error_count.saturating_add(1);
653                    pause
654                };
655                if backoff > 0 {
656                    gloo_timers_approx_delay(backoff.saturating_mul(1000)).await;
657                }
658                global.borrow_mut().queued.push_back(batch);
659                continue;
660            }
661
662            global.borrow_mut().error_count = 0;
663        }
664    }
665
666    async fn gloo_timers_approx_delay(ms: u32) -> () {
667        let promise = js_sys::Promise::new(&mut |resolve, _reject| {
668            let Some(window) = web_sys::window() else {
669                let _ = resolve.call0(&JsValue::NULL);
670                return;
671            };
672            let _ =
673                window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms as i32);
674        });
675        let _ = JsFuture::from(promise).await;
676    }
677
678    async fn fetch_bytes(url: &str) -> Result<Vec<u8>, JsValue> {
679        let opts = web_sys::RequestInit::new();
680        opts.set_method("GET");
681        opts.set_mode(web_sys::RequestMode::Cors);
682        opts.set_cache(web_sys::RequestCache::Default);
683        let request = Request::new_with_str_and_init(url, &opts)?;
684        let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
685        let resp_value = JsFuture::from(window.fetch_with_request(&request)).await?;
686        let resp: Response = resp_value
687            .dyn_into()
688            .map_err(|_| JsValue::from_str("fetch did not return a Response"))?;
689        if !resp.ok() {
690            return Err(JsValue::from_str(&format!(
691                "fetch failed status {}",
692                resp.status()
693            )));
694        }
695        let buffer = JsFuture::from(resp.array_buffer()?).await?;
696        let arr = js_sys::Uint8Array::new(&buffer);
697        let bytes = arr.to_vec();
698        Ok(bytes)
699    }
700
701    pub fn ensure_fallback_initialized() {
702        install_fallback_font_downloader();
703    }
704}
705
706#[cfg(not(target_arch = "wasm32"))]
707pub mod wasm_fallback {
708    pub fn submit_unresolved(_codepoints: Vec<u32>) {}
709    pub fn ensure_fallback_initialized() {}
710    pub fn install_fallback_font_downloader() {}
711}
712
713#[cfg(test)]
714mod tests {
715    use super::*;
716    use std::collections::HashSet;
717
718    #[test]
719    fn empty_returns_empty() {
720        let mut d = NotoFontDownloader::new();
721        let res = d.get_fonts_to_download(&HashSet::new(), "en");
722        assert!(res.is_empty());
723        assert!(d.get_codepoints_with_no_known_font().is_empty());
724    }
725
726    #[test]
727    fn above_max_ignored() {
728        let mut d = NotoFontDownloader::new();
729        let mut set = HashSet::new();
730        set.insert(0x110000);
731        let res = d.get_fonts_to_download(&set, "en");
732        assert!(res.is_empty());
733        assert!(d.get_codepoints_with_no_known_font().is_empty());
734    }
735
736    #[test]
737    fn pua_remembered() {
738        let mut d = NotoFontDownloader::new();
739        let mut set = HashSet::new();
740        set.insert(0xE000);
741        let first = d.get_fonts_to_download(&set, "en");
742        assert!(first.is_empty());
743        assert!(d.get_codepoints_with_no_known_font().contains(&0xE000));
744        let second = d.get_fonts_to_download(&set, "en");
745        assert!(second.is_empty());
746    }
747
748    #[test]
749    fn arabic_resolves() {
750        let mut d = NotoFontDownloader::new();
751        let set: HashSet<u32> = [0x0639, 0x0641].into_iter().collect();
752        let fonts = d.get_fonts_to_download(&set, "en");
753        assert!(!fonts.is_empty(), "Arabic should resolve");
754        assert!(
755            fonts.iter().all(|f| f.name.starts_with("Noto Sans Arabic")),
756            "got {:?}",
757            fonts.iter().map(|f| f.name).collect::<Vec<_>>()
758        );
759    }
760
761    #[test]
762    fn emoji_resolves() {
763        let mut d = NotoFontDownloader::new();
764        let set: HashSet<u32> = [0x1F600, 0x1F389].into_iter().collect();
765        let fonts = d.get_fonts_to_download(&set, "en");
766        assert!(!fonts.is_empty());
767        assert!(
768            fonts.iter().all(|f| f.name.starts_with("Noto Color Emoji")),
769            "got {:?}",
770            fonts.iter().map(|f| f.name).collect::<Vec<_>>()
771        );
772    }
773
774    #[test]
775    fn cjk_zh_cn() {
776        let mut d = NotoFontDownloader::new();
777        let set: HashSet<u32> = [0x5B57].into_iter().collect();
778        let fonts = d.get_fonts_to_download(&set, "zh-CN");
779        assert!(!fonts.is_empty());
780        assert!(
781            fonts.iter().all(|f| f.name.starts_with("Noto Sans SC")),
782            "got {:?}",
783            fonts.iter().map(|f| f.name).collect::<Vec<_>>()
784        );
785    }
786
787    #[test]
788    fn japanese_hiragana() {
789        let mut d = NotoFontDownloader::new();
790        let set: HashSet<u32> = [0x3042].into_iter().collect();
791        let fonts = d.get_fonts_to_download(&set, "ja");
792        assert!(!fonts.is_empty());
793        assert!(
794            fonts.iter().all(|f| f.name.starts_with("Noto Sans JP")),
795            "got {:?}",
796            fonts.iter().map(|f| f.name).collect::<Vec<_>>()
797        );
798    }
799
800    #[test]
801    fn devanagari() {
802        let mut d = NotoFontDownloader::new();
803        let set: HashSet<u32> = [0x0905, 0x0915].into_iter().collect();
804        let fonts = d.get_fonts_to_download(&set, "en");
805        assert!(!fonts.is_empty(), "Devanagari should resolve to Noto Sans*");
806        // Compose test says Noto Sans (base) for 0905,0915
807        assert!(
808            fonts.iter().all(|f| f.name.starts_with("Noto Sans")),
809            "got {:?}",
810            fonts.iter().map(|f| f.name).collect::<Vec<_>>()
811        );
812    }
813
814    #[test]
815    fn hebrew_resolves() {
816        let mut d = NotoFontDownloader::new();
817        let set: HashSet<u32> = [0x05E9, 0x05D0].into_iter().collect();
818        let fonts = d.get_fonts_to_download(&set, "en");
819        assert!(!fonts.is_empty());
820        assert!(
821            fonts.iter().all(|f| f.name.starts_with("Noto Sans Hebrew")),
822            "got {:?}",
823            fonts.iter().map(|f| f.name).collect::<Vec<_>>()
824        );
825    }
826
827    #[test]
828    fn thai_resolves() {
829        let mut d = NotoFontDownloader::new();
830        let set: HashSet<u32> = [0x0E01, 0x0E2A].into_iter().collect();
831        let fonts = d.get_fonts_to_download(&set, "en");
832        assert!(!fonts.is_empty());
833        assert!(
834            fonts.iter().all(|f| f.name.starts_with("Noto Sans Thai")),
835            "got {:?}",
836            fonts.iter().map(|f| f.name).collect::<Vec<_>>()
837        );
838    }
839
840    #[test]
841    fn bengali_resolves() {
842        let mut d = NotoFontDownloader::new();
843        let set: HashSet<u32> = [0x0985, 0x0995].into_iter().collect();
844        let fonts = d.get_fonts_to_download(&set, "en");
845        assert!(!fonts.is_empty());
846        assert!(
847            fonts
848                .iter()
849                .all(|f| f.name.starts_with("Noto Sans Bengali")),
850            "got {:?}",
851            fonts.iter().map(|f| f.name).collect::<Vec<_>>()
852        );
853    }
854
855    #[test]
856    fn box_drawing_resolves() {
857        let mut d = NotoFontDownloader::new();
858        let set: HashSet<u32> = [0x2500, 0x2502].into_iter().collect();
859        let fonts = d.get_fonts_to_download(&set, "en");
860        assert!(!fonts.is_empty());
861        // Box drawing may be in HK or SC depending on generated data version; just check Noto Sans*
862        assert!(
863            fonts.iter().all(|f| f.name.starts_with("Noto Sans")),
864            "got {:?}",
865            fonts.iter().map(|f| f.name).collect::<Vec<_>>()
866        );
867    }
868
869    #[test]
870    fn korean_ko() {
871        let mut d = NotoFontDownloader::new();
872        let set: HashSet<u32> = [0xACA8, 0xACAF, 0xACF0].into_iter().collect();
873        let fonts = d.get_fonts_to_download(&set, "ko");
874        assert!(!fonts.is_empty());
875        assert!(
876            fonts.iter().all(|f| f.name.starts_with("Noto Sans KR")),
877            "got {:?}",
878            fonts.iter().map(|f| f.name).collect::<Vec<_>>()
879        );
880    }
881
882    #[test]
883    fn cjk_zh_hant() {
884        let mut d = NotoFontDownloader::new();
885        let set: HashSet<u32> = [0x5B57].into_iter().collect();
886        let fonts = d.get_fonts_to_download(&set, "zh-TW");
887        assert!(!fonts.is_empty());
888        assert!(
889            fonts.iter().all(|f| f.name.starts_with("Noto Sans TC")),
890            "got {:?}",
891            fonts.iter().map(|f| f.name).collect::<Vec<_>>()
892        );
893    }
894
895    #[test]
896    fn successful_font_is_not_fetched_again() {
897        let mut progress = FontDownloadProgress::default();
898        assert!(progress.needs_fetch("successful"));
899        assert!(progress.needs_fetch("failed"));
900        progress.record_success("successful".into());
901        assert!(!progress.needs_fetch("successful"));
902        assert!(progress.needs_fetch("failed"));
903    }
904}