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