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