Skip to main content

repose_text/
lib.rs

1use font_awl::FontProvider;
2use rapidhash::{HashMapExt, RapidHashMap, fast::RapidHasher};
3use skrifa::MetadataProvider;
4use skrifa::outline::OutlinePen;
5use std::sync::OnceLock;
6
7pub mod fallback;
8pub mod fallback_data;
9pub mod unresolved;
10
11use std::sync::atomic::{AtomicU64, Ordering};
12use std::{
13    collections::{HashMap, VecDeque},
14    hash::{Hash, Hasher},
15    sync::Mutex,
16};
17use unicode_segmentation::UnicodeSegmentation;
18
19static FRAME_COUNTER: AtomicU64 = AtomicU64::new(0);
20static FONT_GENERATION: AtomicU64 = AtomicU64::new(0);
21static FALLBACK_DIRTY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
22
23pub fn begin_frame() {
24    FRAME_COUNTER.fetch_add(1, Ordering::Relaxed);
25}
26
27pub fn current_frame() -> u64 {
28    FRAME_COUNTER.load(Ordering::Relaxed)
29}
30
31pub fn font_generation() -> u64 {
32    FONT_GENERATION.load(Ordering::Relaxed)
33}
34
35pub fn take_fallback_dirty() -> bool {
36    // NOTE: concurrent store(true) either before or after swap will be preserved
37    // If it happens after swap, next call will return true. So swap is fine, keep simple but use SeqCst to ensure ordering
38    FALLBACK_DIRTY.swap(false, Ordering::SeqCst)
39}
40
41const GLYPH_CACHE_CAP: usize = 4096;
42const WRAP_CACHE_CAP: usize = 1024;
43const ELLIP_CACHE_CAP: usize = 2048;
44
45static METRICS_LRU: OnceLock<Mutex<Lru<(u64, u32, u64, u16, u8, i32, u64), TextMetrics>>> =
46    OnceLock::new();
47fn metrics_cache() -> &'static Mutex<Lru<(u64, u32, u64, u16, u8, i32, u64), TextMetrics>> {
48    METRICS_LRU.get_or_init(|| Mutex::new(Lru::new(4096)))
49}
50
51struct Lru<K, V> {
52    map: RapidHashMap<K, V>,
53    ticks: RapidHashMap<K, u64>,
54    order: VecDeque<K>,
55    cap: usize,
56    tick_counter: u64,
57}
58impl<K: std::hash::Hash + Eq + Clone, V> Lru<K, V> {
59    fn new(cap: usize) -> Self {
60        Self {
61            map: RapidHashMap::new(),
62            ticks: RapidHashMap::new(),
63            order: VecDeque::new(),
64            cap,
65            tick_counter: 0,
66        }
67    }
68    fn get(&mut self, k: &K) -> Option<&V> {
69        if self.map.contains_key(k) {
70            self.tick_counter = self.tick_counter.wrapping_add(1);
71            self.ticks.insert(k.clone(), self.tick_counter);
72            // For correctness with existing clear(), we keep order in sync via tick map.
73            // To keep order VecDeque consistent without O(n), we push new entry and skip stale on pop
74            self.order.push_back(k.clone());
75            if self.order.len() > self.cap * 3 {
76                let mut pairs: Vec<(K, u64)> = self
77                    .ticks
78                    .iter()
79                    .map(|(kk, tt)| (kk.clone(), *tt))
80                    .collect();
81                pairs.sort_by_key(|(_, t)| *t);
82                self.order.clear();
83                for (kk, _) in pairs {
84                    self.order.push_back(kk);
85                }
86            }
87        }
88        self.map.get(k)
89    }
90    fn put(&mut self, k: K, v: V) {
91        self.tick_counter = self.tick_counter.wrapping_add(1);
92        let is_new = !self.map.contains_key(&k);
93        self.map.insert(k.clone(), v);
94        self.ticks.insert(k.clone(), self.tick_counter);
95        if is_new {
96            self.order.push_back(k.clone());
97        } else {
98            self.order.push_back(k);
99        }
100        while self.map.len() > self.cap {
101            let victim = {
102                let mut min_key: Option<K> = None;
103                let mut min_tick = u64::MAX;
104                for (kk, tt) in &self.ticks {
105                    if *tt < min_tick {
106                        min_tick = *tt;
107                        min_key = Some(kk.clone());
108                    }
109                }
110                min_key
111            };
112            if let Some(victim) = victim {
113                self.map.remove(&victim);
114                self.ticks.remove(&victim);
115                if let Some(pos) = self.order.iter().position(|x| x == &victim) {
116                    self.order.remove(pos);
117                }
118            } else {
119                break;
120            }
121        }
122        if self.order.len() > self.cap * 2 {
123            let mut seen = std::collections::HashSet::new();
124            let mut compacted = VecDeque::new();
125            // Keep only last occurrence per key, in tick order
126            let mut pairs: Vec<(K, u64)> = self
127                .ticks
128                .iter()
129                .map(|(kk, tt)| (kk.clone(), *tt))
130                .collect();
131            pairs.sort_by_key(|(_, t)| *t);
132            for (kk, _) in pairs {
133                if seen.insert(kk.clone()) {
134                    compacted.push_back(kk);
135                }
136            }
137            self.order = compacted;
138        }
139    }
140    fn clear_both(&mut self) {
141        self.map.clear();
142        self.ticks.clear();
143        self.order.clear();
144        self.tick_counter = 0;
145    }
146}
147
148static WRAP_LRU: OnceLock<
149    Mutex<Lru<(u64, u32, u32, u16, bool, u16, u8, i32, u64), (Vec<String>, bool)>>,
150> = OnceLock::new();
151
152static WRAP_RANGES_LRU: OnceLock<
153    Mutex<Lru<(u64, u32, u32, u16, bool, u16, u8, i32, u64), (Vec<(usize, usize)>, bool)>>,
154> = OnceLock::new();
155
156static ELLIP_LRU: OnceLock<Mutex<Lru<(u64, u32, u32, u16, u8, i32, u64), String>>> =
157    OnceLock::new();
158
159fn wrap_cache()
160-> &'static Mutex<Lru<(u64, u32, u32, u16, bool, u16, u8, i32, u64), (Vec<String>, bool)>> {
161    WRAP_LRU.get_or_init(|| Mutex::new(Lru::new(WRAP_CACHE_CAP)))
162}
163
164fn wrap_ranges_cache()
165-> &'static Mutex<Lru<(u64, u32, u32, u16, bool, u16, u8, i32, u64), (Vec<(usize, usize)>, bool)>> {
166    WRAP_RANGES_LRU.get_or_init(|| Mutex::new(Lru::new(WRAP_CACHE_CAP)))
167}
168
169fn ellip_cache() -> &'static Mutex<Lru<(u64, u32, u32, u16, u8, i32, u64), String>> {
170    ELLIP_LRU.get_or_init(|| Mutex::new(Lru::new(ELLIP_CACHE_CAP)))
171}
172
173fn fast_hash(s: &str) -> u64 {
174    let mut h = RapidHasher::default();
175    s.len().hash(&mut h);
176    s.hash(&mut h);
177    h.finish()
178}
179
180#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
181pub struct GlyphKey(pub u64);
182
183/// Cache key for the renderer's glyph slug cache -> uniquely identifies a
184/// specific glyph in a specific font face.
185#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
186pub struct CacheKey {
187    pub font_id: u64,
188    pub glyph_id: u32,
189    pub font_size_bits: u32,
190}
191
192static GLYPH_ID_WARNED: OnceLock<Mutex<std::collections::HashSet<(u64, u32)>>> = OnceLock::new();
193
194/// Map a `u32` glyph id to the `u16` id swash accepts.
195///
196/// Glyph ids above `u16::MAX` (possible in very large fonts) cannot be
197/// rendered by swash. Remap those to `.notdef` (0) and emit a warn once per
198/// `(font_id, glyph_id)` instead of silently dropping the glyph. The public
199/// [`CacheKey`] keeps the full `u32` id so no API changes are needed.
200fn swash_glyph_id(font_id: u64, glyph_id: u32) -> u16 {
201    match u16::try_from(glyph_id) {
202        Ok(v) => v,
203        Err(_) => {
204            let warned =
205                GLYPH_ID_WARNED.get_or_init(|| Mutex::new(std::collections::HashSet::new()));
206            let is_new = warned
207                .lock()
208                .map(|mut g| g.insert((font_id, glyph_id)))
209                .unwrap_or(false);
210            if is_new {
211                log::warn!(
212                    "glyph id {} (font {}) exceeds u16::MAX; falling back to .notdef (0)",
213                    glyph_id,
214                    font_id
215                );
216            }
217            0
218        }
219    }
220}
221
222/// Vector path command for glyph outlines.
223#[derive(Clone, Debug)]
224pub enum Command {
225    MoveTo(f32, f32),
226    LineTo(f32, f32),
227    QuadTo(f32, f32, f32, f32),
228    CurveTo(f32, f32, f32, f32, f32, f32),
229    Close,
230}
231
232pub struct ShapedGlyph {
233    pub key: GlyphKey,
234    pub px: f32,
235    pub x: f32,
236    pub y: f32,
237    pub w: f32,
238    pub h: f32,
239    pub bearing_x: f32,
240    pub bearing_y: f32,
241    pub advance: f32,
242}
243
244pub use swash::scale::image::Content as SwashContent;
245
246pub struct GlyphBitmap {
247    pub key: GlyphKey,
248    pub w: u32,
249    pub h: u32,
250    pub content: SwashContent,
251    pub data: Vec<u8>,
252}
253
254struct FontRecord {
255    id: u64,
256    data: parley::FontData,
257    data_bytes: Vec<u8>,
258}
259
260struct Engine {
261    font_cx: parley::FontContext,
262    layout_cx: parley::LayoutContext<()>,
263    swash_cx: swash::scale::ScaleContext,
264    key_map: HashMap<GlyphKey, (u64, u32)>,
265    font_registry: Vec<FontRecord>,
266    next_font_id: u64,
267    /// Cache of rendered glyphs keyed by (font_id, glyph_id, font_size_bits).
268    /// Contains (width, height, left, top, content, data).
269    glyph_cache:
270        HashMap<(u64, u32, u32), (u32, u32, i32, i32, swash::scale::image::Content, Vec<u8>)>,
271    /// Cache of (ascent, descent) in px keyed by
272    /// (family hash, weight, px bits). Used for baseline alignment.
273    ascent_cache: RapidHashMap<(u64, u16, u32), (f32, f32)>,
274}
275
276impl Engine {
277    fn ensure_font(&mut self, fd: &parley::FontData) -> u64 {
278        if let Some(existing) = self.font_registry.iter().find(|r| r.data == *fd) {
279            log::debug!(
280                "[font] reuse id={} len={}",
281                existing.id,
282                fd.data.as_ref().len()
283            );
284            return existing.id;
285        }
286        let id = self.next_font_id;
287        self.next_font_id += 1;
288        let bytes = fd.data.as_ref().to_vec();
289        log::debug!("[font] register id={} len={}", id, bytes.len());
290        self.font_registry.push(FontRecord {
291            id,
292            data: fd.clone(),
293            data_bytes: bytes,
294        });
295        id
296    }
297
298    fn trim_glyph_cache(&mut self) {
299        if self.glyph_cache.len() > GLYPH_CACHE_CAP {
300            let to_remove = self.glyph_cache.len() - GLYPH_CACHE_CAP;
301            let keys: Vec<_> = self.glyph_cache.keys().take(to_remove).copied().collect();
302            for k in keys {
303                self.glyph_cache.remove(&k);
304            }
305        }
306    }
307
308    /// Resolve `(ascent, descent)` in px for the primary font matching
309    /// `(family, weight)`, trying named families first and falling back to
310    /// the bundled sans. Returns a `0.8em`/`0.2em` estimate when unresolved.
311    fn resolve_vertical_metrics(
312        &mut self,
313        font_family: Option<&str>,
314        font_weight: u16,
315        px: f32,
316    ) -> (f32, f32) {
317        let mut candidates: Vec<&str> = Vec::new();
318        match font_family {
319            Some("monospace") => candidates.push("JetBrains Mono"),
320            Some("sans-serif") => candidates.push("Open Sans"),
321            Some("emoji") => candidates.push("Noto Color Emoji"),
322            Some(other) => candidates.push(other),
323            None => {}
324        }
325        candidates.push("Open Sans");
326        for name in candidates {
327            if let Some(m) = self.metrics_for_family(name, font_weight, px) {
328                return m;
329            }
330        }
331        (px * 0.8, px * 0.2)
332    }
333
334    /// Best-weight-match `(ascent, descent)` for a named family, or `None`
335    /// when the family (or its data) is unavailable.
336    fn metrics_for_family(&mut self, name: &str, font_weight: u16, px: f32) -> Option<(f32, f32)> {
337        let info = self.font_cx.collection.family_by_name(name)?;
338        let target = font_weight as f32;
339        let mut best: Option<(f32, Vec<u8>, u32)> = None;
340        for font in info.fonts() {
341            let dist = (font.weight().value() - target).abs();
342            if best.as_ref().is_some_and(|(bd, _, _)| *bd <= dist) {
343                continue;
344            }
345            let bytes: Vec<u8> = match font.source().kind() {
346                parley::fontique::SourceKind::Memory(blob) => blob.as_ref().to_vec(),
347                #[cfg(not(target_arch = "wasm32"))]
348                parley::fontique::SourceKind::Path(path) => std::fs::read(path).ok()?,
349                #[cfg(target_arch = "wasm32")]
350                parley::fontique::SourceKind::Path(_) => continue,
351            };
352            best = Some((dist, bytes, font.index()));
353        }
354        let (_, bytes, index) = best?;
355        let font = skrifa::FontRef::from_index(&bytes, index).ok()?;
356        let metrics = font.metrics(
357            skrifa::instance::Size::new(px),
358            skrifa::instance::LocationRef::default(),
359        );
360        Some((metrics.ascent.max(0.0), metrics.descent.abs().max(0.0)))
361    }
362
363    fn raster_placement(
364        &mut self,
365        font_id: u64,
366        glyph_id: u32,
367        px: f32,
368    ) -> Option<(f32, f32, f32, f32)> {
369        use swash::scale::{Render, Source, StrikeWith};
370        let cache_key = (font_id, glyph_id, px.to_bits());
371        if let Some(cached) = self.glyph_cache.get(&cache_key) {
372            log::debug!(
373                "[raster_placement] HIT fid={} gid={} px={} => {}x{} {}x{}",
374                font_id,
375                glyph_id,
376                px,
377                cached.0,
378                cached.1,
379                cached.2,
380                cached.3
381            );
382            return Some((
383                cached.0 as f32,
384                cached.1 as f32,
385                cached.2 as f32,
386                cached.3 as f32,
387            ));
388        }
389        let data_bytes = self
390            .font_registry
391            .iter()
392            .find(|r| r.id == font_id)?
393            .data_bytes
394            .clone();
395        let font = swash::FontRef::from_index(&data_bytes, 0)?;
396        let mut scaler = self.swash_cx.builder(font).size(px).hint(true).build();
397        let image = Render::new(&[
398            Source::Outline,
399            Source::ColorBitmap(StrikeWith::BestFit),
400            Source::ColorOutline(0),
401        ])
402        .render(&mut scaler, swash_glyph_id(font_id, glyph_id))?;
403        log::debug!(
404            "[raster_placement] MISS fid={} gid={} px={} => {}x{} {}x{}",
405            font_id,
406            glyph_id,
407            px,
408            image.placement.width,
409            image.placement.height,
410            image.placement.left,
411            image.placement.top
412        );
413        self.glyph_cache.insert(
414            cache_key,
415            (
416                image.placement.width,
417                image.placement.height,
418                image.placement.left,
419                image.placement.top,
420                image.content,
421                image.data,
422            ),
423        );
424        self.trim_glyph_cache();
425        Some((
426            image.placement.width as f32,
427            image.placement.height as f32,
428            image.placement.left as f32,
429            image.placement.top as f32,
430        ))
431    }
432}
433
434static ENGINE: OnceLock<Mutex<Engine>> = OnceLock::new();
435
436pub static FONT_PROVIDER: OnceLock<Mutex<font_awl::Provider>> = OnceLock::new();
437
438fn init_engine_sync() -> Engine {
439    let mut provider = font_awl::Provider::new();
440    provider.load_bundled_fonts();
441    #[cfg(not(target_arch = "wasm32"))]
442    if let Err(e) = provider.load_system_fonts_best_effort() {
443        log::warn!("font-awl: failed to load system fonts: {e}");
444    }
445
446    let mut font_cx = provider.new_parley_context();
447    // On wasm, bundled Symbols2 is NOT added to generic families by font-awl (bundled.rs only sets generic for OpenSans).
448    // Without this, "sans-serif" text like Text("★") has no fallback to Symbols2 and renders as .notdef (gid 0).
449    // Add Symbols2 to SansSerif generic at init so explicit fallback stacks and generic fallback both work.
450    #[cfg(target_arch = "wasm32")]
451    {
452        if let Some(info) = font_cx.collection.family_by_name("Noto Sans Symbols 2") {
453            let id = info.id();
454            let mut existing: Vec<parley::fontique::FamilyId> = font_cx
455                .collection
456                .generic_families(parley::fontique::GenericFamily::SansSerif)
457                .collect();
458            if !existing.contains(&id) {
459                existing.push(id);
460                font_cx.collection.set_generic_families(
461                    parley::fontique::GenericFamily::SansSerif,
462                    existing.into_iter(),
463                );
464            }
465        }
466        // Also ensure Emoji generic exists (may be empty initially, but keep for layered fallback).
467        // No-op if already set.
468    }
469    let layout_cx = parley::LayoutContext::new();
470
471    static MATERIAL_SYMBOLS_TTF: &[u8] = include_bytes!("assets/MaterialSymbolsOutlined.ttf");
472    let blob: parley::fontique::Blob<u8> = MATERIAL_SYMBOLS_TTF.to_vec().into();
473    font_cx.collection.register_fonts(blob, None);
474
475    let _ = FONT_PROVIDER.set(Mutex::new(provider));
476
477    Engine {
478        font_cx,
479        layout_cx,
480        swash_cx: swash::scale::ScaleContext::new(),
481        key_map: HashMap::new(),
482        font_registry: Vec::new(),
483        next_font_id: 1,
484        glyph_cache: HashMap::new(),
485        ascent_cache: RapidHashMap::new(),
486    }
487}
488
489#[cfg(target_arch = "wasm32")]
490pub async fn init_fonts_wasm() {
491    let mut provider = font_awl::Provider::new();
492    provider.load_bundled_fonts();
493    if let Err(e) = provider.load_web_fonts().await {
494        log::warn!("font-awl: failed to load web fonts: {e}");
495    }
496    let _ = FONT_PROVIDER.set(Mutex::new(provider));
497
498    if let Some(eng) = ENGINE.get() {
499        let mut eng = eng.lock().unwrap();
500        // Register web fonts into the existing engine's collection
501        // by re-building font_cx from the updated provider
502        if let Some(provider_lock) = FONT_PROVIDER.get() {
503            let p = provider_lock.lock().unwrap();
504            eng.font_cx = p.new_parley_context();
505        }
506    }
507}
508
509fn engine() -> &'static Mutex<Engine> {
510    ENGINE.get_or_init(|| Mutex::new(init_engine_sync()))
511}
512
513pub fn register_font_data(bytes: &[u8]) {
514    let mut eng = engine().lock().unwrap();
515    let blob: parley::fontique::Blob<u8> = bytes.to_vec().into();
516    let families = eng.font_cx.collection.register_fonts(blob.clone(), None);
517    // Detect family type: font_family_name fails for woff2 (skrifa needs decompressed), so fallback to registered family names
518    let mut is_emoji = false;
519    let mut is_symbols = false;
520    if let Some(name) = font_family_name(bytes) {
521        if name.starts_with("Noto Color Emoji") {
522            is_emoji = true;
523        } else if name.starts_with("Noto Sans Symbols") {
524            is_symbols = true;
525        }
526    }
527    if !is_emoji && !is_symbols {
528        for (fid, _) in &families {
529            if let Some(fname) = eng.font_cx.collection.family_name(*fid) {
530                if fname.starts_with("Noto Color Emoji") {
531                    is_emoji = true;
532                }
533                if fname.starts_with("Noto Sans Symbols") {
534                    is_symbols = true;
535                }
536            } else if let Some(info) = eng.font_cx.collection.family(*fid) {
537                let fname = info.name();
538                if fname.starts_with("Noto Color Emoji") {
539                    is_emoji = true;
540                }
541                if fname.starts_with("Noto Sans Symbols") {
542                    is_symbols = true;
543                }
544            }
545        }
546    }
547    if is_emoji {
548        let ids: Vec<parley::fontique::FamilyId> = families.iter().map(|(fid, _)| *fid).collect();
549        let mut existing: Vec<parley::fontique::FamilyId> = eng
550            .font_cx
551            .collection
552            .generic_families(parley::fontique::GenericFamily::Emoji)
553            .collect();
554        for id in ids.clone() {
555            if !existing.contains(&id) {
556                existing.push(id);
557            }
558        }
559        eng.font_cx
560            .collection
561            .set_generic_families(parley::fontique::GenericFamily::Emoji, existing.into_iter());
562    } else if is_symbols {
563        let ids: Vec<parley::fontique::FamilyId> = families.iter().map(|(fid, _)| *fid).collect();
564        let mut existing: Vec<parley::fontique::FamilyId> = eng
565            .font_cx
566            .collection
567            .generic_families(parley::fontique::GenericFamily::SansSerif)
568            .collect();
569        for id in ids.clone() {
570            if !existing.contains(&id) {
571                existing.push(id);
572            }
573        }
574        eng.font_cx.collection.set_generic_families(
575            parley::fontique::GenericFamily::SansSerif,
576            existing.into_iter(),
577        );
578    }
579    // Clear source cache so next layout re-resolves fonts (mirrors Compose invalidation)
580    eng.font_cx.source_cache = parley::fontique::SourceCache::default();
581    if let Some(provider_lock) = FONT_PROVIDER.get() {
582        let mut p = provider_lock.lock().unwrap();
583        let families2 = p.collection_mut().register_fonts(blob, None);
584        // Mirror generic setup for provider's collection as well (used for new contexts)
585        if is_emoji {
586            let ids: Vec<parley::fontique::FamilyId> =
587                families2.iter().map(|(fid, _)| *fid).collect();
588            let mut existing: Vec<parley::fontique::FamilyId> = p
589                .collection_mut()
590                .generic_families(parley::fontique::GenericFamily::Emoji)
591                .collect();
592            for id in ids.clone() {
593                if !existing.contains(&id) {
594                    existing.push(id);
595                }
596            }
597            p.collection_mut()
598                .set_generic_families(parley::fontique::GenericFamily::Emoji, existing.into_iter());
599        } else if is_symbols {
600            let ids: Vec<parley::fontique::FamilyId> =
601                families2.iter().map(|(fid, _)| *fid).collect();
602            let mut existing: Vec<parley::fontique::FamilyId> = p
603                .collection_mut()
604                .generic_families(parley::fontique::GenericFamily::SansSerif)
605                .collect();
606            for id in ids.clone() {
607                if !existing.contains(&id) {
608                    existing.push(id);
609                }
610            }
611            p.collection_mut().set_generic_families(
612                parley::fontique::GenericFamily::SansSerif,
613                existing.into_iter(),
614            );
615        }
616    }
617    clear_caches_for_fallback_in(&mut eng);
618    // Notify unresolved registry (mirrors Compose: fontFamilyResolver.preload + onNewFontInstalled)
619    #[cfg(target_arch = "wasm32")]
620    {
621        // Re-invalidate via registry to trigger ParagraphLayouter-style listeners
622        // (wasm_fallback also does this; double-clear is safe)
623        crate::unresolved::web_unresolved_registry().on_new_font_installed();
624    }
625}
626
627pub(crate) fn clear_caches_for_fallback() {
628    clear_lru_caches();
629    if let Ok(mut eng) = engine().lock() {
630        eng.ascent_cache.clear();
631    }
632    bump_frame_for_fallback();
633}
634
635/// Variant for callers that already hold the engine guard.
636pub(crate) fn clear_caches_for_fallback_in(eng: &mut Engine) {
637    clear_lru_caches();
638    eng.ascent_cache.clear();
639    bump_frame_for_fallback();
640}
641
642fn clear_lru_caches() {
643    if let Some(c) = METRICS_LRU.get()
644        && let Ok(mut g) = c.lock()
645    {
646        g.clear_both();
647    }
648    if let Some(c) = WRAP_LRU.get()
649        && let Ok(mut g) = c.lock()
650    {
651        g.clear_both();
652    }
653    if let Some(c) = WRAP_RANGES_LRU.get()
654        && let Ok(mut g) = c.lock()
655    {
656        g.clear_both();
657    }
658    if let Some(c) = ELLIP_LRU.get()
659        && let Ok(mut g) = c.lock()
660    {
661        g.clear_both();
662    }
663}
664
665pub(crate) fn bump_frame_for_fallback() {
666    FRAME_COUNTER.fetch_add(1, Ordering::Relaxed);
667    FONT_GENERATION.fetch_add(1, Ordering::Relaxed);
668    FALLBACK_DIRTY.store(true, Ordering::Relaxed);
669}
670
671#[cfg(target_arch = "wasm32")]
672pub fn ensure_web_fallback_initialized() {
673    crate::fallback::wasm_fallback::ensure_fallback_initialized();
674}
675
676#[cfg(not(target_arch = "wasm32"))]
677pub fn ensure_web_fallback_initialized() {}
678
679/// Load a font from a file path and register it into the global font system.
680///
681/// Returns an error if the file cannot be read.
682pub fn load_font_file(path: impl AsRef<std::path::Path>) -> std::io::Result<()> {
683    let bytes = std::fs::read(path)?;
684    register_font_data(&bytes);
685    Ok(())
686}
687
688/// Vertical font metrics `(ascent, descent)` in px for the primary font
689/// matching `(font_family, font_weight)`.
690///
691/// Used for text baseline alignment (`AlignItems::Baseline`): the first
692/// baseline of a text block sits `ascent` below the top of its first line's
693/// em box (plus half-leading, applied by the caller). Results are cached.
694/// Falls back to a `0.8em`/`0.2em` estimate when no font resolves.
695pub fn primary_font_vertical_metrics(
696    font_family: Option<&str>,
697    font_weight: u16,
698    px: f32,
699) -> (f32, f32) {
700    if !(px > 0.0) {
701        return (0.0, 0.0);
702    }
703    let key = (
704        font_family.map(fast_hash).unwrap_or(0),
705        font_weight,
706        (px * 100.0) as u32,
707    );
708    let mut eng = engine().lock().unwrap();
709    if let Some(&m) = eng.ascent_cache.get(&key) {
710        return m;
711    }
712    let m = eng.resolve_vertical_metrics(font_family, font_weight, px);
713    eng.ascent_cache.insert(key, m);
714    m
715}
716
717/// Extract the family name from raw font bytes.
718///
719/// Tries the typographic family name first, falling back to the standard family name.
720/// Returns `None` if the font data is invalid or contains no names.
721pub fn font_family_name(bytes: &[u8]) -> Option<String> {
722    use skrifa::string::StringId;
723    let font = skrifa::FontRef::new(bytes).ok()?;
724    font.localized_strings(StringId::TYPOGRAPHIC_FAMILY_NAME)
725        .english_or_first()
726        .map(|s| s.to_string())
727        .or_else(|| {
728            font.localized_strings(StringId::FAMILY_NAME)
729                .english_or_first()
730                .map(|s| s.to_string())
731        })
732}
733
734fn key_from_pair(font_id: u64, glyph_id: u32) -> GlyphKey {
735    let mut h = RapidHasher::default();
736    font_id.hash(&mut h);
737    glyph_id.hash(&mut h);
738    GlyphKey(h.finish())
739}
740
741#[cfg(target_arch = "wasm32")]
742fn collect_unresolved_codepoints(layout: &parley::Layout<()>, text: &str) -> Vec<u32> {
743    use parley::layout::PositionedLayoutItem;
744    let mut out = Vec::new();
745    let mut total_glyphs: usize = 0;
746    for line in layout.lines() {
747        for item in line.items() {
748            let PositionedLayoutItem::GlyphRun(glyph_run) = item else {
749                continue;
750            };
751            let run = glyph_run.run();
752            // parley clusters already grouped by text; if glyph id==0 => missing
753            for cluster in run.clusters() {
754                let glyphs: Vec<_> = cluster.glyphs().collect();
755                total_glyphs += glyphs.len();
756                let has_missing = glyphs.iter().any(|g| g.id == 0);
757                if has_missing {
758                    let range = cluster.text_range();
759                    // slice may be invalid if out of bounds? clamp
760                    let end = range.end.min(text.len());
761                    let start = range.start.min(end);
762                    for ch in text[start..end].chars() {
763                        out.push(ch as u32);
764                    }
765                    // Fallback: if text_range empty but still missing, push replacement
766                    if range.start == range.end {
767                        // try to guess from glyph? skip
768                    }
769                }
770            }
771        }
772    }
773    if out.is_empty() && !text.is_empty() && total_glyphs == 0 {
774        if text.chars().any(|c| !c.is_whitespace()) {
775            for ch in text.chars() {
776                if !ch.is_whitespace() && ch != '\n' && ch != '\r' && ch != '\t' {
777                    out.push(ch as u32);
778                }
779            }
780        }
781    }
782    out
783}
784
785fn shape_line_inner(
786    eng: &mut Engine,
787    text: &str,
788    px: f32,
789    line_height_ratio: f32,
790    font_family: Option<&str>,
791    font_weight: u16,
792    font_style: u8,
793    letter_spacing: f32,
794    font_variation_settings: Option<&str>,
795) -> Vec<ShapedGlyph> {
796    use parley::FontWeight;
797    use parley::layout::PositionedLayoutItem;
798    use parley::style::StyleProperty;
799
800    let Engine {
801        ref mut font_cx,
802        ref mut layout_cx,
803        ..
804    } = *eng;
805    let mut builder = layout_cx.ranged_builder(font_cx, text, 1.0, true);
806    builder.push_default(StyleProperty::FontSize(px));
807    if line_height_ratio > 0.0 {
808        builder.push_default(StyleProperty::LineHeight(
809            parley::LineHeight::FontSizeRelative(line_height_ratio),
810        ));
811    }
812    builder.push_default(StyleProperty::FontWeight(FontWeight::new(
813        font_weight as f32,
814    )));
815    builder.push_default(StyleProperty::FontStyle(match font_style {
816        1 => parley::FontStyle::Italic,
817        _ => parley::FontStyle::Normal,
818    }));
819    builder.push_default(StyleProperty::LetterSpacing(letter_spacing));
820
821    if let Some(settings) = font_variation_settings {
822        builder.push_default(StyleProperty::FontVariations(
823            parley::style::FontVariations::from(settings),
824        ));
825    }
826
827    if let Some(family) = font_family {
828        #[cfg(not(target_arch = "wasm32"))]
829        {
830            use parley::style::{FontFamilyName, GenericFamily};
831            let names: &[FontFamilyName] = match family {
832                "monospace" => &[
833                    FontFamilyName::named("JetBrains Mono"),
834                    GenericFamily::Monospace.into(),
835                ],
836                "sans-serif" => &[
837                    FontFamilyName::named("Open Sans"),
838                    GenericFamily::SansSerif.into(),
839                ],
840                "emoji" => &[
841                    FontFamilyName::named("Noto Color Emoji"),
842                    GenericFamily::Emoji.into(),
843                ],
844                "serif" => &[GenericFamily::Serif.into()],
845                "cursive" => &[GenericFamily::Cursive.into()],
846                "fantasy" => &[GenericFamily::Fantasy.into()],
847                "system-ui" => &[GenericFamily::SystemUi.into()],
848                "math" => &[GenericFamily::Math.into()],
849                _ => &[FontFamilyName::named(family)],
850            };
851            builder.push(names, 0..text.len());
852        }
853        #[cfg(target_arch = "wasm32")]
854        {
855            use parley::style::{FontFamilyName, GenericFamily};
856            let names: &[FontFamilyName] = match family {
857                "monospace" => &[
858                    FontFamilyName::named("JetBrains Mono"),
859                    GenericFamily::Monospace.into(),
860                    GenericFamily::SansSerif.into(),
861                    FontFamilyName::named("Noto Sans Symbols 2"),
862                    FontFamilyName::named("Noto Sans Symbols2"),
863                    FontFamilyName::named("Noto Sans Symbols"),
864                ],
865                "sans-serif" => &[
866                    FontFamilyName::named("Open Sans"),
867                    GenericFamily::SansSerif.into(),
868                    GenericFamily::Emoji.into(),
869                    FontFamilyName::named("Noto Color Emoji"),
870                    FontFamilyName::named("Noto Sans Symbols 2"),
871                    FontFamilyName::named("Noto Sans Symbols2"),
872                    FontFamilyName::named("Noto Sans Symbols"),
873                ],
874                "emoji" => &[
875                    FontFamilyName::named("Noto Color Emoji"),
876                    GenericFamily::Emoji.into(),
877                    GenericFamily::SansSerif.into(),
878                    FontFamilyName::named("Noto Sans Symbols 2"),
879                ],
880                "serif" => &[
881                    GenericFamily::Serif.into(),
882                    GenericFamily::SansSerif.into(),
883                    FontFamilyName::named("Noto Sans Symbols 2"),
884                ],
885                "cursive" => &[
886                    GenericFamily::Cursive.into(),
887                    GenericFamily::SansSerif.into(),
888                    FontFamilyName::named("Noto Sans Symbols 2"),
889                ],
890                "fantasy" => &[
891                    GenericFamily::Fantasy.into(),
892                    GenericFamily::SansSerif.into(),
893                    FontFamilyName::named("Noto Sans Symbols 2"),
894                ],
895                "system-ui" => &[
896                    GenericFamily::SystemUi.into(),
897                    GenericFamily::SansSerif.into(),
898                    FontFamilyName::named("Noto Sans Symbols 2"),
899                ],
900                "math" => &[
901                    GenericFamily::Math.into(),
902                    GenericFamily::SansSerif.into(),
903                    FontFamilyName::named("Noto Sans Symbols 2"),
904                ],
905                _ => &[FontFamilyName::named(family)],
906            };
907            builder.push(names, 0..text.len());
908        }
909    } else {
910        #[cfg(target_arch = "wasm32")]
911        {
912            use parley::style::{FontFamilyName, GenericFamily};
913            let fallback: &[FontFamilyName] = &[
914                GenericFamily::SansSerif.into(),
915                GenericFamily::Emoji.into(),
916                FontFamilyName::named("Noto Color Emoji"),
917                FontFamilyName::named("Noto Sans Symbols 2"),
918                FontFamilyName::named("Noto Sans Symbols2"),
919                FontFamilyName::named("Noto Sans Symbols"),
920            ];
921            builder.push(fallback, 0..text.len());
922        }
923    }
924
925    let mut layout = builder.build(text);
926    layout.break_all_lines(None);
927    layout.align(
928        parley::Alignment::Start,
929        parley::AlignmentOptions::default(),
930    );
931
932    #[cfg(target_arch = "wasm32")]
933    {
934        let unresolved = collect_unresolved_codepoints(&layout, text);
935        // Filter out PUA (Material Symbols etc. E000-F8FF, F0000-FFFFD, 100000-10FFFD) - they are bundled via MaterialSymbolsOutlined.ttf, not Noto fallback
936        let unresolved: Vec<u32> = unresolved
937            .into_iter()
938            .filter(|cp| {
939                !((0xE000..=0xF8FF).contains(cp)
940                    || (0xF0000..=0xFFFFD).contains(cp)
941                    || (0x100000..=0x10FFFD).contains(cp))
942            })
943            .collect();
944        if !unresolved.is_empty() {
945            let reg = crate::unresolved::web_unresolved_registry();
946            let is_new = unresolved.iter().any(|cp| !reg.contains(*cp));
947            if is_new {
948                crate::fallback::wasm_fallback::ensure_fallback_initialized();
949                reg.add_unresolved_vec(unresolved);
950            }
951        }
952    }
953
954    let mut out: Vec<ShapedGlyph> = Vec::new();
955    for line in layout.lines() {
956        for item in line.items() {
957            let PositionedLayoutItem::GlyphRun(glyph_run) = item else {
958                continue;
959            };
960            let font_data = glyph_run.run().font();
961            let fid = eng.ensure_font(font_data);
962            log::debug!(
963                "[shape] run: fid={} font_data_len={}",
964                fid,
965                font_data.data.as_ref().len()
966            );
967            for g in glyph_run.positioned_glyphs() {
968                let gid = g.id;
969                let key = key_from_pair(fid, gid);
970                eng.key_map.insert(key, (fid, gid));
971
972                let (w, h, left, top) = eng
973                    .raster_placement(fid, gid, px)
974                    .unwrap_or((0.0, 0.0, 0.0, 0.0));
975
976                log::debug!(
977                    "[shape] glyph: gid={} px={} x={:.1} y={:.1} advance={:.1} bitmap={}x{} {}x{}",
978                    gid,
979                    px,
980                    g.x,
981                    g.y,
982                    g.advance,
983                    w,
984                    h,
985                    left,
986                    top,
987                );
988
989                out.push(ShapedGlyph {
990                    key,
991                    px,
992                    x: g.x,
993                    y: g.y,
994                    w,
995                    h,
996                    bearing_x: left,
997                    bearing_y: top,
998                    advance: g.advance + letter_spacing,
999                });
1000            }
1001        }
1002    }
1003    out
1004}
1005
1006pub fn shape_line(
1007    text: &str,
1008    px: f32,
1009    line_height_ratio: f32,
1010    font_family: Option<&str>,
1011    font_weight: u16,
1012    font_style: u8,
1013    letter_spacing: f32,
1014    font_variation_settings: Option<&str>,
1015) -> Vec<ShapedGlyph> {
1016    let mut eng = engine().lock().unwrap();
1017    shape_line_inner(
1018        &mut eng,
1019        text,
1020        px,
1021        line_height_ratio,
1022        font_family,
1023        font_weight,
1024        font_style,
1025        letter_spacing,
1026        font_variation_settings,
1027    )
1028}
1029
1030pub fn rasterize(key: GlyphKey, px: f32) -> Option<GlyphBitmap> {
1031    use swash::scale::{Render, Source, StrikeWith};
1032    let mut eng = engine().lock().unwrap();
1033    let &(fid, gid) = eng.key_map.get(&key)?;
1034    let cache_key = (fid, gid, px.to_bits());
1035    if let Some(cached) = eng.glyph_cache.get(&cache_key) {
1036        log::debug!(
1037            "[rasterize] HIT fid={} gid={} px={} => {}x{}",
1038            fid,
1039            gid,
1040            px,
1041            cached.0,
1042            cached.1
1043        );
1044        return Some(GlyphBitmap {
1045            key,
1046            w: cached.0,
1047            h: cached.1,
1048            content: cached.4,
1049            data: cached.5.clone(),
1050        });
1051    }
1052    let data_bytes = eng
1053        .font_registry
1054        .iter()
1055        .find(|r| r.id == fid)?
1056        .data_bytes
1057        .clone();
1058    let font = swash::FontRef::from_index(&data_bytes, 0)?;
1059    let mut scaler = eng.swash_cx.builder(font).size(px).hint(true).build();
1060    let image = Render::new(&[
1061        Source::Outline,
1062        Source::ColorBitmap(StrikeWith::BestFit),
1063        Source::ColorOutline(0),
1064    ])
1065    .render(&mut scaler, swash_glyph_id(fid, gid))?;
1066    log::debug!(
1067        "[rasterize] MISS fid={} gid={} px={} => {}x{}",
1068        fid,
1069        gid,
1070        px,
1071        image.placement.width,
1072        image.placement.height
1073    );
1074    let bitmap = GlyphBitmap {
1075        key,
1076        w: image.placement.width,
1077        h: image.placement.height,
1078        content: image.content,
1079        data: image.data,
1080    };
1081    eng.glyph_cache.insert(
1082        cache_key,
1083        (
1084            bitmap.w,
1085            bitmap.h,
1086            image.placement.left,
1087            image.placement.top,
1088            bitmap.content,
1089            bitmap.data.clone(),
1090        ),
1091    );
1092    eng.trim_glyph_cache();
1093    Some(bitmap)
1094}
1095
1096pub fn lookup_cache_key(key: GlyphKey, px: f32) -> Option<CacheKey> {
1097    let eng = engine().lock().unwrap();
1098    let &(fid, gid) = eng.key_map.get(&key)?;
1099    Some(CacheKey {
1100        font_id: fid,
1101        glyph_id: gid,
1102        font_size_bits: px.to_bits(),
1103    })
1104}
1105
1106fn extract_outlines_for(data_bytes: &[u8], glyph_id: u32) -> Option<Box<[Command]>> {
1107    let font = skrifa::FontRef::new(data_bytes).ok()?;
1108    let mut pen = OutlinePenCollector(Vec::new());
1109    font.outline_glyphs()
1110        .get(skrifa::GlyphId::new(glyph_id))?
1111        .draw(skrifa::instance::Size::new(1.0), &mut pen)
1112        .ok()?;
1113    Some(pen.0.into_boxed_slice())
1114}
1115
1116pub fn extract_outline_commands(cache_key: CacheKey) -> Option<Box<[Command]>> {
1117    let eng = engine().lock().unwrap();
1118    let record = eng
1119        .font_registry
1120        .iter()
1121        .find(|r| r.id == cache_key.font_id)?;
1122    extract_outlines_for(&record.data_bytes, cache_key.glyph_id)
1123}
1124
1125pub fn lookup_and_extract_outline(key: GlyphKey, px: f32) -> Option<(CacheKey, Box<[Command]>)> {
1126    let eng = engine().lock().unwrap();
1127    let &(fid, gid) = eng.key_map.get(&key)?;
1128    let record = eng.font_registry.iter().find(|r| r.id == fid)?;
1129    let ck = CacheKey {
1130        font_id: fid,
1131        glyph_id: gid,
1132        font_size_bits: px.to_bits(),
1133    };
1134    let cmds = extract_outlines_for(&record.data_bytes, gid)?;
1135    Some((ck, cmds))
1136}
1137
1138struct OutlinePenCollector(Vec<Command>);
1139
1140impl OutlinePen for OutlinePenCollector {
1141    fn move_to(&mut self, x: f32, y: f32) {
1142        self.0.push(Command::MoveTo(x, y));
1143    }
1144    fn line_to(&mut self, x: f32, y: f32) {
1145        self.0.push(Command::LineTo(x, y));
1146    }
1147    fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
1148        self.0.push(Command::QuadTo(cx0, cy0, x, y));
1149    }
1150    fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
1151        self.0.push(Command::CurveTo(cx0, cy0, cx1, cy1, x, y));
1152    }
1153    fn close(&mut self) {
1154        self.0.push(Command::Close);
1155    }
1156}
1157
1158#[derive(Clone)]
1159pub struct TextMetrics {
1160    pub positions: Vec<f32>,
1161    pub byte_offsets: Vec<usize>,
1162}
1163
1164pub fn metrics_for_textfield(
1165    text: &str,
1166    px: f32,
1167    font_family: Option<&str>,
1168    font_weight: u16,
1169    font_style: u8,
1170    letter_spacing: f32,
1171    font_variation_settings: Option<&str>,
1172) -> TextMetrics {
1173    let family_hash = font_family.map(fast_hash).unwrap_or(0);
1174    let fvs_hash = font_variation_settings.map(fast_hash).unwrap_or(0);
1175    let key = (
1176        fast_hash(text),
1177        (px * 100.0) as u32,
1178        family_hash,
1179        font_weight,
1180        font_style,
1181        (letter_spacing * 100.0) as i32,
1182        fvs_hash,
1183    );
1184    if let Some(m) = metrics_cache().lock().unwrap().get(&key).cloned() {
1185        return m;
1186    }
1187    let mut eng = engine().lock().unwrap();
1188
1189    use parley::FontWeight;
1190    use parley::style::StyleProperty;
1191
1192    let Engine {
1193        ref mut font_cx,
1194        ref mut layout_cx,
1195        ..
1196    } = *eng;
1197    let mut builder = layout_cx.ranged_builder(font_cx, text, 1.0, true);
1198    builder.push_default(StyleProperty::FontSize(px));
1199    builder.push_default(StyleProperty::FontWeight(FontWeight::new(
1200        font_weight as f32,
1201    )));
1202    builder.push_default(StyleProperty::FontStyle(match font_style {
1203        1 => parley::FontStyle::Italic,
1204        _ => parley::FontStyle::Normal,
1205    }));
1206    builder.push_default(StyleProperty::LetterSpacing(letter_spacing));
1207    if let Some(settings) = font_variation_settings {
1208        builder.push_default(StyleProperty::FontVariations(
1209            parley::style::FontVariations::from(settings),
1210        ));
1211    }
1212    if let Some(family) = font_family {
1213        #[cfg(not(target_arch = "wasm32"))]
1214        {
1215            use parley::style::{FontFamilyName, GenericFamily};
1216            let names: &[FontFamilyName] = match family {
1217                "monospace" => &[
1218                    FontFamilyName::named("JetBrains Mono"),
1219                    GenericFamily::Monospace.into(),
1220                ],
1221                "sans-serif" => &[
1222                    FontFamilyName::named("Open Sans"),
1223                    GenericFamily::SansSerif.into(),
1224                ],
1225                "emoji" => &[
1226                    FontFamilyName::named("Noto Color Emoji"),
1227                    GenericFamily::Emoji.into(),
1228                ],
1229                "serif" => &[GenericFamily::Serif.into()],
1230                "cursive" => &[GenericFamily::Cursive.into()],
1231                "fantasy" => &[GenericFamily::Fantasy.into()],
1232                "system-ui" => &[GenericFamily::SystemUi.into()],
1233                "math" => &[GenericFamily::Math.into()],
1234                _ => &[FontFamilyName::named(family)],
1235            };
1236            builder.push(names, 0..text.len());
1237        }
1238        #[cfg(target_arch = "wasm32")]
1239        {
1240            use parley::style::{FontFamilyName, GenericFamily};
1241            let names: &[FontFamilyName] = match family {
1242                "monospace" => &[
1243                    FontFamilyName::named("JetBrains Mono"),
1244                    GenericFamily::Monospace.into(),
1245                    GenericFamily::SansSerif.into(),
1246                    FontFamilyName::named("Noto Sans Symbols 2"),
1247                    FontFamilyName::named("Noto Sans Symbols2"),
1248                    FontFamilyName::named("Noto Sans Symbols"),
1249                ],
1250                "sans-serif" => &[
1251                    FontFamilyName::named("Open Sans"),
1252                    GenericFamily::SansSerif.into(),
1253                    GenericFamily::Emoji.into(),
1254                    FontFamilyName::named("Noto Color Emoji"),
1255                    FontFamilyName::named("Noto Sans Symbols 2"),
1256                    FontFamilyName::named("Noto Sans Symbols2"),
1257                    FontFamilyName::named("Noto Sans Symbols"),
1258                ],
1259                "emoji" => &[
1260                    FontFamilyName::named("Noto Color Emoji"),
1261                    GenericFamily::Emoji.into(),
1262                    GenericFamily::SansSerif.into(),
1263                    FontFamilyName::named("Noto Sans Symbols 2"),
1264                ],
1265                "serif" => &[
1266                    GenericFamily::Serif.into(),
1267                    GenericFamily::SansSerif.into(),
1268                    FontFamilyName::named("Noto Sans Symbols 2"),
1269                ],
1270                "cursive" => &[
1271                    GenericFamily::Cursive.into(),
1272                    GenericFamily::SansSerif.into(),
1273                    FontFamilyName::named("Noto Sans Symbols 2"),
1274                ],
1275                "fantasy" => &[
1276                    GenericFamily::Fantasy.into(),
1277                    GenericFamily::SansSerif.into(),
1278                    FontFamilyName::named("Noto Sans Symbols 2"),
1279                ],
1280                "system-ui" => &[
1281                    GenericFamily::SystemUi.into(),
1282                    GenericFamily::SansSerif.into(),
1283                    FontFamilyName::named("Noto Sans Symbols 2"),
1284                ],
1285                "math" => &[
1286                    GenericFamily::Math.into(),
1287                    GenericFamily::SansSerif.into(),
1288                    FontFamilyName::named("Noto Sans Symbols 2"),
1289                ],
1290                _ => &[FontFamilyName::named(family)],
1291            };
1292            builder.push(names, 0..text.len());
1293        }
1294    } else {
1295        #[cfg(target_arch = "wasm32")]
1296        {
1297            use parley::style::{FontFamilyName, GenericFamily};
1298            let fallback: &[FontFamilyName] = &[
1299                GenericFamily::SansSerif.into(),
1300                GenericFamily::Emoji.into(),
1301                FontFamilyName::named("Noto Color Emoji"),
1302                FontFamilyName::named("Noto Sans Symbols 2"),
1303                FontFamilyName::named("Noto Sans Symbols2"),
1304                FontFamilyName::named("Noto Sans Symbols"),
1305            ];
1306            builder.push(fallback, 0..text.len());
1307        }
1308    }
1309
1310    let mut layout = builder.build(text);
1311    layout.break_all_lines(None);
1312    layout.align(
1313        parley::Alignment::Start,
1314        parley::AlignmentOptions::default(),
1315    );
1316
1317    #[cfg(target_arch = "wasm32")]
1318    {
1319        let unresolved = collect_unresolved_codepoints(&layout, text);
1320        let unresolved: Vec<u32> = unresolved
1321            .into_iter()
1322            .filter(|cp| {
1323                !((0xE000..=0xF8FF).contains(cp)
1324                    || (0xF0000..=0xFFFFD).contains(cp)
1325                    || (0x100000..=0x10FFFD).contains(cp))
1326            })
1327            .collect();
1328        if !unresolved.is_empty() {
1329            let reg = crate::unresolved::web_unresolved_registry();
1330            let is_new = unresolved.iter().any(|cp| !reg.contains(*cp));
1331            if is_new {
1332                crate::fallback::wasm_fallback::ensure_fallback_initialized();
1333                reg.add_unresolved_vec(unresolved);
1334            }
1335        }
1336    }
1337
1338    let mut edges: Vec<(usize, f32)> = Vec::new();
1339    let mut last_x = 0.0f32;
1340    let mut glyph_idx = 0usize;
1341    for line in layout.lines() {
1342        for item in line.items() {
1343            let parley::layout::PositionedLayoutItem::GlyphRun(glyph_run) = item else {
1344                continue;
1345            };
1346            let run_offset = glyph_run.offset();
1347            let run = glyph_run.run();
1348            let mut cluster_offset = run_offset;
1349            for cluster in run.clusters() {
1350                let range = cluster.text_range();
1351                for g in cluster.glyphs() {
1352                    let shift = glyph_idx as f32 * letter_spacing;
1353                    let x_pos = cluster_offset + g.x;
1354                    let right = x_pos + shift + g.advance + letter_spacing;
1355                    last_x = right.max(last_x);
1356                    edges.push((range.end, right));
1357                    glyph_idx += 1;
1358                    cluster_offset += g.advance;
1359                }
1360            }
1361        }
1362    }
1363    if edges.last().map(|e| e.0) != Some(text.len()) {
1364        edges.push((text.len(), last_x));
1365    }
1366
1367    let mut positions = Vec::with_capacity(text.graphemes(true).count() + 1);
1368    let mut byte_offsets = Vec::with_capacity(positions.capacity());
1369    positions.push(0.0);
1370    byte_offsets.push(0);
1371    let mut last_byte = 0usize;
1372    for (b, _) in text.grapheme_indices(true) {
1373        // Boundary 0 is already covered by the initial entries above...
1374        if b == 0 {
1375            continue;
1376        }
1377        positions
1378            .push(positions.last().copied().unwrap_or(0.0) + width_between(&edges, last_byte, b));
1379        byte_offsets.push(b);
1380        last_byte = b;
1381    }
1382    if *byte_offsets.last().unwrap_or(&0) != text.len() {
1383        positions.push(
1384            positions.last().copied().unwrap_or(0.0) + width_between(&edges, last_byte, text.len()),
1385        );
1386        byte_offsets.push(text.len());
1387    }
1388    let m = TextMetrics {
1389        positions,
1390        byte_offsets,
1391    };
1392    metrics_cache().lock().unwrap().put(key, m.clone());
1393    m
1394}
1395
1396fn width_between(edges: &[(usize, f32)], start_b: usize, end_b: usize) -> f32 {
1397    let x0 = lookup_right(edges, start_b);
1398    let x1 = lookup_right(edges, end_b);
1399    (x1 - x0).max(0.0)
1400}
1401fn lookup_right(edges: &[(usize, f32)], b: usize) -> f32 {
1402    match edges.binary_search_by_key(&b, |e| e.0) {
1403        Ok(i) => edges[i].1,
1404        Err(i) => {
1405            if i == 0 {
1406                0.0
1407            } else {
1408                edges[i - 1].1
1409            }
1410        }
1411    }
1412}
1413
1414fn wrap_single_hard_line(
1415    text: &str,
1416    px: f32,
1417    max_width: f32,
1418    max_lines: Option<usize>,
1419    font_weight: u16,
1420    font_style: u8,
1421    letter_spacing: f32,
1422    font_variation_settings: Option<&str>,
1423) -> (Vec<String>, bool) {
1424    let soft_wrap = true;
1425    let max_lines_key: u16 = match max_lines {
1426        None => 0,
1427        Some(n) => {
1428            let n = n.min(u16::MAX as usize - 1) as u16;
1429            n.saturating_add(1)
1430        }
1431    };
1432    let fvs_hash = font_variation_settings.map(fast_hash).unwrap_or(0);
1433    let key = (
1434        fast_hash(text),
1435        (px * 100.0) as u32,
1436        (max_width * 100.0) as u32,
1437        max_lines_key,
1438        soft_wrap,
1439        font_weight,
1440        font_style,
1441        (letter_spacing * 100.0) as i32,
1442        fvs_hash,
1443    );
1444    if let Some(h) = wrap_cache().lock().unwrap().get(&key).cloned() {
1445        return h;
1446    }
1447
1448    let m = metrics_for_textfield(
1449        text,
1450        px,
1451        None,
1452        font_weight,
1453        font_style,
1454        letter_spacing,
1455        font_variation_settings,
1456    );
1457    if let Some(&last) = m.positions.last()
1458        && last <= max_width + 0.5
1459    {
1460        return (vec![text.to_string()], false);
1461    }
1462
1463    let width_of = |start_b: usize, end_b: usize| -> f32 {
1464        let i0 = match m.byte_offsets.binary_search(&start_b) {
1465            Ok(i) | Err(i) => i,
1466        };
1467        let i1 = match m.byte_offsets.binary_search(&end_b) {
1468            Ok(i) | Err(i) => i,
1469        };
1470        (m.positions.get(i1).copied().unwrap_or(0.0) - m.positions.get(i0).copied().unwrap_or(0.0))
1471            .max(0.0)
1472    };
1473
1474    let mut out: Vec<String> = Vec::new();
1475    let mut truncated = false;
1476
1477    let mut line_start = 0usize;
1478    let mut best_break = line_start;
1479
1480    for tok in text.split_word_bounds() {
1481        let tok_start = best_break;
1482        let tok_end = tok_start + tok.len();
1483        let w = width_of(line_start, tok_end);
1484
1485        if w <= max_width + 0.5 {
1486            best_break = tok_end;
1487            continue;
1488        }
1489
1490        if best_break > line_start {
1491            out.push(text[line_start..best_break].trim_end().to_string());
1492            line_start = best_break;
1493        } else {
1494            let mut cut = tok_start;
1495            for g in tok.grapheme_indices(true) {
1496                let next = tok_start + g.0 + g.1.len();
1497                if width_of(line_start, next) <= max_width + 0.5 {
1498                    cut = next;
1499                } else {
1500                    break;
1501                }
1502            }
1503            if cut == line_start
1504                && let Some((ofs, grapheme)) = tok.grapheme_indices(true).next()
1505            {
1506                cut = tok_start + ofs + grapheme.len();
1507            }
1508            out.push(text[line_start..cut].to_string());
1509            line_start = cut;
1510        }
1511
1512        if let Some(ml) = max_lines
1513            && out.len() >= ml
1514        {
1515            truncated = true;
1516            line_start = line_start.min(text.len());
1517            break;
1518        }
1519
1520        best_break = line_start;
1521
1522        if line_start < tok_end && width_of(line_start, tok_end) <= max_width + 0.5 {
1523            best_break = tok_end;
1524        }
1525    }
1526
1527    if line_start < text.len() && max_lines.is_none_or(|ml| out.len() < ml) {
1528        out.push(text[line_start..].trim_end().to_string());
1529    }
1530
1531    let res = (out, truncated);
1532
1533    wrap_cache().lock().unwrap().put(key, res.clone());
1534    res
1535}
1536
1537pub fn wrap_lines(
1538    text: &str,
1539    px: f32,
1540    max_width: f32,
1541    max_lines: Option<usize>,
1542    soft_wrap: bool,
1543    font_weight: u16,
1544    font_style: u8,
1545    letter_spacing: f32,
1546    font_variation_settings: Option<&str>,
1547) -> (Vec<String>, bool) {
1548    if text.is_empty() || max_width <= 0.0 {
1549        return (vec![String::new()], false);
1550    }
1551    if !soft_wrap {
1552        return (text.split('\n').map(|s| s.to_string()).collect(), false);
1553    }
1554
1555    let mut combined: Vec<String> = Vec::new();
1556    let mut any_truncated = false;
1557    for (seg_i, seg) in text.split('\n').enumerate() {
1558        let remaining = max_lines.map(|ml| ml.saturating_sub(combined.len()));
1559        if max_lines.is_some_and(|ml| combined.len() >= ml) {
1560            any_truncated = true;
1561            break;
1562        }
1563        let (mut lines, trunc) = wrap_single_hard_line(
1564            seg,
1565            px,
1566            max_width,
1567            remaining,
1568            font_weight,
1569            font_style,
1570            letter_spacing,
1571            font_variation_settings,
1572        );
1573        if lines.is_empty() {
1574            lines.push(String::new());
1575        }
1576        combined.append(&mut lines);
1577        any_truncated |= trunc;
1578        let _ = seg_i;
1579    }
1580    return (combined, any_truncated);
1581}
1582
1583pub fn wrap_line_ranges(
1584    text: &str,
1585    px: f32,
1586    max_width: f32,
1587    max_lines: Option<usize>,
1588    soft_wrap: bool,
1589    font_weight: u16,
1590    font_style: u8,
1591    letter_spacing: f32,
1592    font_variation_settings: Option<&str>,
1593) -> (Vec<(usize, usize)>, bool) {
1594    if text.is_empty() || max_width <= 0.0 {
1595        return (vec![(0, 0)], false);
1596    }
1597    if !soft_wrap {
1598        let mut out = Vec::new();
1599        let mut start = 0usize;
1600        for (i, ch) in text.char_indices() {
1601            if ch == '\n' {
1602                out.push((start, i));
1603                start = i + 1;
1604            }
1605        }
1606        out.push((start, text.len()));
1607        return (out, false);
1608    }
1609
1610    let max_lines_key: u16 = match max_lines {
1611        None => 0,
1612        Some(n) => {
1613            let n = n.min(u16::MAX as usize - 1) as u16;
1614            n.saturating_add(1)
1615        }
1616    };
1617    let fvs_hash = font_variation_settings.map(fast_hash).unwrap_or(0);
1618    let key = (
1619        fast_hash(text),
1620        (px * 100.0) as u32,
1621        (max_width * 100.0) as u32,
1622        max_lines_key,
1623        soft_wrap,
1624        font_weight,
1625        font_style,
1626        (letter_spacing * 100.0) as i32,
1627        fvs_hash,
1628    );
1629    if let Some(v) = wrap_ranges_cache().lock().unwrap().get(&key).cloned() {
1630        return v;
1631    }
1632
1633    let m = metrics_for_textfield(
1634        text,
1635        px,
1636        None,
1637        font_weight,
1638        font_style,
1639        letter_spacing,
1640        font_variation_settings,
1641    );
1642
1643    let width_of = |start_b: usize, end_b: usize| -> f32 {
1644        let i0 = match m.byte_offsets.binary_search(&start_b) {
1645            Ok(i) | Err(i) => i,
1646        };
1647        let i1 = match m.byte_offsets.binary_search(&end_b) {
1648            Ok(i) | Err(i) => i,
1649        };
1650        (m.positions.get(i1).copied().unwrap_or(0.0) - m.positions.get(i0).copied().unwrap_or(0.0))
1651            .max(0.0)
1652    };
1653
1654    let mut out: Vec<(usize, usize)> = Vec::new();
1655    let mut truncated = false;
1656
1657    let mut line0_start = 0usize;
1658    for (i, ch) in text.char_indices() {
1659        if ch == '\n' {
1660            let (mut ranges, tr) = wrap_one_hard_line_ranges(
1661                text,
1662                line0_start,
1663                i,
1664                max_width,
1665                max_lines.map(|ml| ml.saturating_sub(out.len())),
1666                &width_of,
1667            );
1668            out.append(&mut ranges);
1669            if tr {
1670                truncated = true;
1671                break;
1672            }
1673            line0_start = i + 1;
1674
1675            if let Some(ml) = max_lines
1676                && out.len() >= ml
1677            {
1678                truncated = true;
1679                break;
1680            }
1681        }
1682    }
1683    if !truncated {
1684        let (mut ranges, tr) = wrap_one_hard_line_ranges(
1685            text,
1686            line0_start,
1687            text.len(),
1688            max_width,
1689            max_lines.map(|ml| ml.saturating_sub(out.len())),
1690            &width_of,
1691        );
1692        out.append(&mut ranges);
1693        truncated = tr;
1694    }
1695
1696    if out.is_empty() {
1697        out.push((0, 0));
1698    }
1699
1700    let res = (out, truncated);
1701    wrap_ranges_cache().lock().unwrap().put(key, res.clone());
1702    res
1703}
1704
1705fn wrap_one_hard_line_ranges(
1706    text: &str,
1707    start: usize,
1708    end: usize,
1709    max_width: f32,
1710    max_lines: Option<usize>,
1711    width_of: &dyn Fn(usize, usize) -> f32,
1712) -> (Vec<(usize, usize)>, bool) {
1713    let mut out = Vec::new();
1714    let mut t = false;
1715
1716    if start >= end {
1717        out.push((start, start));
1718        return (out, false);
1719    }
1720
1721    if width_of(start, end) <= max_width + 0.5 {
1722        out.push((start, end));
1723        return (out, false);
1724    }
1725
1726    let mut line_start = start;
1727    let mut best_break = line_start;
1728    let mut unconsumed_start = start;
1729
1730    for tok in text[line_start..end].split_word_bounds() {
1731        let tok_abs_start = unconsumed_start;
1732        let tok_abs_end = tok_abs_start + tok.len();
1733        unconsumed_start = tok_abs_end;
1734
1735        let w = width_of(line_start, tok_abs_end);
1736        if w <= max_width + 0.5 {
1737            best_break = tok_abs_end;
1738            continue;
1739        }
1740
1741        if best_break > line_start {
1742            out.push((line_start, best_break));
1743            line_start = best_break;
1744        } else {
1745            let mut cut = tok_abs_start;
1746            for (ofs, g) in tok.grapheme_indices(true) {
1747                let next = tok_abs_start + ofs + g.len();
1748                if width_of(line_start, next) <= max_width + 0.5 {
1749                    cut = next;
1750                } else {
1751                    break;
1752                }
1753            }
1754            if cut == line_start
1755                && let Some((ofs, gr)) = tok.grapheme_indices(true).next()
1756            {
1757                cut = tok_abs_start + ofs + gr.len();
1758            }
1759            out.push((line_start, cut));
1760            line_start = cut;
1761        }
1762
1763        if let Some(ml) = max_lines
1764            && out.len() >= ml
1765        {
1766            t = true;
1767            break;
1768        }
1769
1770        best_break = line_start;
1771    }
1772
1773    if !t && line_start < end && max_lines.is_none_or(|ml| out.len() < ml) {
1774        out.push((line_start, end));
1775    }
1776
1777    (out, t)
1778}
1779
1780pub fn ellipsize_line(
1781    text: &str,
1782    px: f32,
1783    max_width: f32,
1784    font_weight: u16,
1785    font_style: u8,
1786    letter_spacing: f32,
1787    font_variation_settings: Option<&str>,
1788) -> String {
1789    if text.is_empty() || max_width <= 0.0 {
1790        return String::new();
1791    }
1792    let fvs_hash = font_variation_settings.map(fast_hash).unwrap_or(0);
1793    let key = (
1794        fast_hash(text),
1795        (px * 100.0) as u32,
1796        (max_width * 100.0) as u32,
1797        font_weight,
1798        font_style,
1799        (letter_spacing * 100.0) as i32,
1800        fvs_hash,
1801    );
1802    if let Some(s) = ellip_cache().lock().unwrap().get(&key).cloned() {
1803        return s;
1804    }
1805    let m = metrics_for_textfield(
1806        text,
1807        px,
1808        None,
1809        font_weight,
1810        font_style,
1811        letter_spacing,
1812        font_variation_settings,
1813    );
1814    if let Some(&last) = m.positions.last()
1815        && last <= max_width + 0.5
1816    {
1817        return text.to_string();
1818    }
1819    let _el = "…";
1820    let e_w = ellipsis_width(px, letter_spacing);
1821    if e_w >= max_width {
1822        return String::new();
1823    }
1824    let mut cut_i = 0usize;
1825    for i in 0..m.positions.len() {
1826        if m.positions[i] + e_w <= max_width {
1827            cut_i = i;
1828        } else {
1829            break;
1830        }
1831    }
1832    let byte = m
1833        .byte_offsets
1834        .get(cut_i)
1835        .copied()
1836        .unwrap_or(0)
1837        .min(text.len());
1838    let mut out = String::with_capacity(byte + 3);
1839    out.push_str(&text[..byte]);
1840    out.push('…');
1841
1842    let s = out;
1843    ellip_cache().lock().unwrap().put(key, s.clone());
1844
1845    s
1846}
1847
1848fn ellipsis_width(px: f32, letter_spacing: f32) -> f32 {
1849    static ELLIP_W_LRU: OnceLock<Mutex<Lru<(u32, i32), f32>>> = OnceLock::new();
1850    /// Bump when the fallback font set changes: the width cache is keyed only
1851    /// by size, so without a generation tag it would serve pre-fallback widths
1852    /// forever. `clear_caches_for_fallback` can't reach this function-local
1853    /// static, so the generation check below does the invalidation instead.
1854    static ELLIP_W_GEN: AtomicU64 = AtomicU64::new(0);
1855    let cache = ELLIP_W_LRU.get_or_init(|| Mutex::new(Lru::new(64)));
1856    let generation = font_generation();
1857    if ELLIP_W_GEN.load(Ordering::Relaxed) != generation {
1858        if let Ok(mut g) = cache.lock() {
1859            g.clear_both();
1860        }
1861        ELLIP_W_GEN.store(generation, Ordering::Relaxed);
1862    }
1863    let key = ((px * 100.0) as u32, (letter_spacing * 100.0) as i32);
1864    if let Some(w) = cache.lock().unwrap().get(&key).copied() {
1865        return w;
1866    }
1867    let w = if let Some(g) =
1868        crate::shape_line("…", px, px, None, 400, 0, letter_spacing, None).last()
1869    {
1870        g.x + g.advance
1871    } else {
1872        0.0
1873    };
1874    cache.lock().unwrap().put(key, w);
1875    w
1876}