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 Default for NotoFontDownloader {
155    fn default() -> Self {
156        Self::new()
157    }
158}
159
160impl NotoFontDownloader {
161    pub fn new() -> Self {
162        Self {
163            code_points_with_no_known_font: HashSet::new(),
164            lookup: UnicodePropertyLookup::create(),
165        }
166    }
167
168    pub fn get_codepoints_with_no_known_font(&self) -> &HashSet<u32> {
169        &self.code_points_with_no_known_font
170    }
171
172    /// Port of `getFontsToDownload` - returns list of NotoFonts to fetch.
173    /// `language` is navigator.language like "ja", "zh-CN", etc.
174    pub fn get_fonts_to_download(
175        &mut self,
176        codepoints: &HashSet<u32>,
177        language: &str,
178    ) -> Vec<&'static NotoFont> {
179        if codepoints.is_empty() {
180            return Vec::new();
181        }
182
183        // We need mutable coverCount tracking. Kotlin uses object fields.
184        // We will create mutable copies of components and fonts.
185        // Approach: clone lookup values into mutable vec, and create indexed fonts map.
186
187        // First, determine which components are involved.
188        // Build maps: codepoint -> component idx
189        // But Kotlin's algorithm does per codepoint lookup and aggregates.
190        // We'll replicate closely.
191
192        // Create a working copy of values (components) with coverCount reset
193        let mut components: Vec<FallbackFontComponent> = self
194            .lookup
195            .values
196            .iter()
197            .map(|c| FallbackFontComponent {
198                fonts: c.fonts.clone(),
199                cover_count: 0,
200            })
201            .collect();
202
203        // Need mapping from font index -> IndexedNotoFont instance
204        // Kotlin's IndexedNotoFont objects are shared across components (same object if same font index appears in multiple components).
205        // We need to deduplicate.
206        let mut font_index_to_obj: HashMap<usize, IndexedNotoFont> = HashMap::new();
207        // Also build component index for each unique codepoint? Actually Kotlin aggregates by component identity:
208        // For each codepoint, lookup returns a FallbackFontComponent reference (with its fonts list). But after decoding,
209        // many codepoints share the same component object (via trie). In our port, each values[i] is a component.
210        // So if two codepoints fall into same range (same boundary interval), they will lookup same values index.
211        // Kotlin then does: if component.coverCount ==0 requiredComponents += component ; component.coverCount++
212        // So deduplication is by component identity (index in values), not by fonts equality.
213        // We must track component instances by their index in `components` vec.
214
215        // To know which component each codepoint maps to, we can binary search boundaries manually (lookup) but need index.
216        // Instead, we can get lookup index by performing same binary search returning idx.
217
218        fn lookup_idx(boundaries: &[u32], value: u32) -> usize {
219            let mut start = 0usize;
220            let mut end = boundaries.len();
221            loop {
222                if start == end {
223                    return start;
224                }
225                let mid = start + (end - start) / 2;
226                if value >= boundaries[mid] {
227                    start = mid + 1;
228                } else {
229                    end = mid;
230                }
231            }
232        }
233
234        let mut missing: Vec<u32> = Vec::new();
235        let mut required_component_indices: Vec<usize> = Vec::new();
236
237        for &cp in codepoints {
238            if self.code_points_with_no_known_font.contains(&cp) || cp > MAX_CODE_POINT {
239                continue;
240            }
241            let idx = lookup_idx(&self.lookup.boundaries, cp);
242            let comp = &mut components[idx];
243            if comp.fonts.is_empty() {
244                missing.push(cp);
245            } else {
246                if comp.cover_count == 0 {
247                    required_component_indices.push(idx);
248                }
249                comp.cover_count += 1;
250            }
251        }
252
253        if !missing.is_empty() {
254            self.code_points_with_no_known_font.extend(missing);
255        }
256
257        if required_component_indices.is_empty() {
258            return Vec::new();
259        }
260
261        // Ensure font objects exist for all fonts referenced in required components
262        for &comp_idx in &required_component_indices {
263            for &font_idx in &components[comp_idx].fonts.clone() {
264                font_index_to_obj
265                    .entry(font_idx)
266                    .or_insert_with(|| IndexedNotoFont {
267                        index: font_idx,
268                        font: &NOTO_FONTS[font_idx],
269                        cover_count: 0,
270                        cover_components: Vec::new(),
271                    });
272            }
273        }
274
275        // Populate candidateFonts: for each required component, for each font in it, update coverCount
276        let mut candidate_font_indices: HashSet<usize> = HashSet::new();
277
278        for &comp_idx in &required_component_indices {
279            let comp_cover = components[comp_idx].cover_count;
280            let fonts_clone = components[comp_idx].fonts.clone();
281            for font_idx in fonts_clone {
282                let font_obj = font_index_to_obj.get_mut(&font_idx).unwrap();
283                if font_obj.cover_count == 0 {
284                    candidate_font_indices.insert(font_idx);
285                }
286                font_obj.cover_count += comp_cover;
287                font_obj.cover_components.push(comp_idx);
288            }
289        }
290
291        // Convert candidate set to vec for iteration
292        let mut candidate_vec: Vec<usize> = candidate_font_indices.into_iter().collect();
293
294        let mut selected: Vec<&'static NotoFont> = Vec::new();
295
296        // Greedy selection loop
297        while !candidate_vec.is_empty() {
298            // select best font among candidates
299            let best_idx = select_font(&candidate_vec, &font_index_to_obj, language);
300            let best_font = font_index_to_obj.get(&best_idx).unwrap();
301            selected.push(best_font.font);
302
303            let covered_components: Vec<usize> = best_font.cover_components.clone();
304            for comp_idx in covered_components {
305                let comp_cover = components[comp_idx].cover_count;
306                let fonts_in_comp = components[comp_idx].fonts.clone();
307                for f_idx in fonts_in_comp {
308                    if let Some(fobj) = font_index_to_obj.get_mut(&f_idx) {
309                        fobj.cover_count = fobj.cover_count.saturating_sub(comp_cover);
310                        // remove component from its cover list
311                        fobj.cover_components.retain(|&c| c != comp_idx);
312                    }
313                }
314                components[comp_idx].cover_count = 0;
315            }
316
317            candidate_vec.retain(|fid| {
318                font_index_to_obj
319                    .get(fid)
320                    .map(|f| f.cover_count != 0)
321                    .unwrap_or(false)
322            });
323        }
324
325        // distinctBy index already guaranteed by set
326        selected
327    }
328}
329
330fn is_cjk_font(font: &NotoFont) -> bool {
331    is_noto_sans_sc(font)
332        || is_noto_sans_tc(font)
333        || is_noto_sans_hk(font)
334        || is_noto_sans_jp(font)
335        || is_noto_sans_kr(font)
336}
337fn is_noto_sans_sc(f: &NotoFont) -> bool {
338    f.name.starts_with("Noto Sans SC")
339}
340fn is_noto_sans_tc(f: &NotoFont) -> bool {
341    f.name.starts_with("Noto Sans TC")
342}
343fn is_noto_sans_hk(f: &NotoFont) -> bool {
344    f.name.starts_with("Noto Sans HK")
345}
346fn is_noto_sans_jp(f: &NotoFont) -> bool {
347    f.name.starts_with("Noto Sans JP")
348}
349fn is_noto_sans_kr(f: &NotoFont) -> bool {
350    f.name.starts_with("Noto Sans KR")
351}
352fn is_noto_color_emoji(f: &NotoFont) -> bool {
353    f.name.starts_with("Noto Color Emoji")
354}
355fn is_noto_sans_symbols(f: &NotoFont) -> bool {
356    f.name.starts_with("Noto Sans Symbols")
357}
358
359fn select_font(
360    candidates: &[usize],
361    arena: &HashMap<usize, IndexedNotoFont>,
362    language: &str,
363) -> usize {
364    // Find max coverCount
365    let mut max_covered = -1i32;
366    let mut best_fonts: Vec<usize> = Vec::new();
367    let mut best: Option<usize> = None;
368    for &fid in candidates {
369        let f = &arena[&fid];
370        let cc = f.cover_count as i32;
371        if cc > max_covered {
372            best_fonts.clear();
373            best_fonts.push(fid);
374            best = Some(fid);
375            max_covered = cc;
376        } else if cc == max_covered {
377            best_fonts.push(fid);
378            if best.map(|b| fid < b).unwrap_or(true) {
379                best = Some(fid);
380            }
381        }
382    }
383
384    // Language tie-break
385    if best_fonts.len() > 1 {
386        // check if all best are cjk
387        let all_cjk = best_fonts.iter().all(|&fid| is_cjk_font(arena[&fid].font));
388        if all_cjk {
389            if let Some(idx) = select_best_for_language(&best_fonts, arena, language) {
390                return idx;
391            }
392            if let Some(idx) = select_best_for_language(candidates, arena, language) {
393                return idx;
394            }
395        } else {
396            // emoji/symbols preference
397            if let Some(&fid) = best_fonts
398                .iter()
399                .find(|&&fid| is_noto_color_emoji(arena[&fid].font))
400            {
401                return fid;
402            }
403            if let Some(&fid) = best_fonts
404                .iter()
405                .find(|&&fid| is_noto_sans_symbols(arena[&fid].font))
406            {
407                return fid;
408            }
409            if let Some(&fid) = best_fonts
410                .iter()
411                .find(|&&fid| is_noto_sans_sc(arena[&fid].font))
412            {
413                return fid;
414            }
415        }
416    }
417
418    best.expect("No fallback font selected")
419}
420
421fn select_best_for_language(
422    candidates: &[usize],
423    arena: &HashMap<usize, IndexedNotoFont>,
424    language: &str,
425) -> Option<usize> {
426    match language {
427        "zh-Hans" | "zh-CN" | "zh-SG" | "zh-MY" => candidates
428            .iter()
429            .find(|&&fid| is_noto_sans_sc(arena[&fid].font))
430            .copied(),
431        "zh-Hant" | "zh-TW" | "zh-MO" => candidates
432            .iter()
433            .find(|&&fid| is_noto_sans_tc(arena[&fid].font))
434            .copied(),
435        "zh-HK" => candidates
436            .iter()
437            .find(|&&fid| is_noto_sans_hk(arena[&fid].font))
438            .copied(),
439        "ja" => candidates
440            .iter()
441            .find(|&&fid| is_noto_sans_jp(arena[&fid].font))
442            .copied(),
443        "ko" => candidates
444            .iter()
445            .find(|&&fid| is_noto_sans_kr(arena[&fid].font))
446            .copied(),
447        _ => None,
448    }
449}
450
451// WASM download + registry (mirrors WebFallbackFontDownloader)
452
453#[cfg(target_arch = "wasm32")]
454pub mod wasm_fallback {
455    use super::*;
456    use std::cell::RefCell;
457    use std::collections::HashSet;
458    use std::rc::Rc;
459    use wasm_bindgen::JsCast;
460    use wasm_bindgen::prelude::*;
461    use wasm_bindgen_futures::JsFuture;
462    use web_sys::{Request, Response};
463
464    const BATCH_WINDOW_MS: u32 = 60;
465    const MAX_BATCH_SIZE: usize = 10;
466
467    thread_local! {
468        static GLOBAL: Rc<RefCell<WebFallbackFontDownloader>> = Rc::new(RefCell::new(WebFallbackFontDownloader::new()));
469        static INSTALLED: RefCell<bool> = const { RefCell::new(false) };
470        static LISTENER_KEEP: RefCell<Option<std::sync::Arc<dyn crate::unresolved::UnresolvedListener>>> = const { RefCell::new(None) };
471    }
472
473    struct WebFallbackFontDownloader {
474        downloader: NotoFontDownloader,
475        queued: Vec<HashSet<u32>>,
476        is_running: bool,
477        error_count: u32,
478    }
479
480    impl WebFallbackFontDownloader {
481        fn new() -> Self {
482            Self {
483                downloader: NotoFontDownloader::new(),
484                queued: Vec::new(),
485                is_running: false,
486                error_count: 0,
487            }
488        }
489
490        fn submit(&mut self, codepoints: HashSet<u32>) {
491            if codepoints.is_empty() {
492                return;
493            }
494            self.queued.push(codepoints);
495        }
496
497        fn drain_channel(&mut self) {
498            self.queued.clear();
499        }
500    }
501
502    pub fn submit_unresolved(codepoints: Vec<u32>) {
503        if codepoints.is_empty() {
504            return;
505        }
506        // For backwards compat, also push directly if not installed
507        let is_installed = INSTALLED.with(|v| *v.borrow());
508        if is_installed {
509            // Use registry to dedupe globally like compose
510            crate::unresolved::web_unresolved_registry().add_unresolved_vec(codepoints);
511        } else {
512            let set: HashSet<u32> = codepoints.into_iter().collect();
513            GLOBAL.with(|g| {
514                let mut mgr = g.borrow_mut();
515                mgr.submit(set.clone());
516                if !mgr.is_running {
517                    mgr.is_running = true;
518                    let g_clone = g.clone();
519                    wasm_bindgen_futures::spawn_local(async move {
520                        run_loop(g_clone).await;
521                    });
522                }
523            });
524            // Also seed registry so future installs see pending
525            crate::unresolved::web_unresolved_registry()
526                .add_unresolved_codepoints(&set.into_iter().collect::<Vec<_>>());
527        }
528    }
529
530    pub fn install_fallback_font_downloader() {
531        let already = INSTALLED.with(|v| {
532            let mut b = v.borrow_mut();
533            if *b {
534                true
535            } else {
536                *b = true;
537                false
538            }
539        });
540        if already {
541            return;
542        }
543
544        // Create listener that forwards registry -> downloader channel (mirrors UnresolvedSymbolsRegistry.Listener)
545        struct RegistryListener;
546        impl crate::unresolved::UnresolvedListener for RegistryListener {
547            fn on_unresolved_codepoints(&self, codepoints: &HashSet<u32>) {
548                let set = codepoints.clone();
549                GLOBAL.with(|g| {
550                    let mut mgr = g.borrow_mut();
551                    mgr.submit(set);
552                    if !mgr.is_running {
553                        mgr.is_running = true;
554                        let g_clone = g.clone();
555                        wasm_bindgen_futures::spawn_local(async move {
556                            run_loop(g_clone).await;
557                        });
558                    }
559                });
560            }
561            fn on_new_font_installed(&self) {
562                // In compose ParagraphLayouter invalidates paragraph on new font
563                // Here global caches already cleared via register_font_data
564            }
565        }
566
567        let listener: std::sync::Arc<dyn crate::unresolved::UnresolvedListener> =
568            std::sync::Arc::new(RegistryListener);
569        crate::unresolved::web_unresolved_registry().add_listener(listener.clone());
570        // so Weak in registry doesn't die (was a bug prev.: listener dropped after fn)
571        LISTENER_KEEP.with(|c| *c.borrow_mut() = Some(listener));
572    }
573
574    async fn run_loop(global: Rc<RefCell<WebFallbackFontDownloader>>) {
575        loop {
576            let batch = {
577                // wait for at least one
578                loop {
579                    let has = global.borrow().queued.len() > 0;
580                    if has {
581                        break;
582                    }
583                    gloo_timers_approx_delay(16).await;
584                }
585                let mut batch_set = HashSet::new();
586                // FIFO: take oldest first (mirrors Channel receive order), not LIFO pop()
587                {
588                    let mut mgr = global.borrow_mut();
589                    if !mgr.queued.is_empty() {
590                        let first = mgr.queued.remove(0);
591                        batch_set.extend(first);
592                    }
593                }
594                // collect up to 9 more within 60ms window (mirrors repeat(9) withTimeoutOrNull)
595                let mut collected = 1;
596                let start = js_sys::Date::now();
597                while collected < MAX_BATCH_SIZE {
598                    let elapsed = js_sys::Date::now() - start;
599                    if elapsed >= BATCH_WINDOW_MS as f64 {
600                        break;
601                    }
602                    let maybe = {
603                        let mut mgr = global.borrow_mut();
604                        if !mgr.queued.is_empty() {
605                            Some(mgr.queued.remove(0))
606                        } else {
607                            None
608                        }
609                    };
610                    if let Some(s) = maybe {
611                        batch_set.extend(s);
612                        collected += 1;
613                    } else {
614                        let remaining = (BATCH_WINDOW_MS as f64 - elapsed).max(0.0) as u32;
615                        let wait = remaining.min(10);
616                        if wait > 0 {
617                            gloo_timers_approx_delay(wait).await;
618                        }
619                        if global.borrow().queued.is_empty() {
620                            if js_sys::Date::now() - start >= BATCH_WINDOW_MS as f64 {
621                                break;
622                            }
623                        }
624                    }
625                }
626                batch_set
627            };
628
629            if batch.is_empty() {
630                continue;
631            }
632
633            // Attempt download (mirrors downloader.downloadFallbackFont)
634            let fonts_to_download: Vec<&'static NotoFont> = {
635                let mut mgr = global.borrow_mut();
636                let lang = web_sys::window()
637                    .and_then(|w| w.navigator().language())
638                    .unwrap_or_else(|| "en".to_string());
639                let res = mgr.downloader.get_fonts_to_download(&batch, &lang);
640                res
641            };
642
643            if fonts_to_download.is_empty() {
644                // No fonts needed (e.g., PUA), drain and continue, don't retry
645                continue;
646            }
647
648            let mut successes: Vec<Vec<u8>> = Vec::new();
649            let mut any_success = false;
650            let mut all_failed = true;
651
652            use std::collections::HashSet;
653            let mut seen_urls: HashSet<String> = HashSet::new();
654            for font in &fonts_to_download {
655                let url = if font.name.starts_with("Noto Color Emoji") {
656                    // HACK: Use full ttf from jsDelivr (CORS, 10MB) instead of gstatic woff2 subsets (woff2 decode not supported by fontique/read-fonts)
657                    "https://cdn.jsdelivr.net/gh/googlefonts/noto-emoji@main/fonts/NotoColorEmoji.ttf".to_string()
658                } else {
659                    format!("{}{}", FONT_FALLBACK_BASE_URL, font.url)
660                };
661                if !seen_urls.insert(url.clone()) {
662                    continue;
663                }
664                match fetch_bytes(&url).await {
665                    Ok(bytes) => {
666                        successes.push(bytes);
667                        any_success = true;
668                        all_failed = false;
669                    }
670                    Err(_) => {}
671                }
672            }
673
674            if all_failed && !any_success {
675                let backoff = {
676                    let mut mgr = global.borrow_mut();
677                    let pause = mgr.error_count * 5;
678                    mgr.error_count += 1;
679                    pause
680                };
681                // Non-blocking retry like compose scope.launch { delay(pause); submit(batch) }
682                let batch_clone = batch.clone();
683                wasm_bindgen_futures::spawn_local(async move {
684                    if backoff > 0 {
685                        gloo_timers_approx_delay(backoff * 1000).await;
686                    }
687                    GLOBAL.with(|g| {
688                        g.borrow_mut().submit(batch_clone);
689                    });
690                });
691                continue;
692            }
693
694            // Success path: reset errorCount, drainChannel(), onFontsLoaded()
695            {
696                let mut mgr = global.borrow_mut();
697                mgr.error_count = 0;
698                // drainChannel() – mirrors compose after success
699                mgr.drain_channel();
700            }
701
702            for bytes in &successes {
703                crate::register_font_data(&bytes);
704            }
705
706            if any_success {
707                crate::unresolved::web_unresolved_registry().on_new_font_installed();
708                // ensure caches cleared and frame bumped (register does, but double-safe)
709                crate::clear_caches_for_fallback();
710                crate::bump_frame_for_fallback();
711            }
712        }
713    }
714
715    async fn gloo_timers_approx_delay(ms: u32) -> () {
716        let promise = js_sys::Promise::new(&mut |resolve, _reject| {
717            let window = web_sys::window().unwrap();
718            let _ =
719                window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms as i32);
720        });
721        let _ = JsFuture::from(promise).await;
722    }
723
724    async fn fetch_bytes(url: &str) -> Result<Vec<u8>, JsValue> {
725        // Use RequestInit with CORS mode so cross-origin fetch to fonts.gstatic.com
726        let mut opts = web_sys::RequestInit::new();
727        opts.set_method("GET");
728        opts.set_mode(web_sys::RequestMode::Cors);
729        opts.set_cache(web_sys::RequestCache::Default);
730        let request = Request::new_with_str_and_init(url, &opts)?;
731        let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
732        let resp_value = JsFuture::from(window.fetch_with_request(&request)).await?;
733        let resp: Response = resp_value.dyn_into().unwrap();
734        if !resp.ok() {
735            return Err(JsValue::from_str(&format!(
736                "fetch failed status {}",
737                resp.status()
738            )));
739        }
740        let buffer = JsFuture::from(resp.array_buffer()?).await?;
741        let arr = js_sys::Uint8Array::new(&buffer);
742        let bytes = arr.to_vec();
743        Ok(bytes)
744    }
745
746    // Public API for init
747    pub fn ensure_fallback_initialized() {
748        install_fallback_font_downloader();
749    }
750}
751
752#[cfg(not(target_arch = "wasm32"))]
753pub mod wasm_fallback {
754    pub fn submit_unresolved(_codepoints: Vec<u32>) {}
755    pub fn ensure_fallback_initialized() {}
756    pub fn install_fallback_font_downloader() {}
757}
758
759#[cfg(test)]
760mod tests {
761    use super::*;
762    use std::collections::HashSet;
763
764    #[test]
765    fn empty_returns_empty() {
766        let mut d = NotoFontDownloader::new();
767        let res = d.get_fonts_to_download(&HashSet::new(), "en");
768        assert!(res.is_empty());
769        assert!(d.get_codepoints_with_no_known_font().is_empty());
770    }
771
772    #[test]
773    fn above_max_ignored() {
774        let mut d = NotoFontDownloader::new();
775        let mut set = HashSet::new();
776        set.insert(0x110000);
777        let res = d.get_fonts_to_download(&set, "en");
778        assert!(res.is_empty());
779        assert!(d.get_codepoints_with_no_known_font().is_empty());
780    }
781
782    #[test]
783    fn pua_remembered() {
784        let mut d = NotoFontDownloader::new();
785        let mut set = HashSet::new();
786        set.insert(0xE000);
787        let first = d.get_fonts_to_download(&set, "en");
788        assert!(first.is_empty());
789        assert!(d.get_codepoints_with_no_known_font().contains(&0xE000));
790        let second = d.get_fonts_to_download(&set, "en");
791        assert!(second.is_empty());
792    }
793
794    #[test]
795    fn arabic_resolves() {
796        let mut d = NotoFontDownloader::new();
797        let set: HashSet<u32> = [0x0639, 0x0641].into_iter().collect();
798        let fonts = d.get_fonts_to_download(&set, "en");
799        assert!(!fonts.is_empty(), "Arabic should resolve");
800        assert!(
801            fonts.iter().all(|f| f.name.starts_with("Noto Sans Arabic")),
802            "got {:?}",
803            fonts.iter().map(|f| f.name).collect::<Vec<_>>()
804        );
805    }
806
807    #[test]
808    fn emoji_resolves() {
809        let mut d = NotoFontDownloader::new();
810        let set: HashSet<u32> = [0x1F600, 0x1F389].into_iter().collect();
811        let fonts = d.get_fonts_to_download(&set, "en");
812        assert!(!fonts.is_empty());
813        assert!(
814            fonts.iter().all(|f| f.name.starts_with("Noto Color Emoji")),
815            "got {:?}",
816            fonts.iter().map(|f| f.name).collect::<Vec<_>>()
817        );
818    }
819
820    #[test]
821    fn cjk_zh_cn() {
822        let mut d = NotoFontDownloader::new();
823        let set: HashSet<u32> = [0x5B57].into_iter().collect();
824        let fonts = d.get_fonts_to_download(&set, "zh-CN");
825        assert!(!fonts.is_empty());
826        assert!(
827            fonts.iter().all(|f| f.name.starts_with("Noto Sans SC")),
828            "got {:?}",
829            fonts.iter().map(|f| f.name).collect::<Vec<_>>()
830        );
831    }
832
833    #[test]
834    fn japanese_hiragana() {
835        let mut d = NotoFontDownloader::new();
836        let set: HashSet<u32> = [0x3042].into_iter().collect();
837        let fonts = d.get_fonts_to_download(&set, "ja");
838        assert!(!fonts.is_empty());
839        assert!(
840            fonts.iter().all(|f| f.name.starts_with("Noto Sans JP")),
841            "got {:?}",
842            fonts.iter().map(|f| f.name).collect::<Vec<_>>()
843        );
844    }
845
846    #[test]
847    fn devanagari() {
848        let mut d = NotoFontDownloader::new();
849        let set: HashSet<u32> = [0x0905, 0x0915].into_iter().collect();
850        let fonts = d.get_fonts_to_download(&set, "en");
851        assert!(!fonts.is_empty(), "Devanagari should resolve to Noto Sans*");
852        // Compose test says Noto Sans (base) for 0905,0915
853        assert!(
854            fonts.iter().all(|f| f.name.starts_with("Noto Sans")),
855            "got {:?}",
856            fonts.iter().map(|f| f.name).collect::<Vec<_>>()
857        );
858    }
859
860    #[test]
861    fn hebrew_resolves() {
862        let mut d = NotoFontDownloader::new();
863        let set: HashSet<u32> = [0x05E9, 0x05D0].into_iter().collect();
864        let fonts = d.get_fonts_to_download(&set, "en");
865        assert!(!fonts.is_empty());
866        assert!(
867            fonts.iter().all(|f| f.name.starts_with("Noto Sans Hebrew")),
868            "got {:?}",
869            fonts.iter().map(|f| f.name).collect::<Vec<_>>()
870        );
871    }
872
873    #[test]
874    fn thai_resolves() {
875        let mut d = NotoFontDownloader::new();
876        let set: HashSet<u32> = [0x0E01, 0x0E2A].into_iter().collect();
877        let fonts = d.get_fonts_to_download(&set, "en");
878        assert!(!fonts.is_empty());
879        assert!(
880            fonts.iter().all(|f| f.name.starts_with("Noto Sans Thai")),
881            "got {:?}",
882            fonts.iter().map(|f| f.name).collect::<Vec<_>>()
883        );
884    }
885
886    #[test]
887    fn bengali_resolves() {
888        let mut d = NotoFontDownloader::new();
889        let set: HashSet<u32> = [0x0985, 0x0995].into_iter().collect();
890        let fonts = d.get_fonts_to_download(&set, "en");
891        assert!(!fonts.is_empty());
892        assert!(
893            fonts
894                .iter()
895                .all(|f| f.name.starts_with("Noto Sans Bengali")),
896            "got {:?}",
897            fonts.iter().map(|f| f.name).collect::<Vec<_>>()
898        );
899    }
900
901    #[test]
902    fn box_drawing_resolves() {
903        let mut d = NotoFontDownloader::new();
904        let set: HashSet<u32> = [0x2500, 0x2502].into_iter().collect();
905        let fonts = d.get_fonts_to_download(&set, "en");
906        assert!(!fonts.is_empty());
907        // Box drawing may be in HK or SC depending on generated data version; just check Noto Sans*
908        assert!(
909            fonts.iter().all(|f| f.name.starts_with("Noto Sans")),
910            "got {:?}",
911            fonts.iter().map(|f| f.name).collect::<Vec<_>>()
912        );
913    }
914
915    #[test]
916    fn korean_ko() {
917        let mut d = NotoFontDownloader::new();
918        let set: HashSet<u32> = [0xACA8, 0xACAF, 0xACF0].into_iter().collect();
919        let fonts = d.get_fonts_to_download(&set, "ko");
920        assert!(!fonts.is_empty());
921        assert!(
922            fonts.iter().all(|f| f.name.starts_with("Noto Sans KR")),
923            "got {:?}",
924            fonts.iter().map(|f| f.name).collect::<Vec<_>>()
925        );
926    }
927
928    #[test]
929    fn cjk_zh_hant() {
930        let mut d = NotoFontDownloader::new();
931        let set: HashSet<u32> = [0x5B57].into_iter().collect();
932        let fonts = d.get_fonts_to_download(&set, "zh-TW");
933        assert!(!fonts.is_empty());
934        assert!(
935            fonts.iter().all(|f| f.name.starts_with("Noto Sans TC")),
936            "got {:?}",
937            fonts.iter().map(|f| f.name).collect::<Vec<_>>()
938        );
939    }
940}