Skip to main content

jag_draw/
text.rs

1//! Text rendering providers for jag-draw.
2//!
3//! The primary provider is [`JagTextProvider`] which uses:
4//! - `harfrust` for text shaping (HarfBuzz implementation)
5//! - `swash` for glyph rasterization
6//! - `fontdb` for font discovery and fallback
7//!
8//! This provides high-quality text rendering with:
9//! - Proper kerning and ligatures
10//! - Subpixel RGB rendering
11//! - BiDi support
12//! - Complex script support
13//!
14//! # Example
15//! ```no_run
16//! use jag_draw::{
17//!     JagTextProvider, SubpixelOrientation, TextRun, ColorLinPremul, TextProvider, FontStyle,
18//! };
19//!
20//! let provider = JagTextProvider::from_system_fonts(SubpixelOrientation::RGB)
21//!     .expect("Failed to load fonts");
22//!
23//! let run = TextRun {
24//!     text: "Hello, world!".to_string(),
25//!     pos: [0.0, 0.0],
26//!     size: 16.0,
27//!     color: ColorLinPremul::rgba(255, 255, 255, 255),
28//!     weight: 400.0,
29//!     style: FontStyle::Normal,
30//!     family: None,
31//! };
32//!
33//! let glyphs = provider.rasterize_run(&run);
34//! ```
35
36use std::hash::Hash;
37
38/// LCD subpixel orientation along X axis.
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40pub enum SubpixelOrientation {
41    RGB,
42    BGR,
43}
44
45/// Storage format for a subpixel coverage mask.
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub enum MaskFormat {
48    Rgba8,
49    Rgba16,
50}
51
52/// Subpixel mask in RGB coverage format stored in RGBA (A is unused).
53/// Supports 8-bit or 16-bit per-channel storage.
54#[derive(Clone, Debug)]
55pub struct SubpixelMask {
56    pub width: u32,
57    pub height: u32,
58    pub format: MaskFormat,
59    /// Pixel data, row-major. For Rgba8, 4 bytes/pixel. For Rgba16, 8 bytes/pixel (little-endian u16s).
60    pub data: Vec<u8>,
61}
62
63impl SubpixelMask {
64    pub fn bytes_per_pixel(&self) -> usize {
65        match self.format {
66            MaskFormat::Rgba8 => 4,
67            MaskFormat::Rgba16 => 8,
68        }
69    }
70}
71
72/// Color emoji mask in full RGBA format (premultiplied alpha).
73/// Used for color emoji glyphs that have embedded color information.
74#[derive(Clone, Debug)]
75pub struct ColorMask {
76    pub width: u32,
77    pub height: u32,
78    /// RGBA8 pixel data, row-major, premultiplied alpha.
79    pub data: Vec<u8>,
80}
81
82impl ColorMask {
83    pub fn bytes_per_pixel(&self) -> usize {
84        4
85    }
86}
87
88/// Glyph mask that can be either subpixel (for text) or color (for emoji).
89#[derive(Clone, Debug)]
90pub enum GlyphMask {
91    /// RGB subpixel coverage mask for regular text rendering
92    Subpixel(SubpixelMask),
93    /// Full RGBA color mask for color emoji
94    Color(ColorMask),
95}
96
97impl GlyphMask {
98    pub fn width(&self) -> u32 {
99        match self {
100            GlyphMask::Subpixel(m) => m.width,
101            GlyphMask::Color(m) => m.width,
102        }
103    }
104
105    pub fn height(&self) -> u32 {
106        match self {
107            GlyphMask::Subpixel(m) => m.height,
108            GlyphMask::Color(m) => m.height,
109        }
110    }
111
112    pub fn is_color(&self) -> bool {
113        matches!(self, GlyphMask::Color(_))
114    }
115}
116
117/// GPU-ready batch of glyph masks with positions and color.
118/// This is the canonical representation used when sending text to the GPU.
119#[derive(Clone, Debug)]
120pub struct GlyphBatch {
121    pub glyphs: Vec<(SubpixelMask, [f32; 2], crate::scene::ColorLinPremul)>,
122}
123
124impl GlyphBatch {
125    pub fn new() -> Self {
126        Self { glyphs: Vec::new() }
127    }
128
129    pub fn with_capacity(cap: usize) -> Self {
130        Self {
131            glyphs: Vec::with_capacity(cap),
132        }
133    }
134
135    pub fn is_empty(&self) -> bool {
136        self.glyphs.is_empty()
137    }
138
139    pub fn len(&self) -> usize {
140        self.glyphs.len()
141    }
142}
143
144/// Simple global cache for glyph runs keyed by (text, size, weight, style,
145/// family, provider pointer). Used by direct text rendering paths (e.g.,
146/// jag-surface Canvas) to avoid re-shaping and re-rasterizing identical
147/// text on every frame.
148#[derive(Hash, Eq, PartialEq, Clone, Debug)]
149struct GlyphRunKey {
150    text_hash: u64,
151    size_bits: u32,
152    weight_bits: u32,
153    style_bits: u8,
154    family_hash: u64,
155    provider_id: usize,
156}
157
158struct GlyphRunCache {
159    map: std::sync::Mutex<
160        std::collections::HashMap<GlyphRunKey, std::sync::Arc<Vec<RasterizedGlyph>>>,
161    >,
162    max_entries: usize,
163}
164
165impl GlyphRunCache {
166    fn new(max_entries: usize) -> Self {
167        Self {
168            map: std::sync::Mutex::new(std::collections::HashMap::new()),
169            max_entries: max_entries.max(1),
170        }
171    }
172
173    fn get(&self, key: &GlyphRunKey) -> Option<std::sync::Arc<Vec<RasterizedGlyph>>> {
174        let map = self.map.lock().unwrap();
175        map.get(key).cloned()
176    }
177
178    fn insert(
179        &self,
180        key: GlyphRunKey,
181        glyphs: Vec<RasterizedGlyph>,
182    ) -> std::sync::Arc<Vec<RasterizedGlyph>> {
183        let mut map = self.map.lock().unwrap();
184
185        // Simple eviction strategy to keep memory bounded:
186        // when we grow past 2x capacity with new keys, clear everything.
187        if map.len() >= self.max_entries * 2 && !map.contains_key(&key) {
188            map.clear();
189        }
190
191        if let Some(existing) = map.get(&key) {
192            return existing.clone();
193        }
194
195        let arc = std::sync::Arc::new(glyphs);
196        map.insert(key, arc.clone());
197        arc
198    }
199
200    fn clear(&self) {
201        self.map.lock().unwrap().clear();
202    }
203}
204
205static GLYPH_RUN_CACHE: std::sync::OnceLock<GlyphRunCache> = std::sync::OnceLock::new();
206
207fn global_glyph_run_cache() -> &'static GlyphRunCache {
208    GLYPH_RUN_CACHE.get_or_init(|| GlyphRunCache::new(2048))
209}
210
211/// Invalidate all cached glyph rasterizations.
212///
213/// Must be called after web fonts are registered so that text re-renders
214/// using the newly available font faces instead of stale cached bitmaps.
215pub fn invalidate_glyph_run_cache() {
216    global_glyph_run_cache().clear();
217}
218
219/// Convert an 8-bit grayscale coverage mask to an RGB subpixel mask.
220/// Uses a gentle subpixel shift for improved clarity on small text.
221pub fn grayscale_to_subpixel_rgb(
222    width: u32,
223    height: u32,
224    gray: &[u8],
225    orientation: SubpixelOrientation,
226) -> SubpixelMask {
227    let w = width as usize;
228    let h = height as usize;
229    assert_eq!(gray.len(), w * h);
230    let mut out = vec![0u8; w * h * 4];
231
232    // Gentle subpixel rendering: slight horizontal shift per channel
233    // Much lighter than the original 3-tap kernel to avoid blurring
234    for y in 0..h {
235        for x in 0..w {
236            let c0 = gray[y * w + x] as f32 / 255.0;
237            let cl = if x > 0 {
238                gray[y * w + (x - 1)] as f32 / 255.0
239            } else {
240                c0
241            };
242            let cr = if x + 1 < w {
243                gray[y * w + (x + 1)] as f32 / 255.0
244            } else {
245                c0
246            };
247
248            // Very light blending (10% neighbor influence instead of 33%)
249            let sample_left = 0.9 * c0 + 0.1 * cl;
250            let sample_center = c0;
251            let sample_right = 0.9 * c0 + 0.1 * cr;
252
253            let (r_cov, g_cov, b_cov) = match orientation {
254                SubpixelOrientation::RGB => (sample_left, sample_center, sample_right),
255                SubpixelOrientation::BGR => (sample_right, sample_center, sample_left),
256            };
257
258            let i = (y * w + x) * 4;
259            out[i + 0] = (r_cov * 255.0 + 0.5) as u8;
260            out[i + 1] = (g_cov * 255.0 + 0.5) as u8;
261            out[i + 2] = (b_cov * 255.0 + 0.5) as u8;
262            out[i + 3] = 0u8; // alpha unused; output premul alpha computed in shader
263        }
264    }
265    SubpixelMask {
266        width,
267        height,
268        format: MaskFormat::Rgba8,
269        data: out,
270    }
271}
272
273/// Convert an 8-bit grayscale coverage mask to an RGB mask with equal channels (grayscale AA).
274pub fn grayscale_to_rgb_equal(width: u32, height: u32, gray: &[u8]) -> SubpixelMask {
275    let w = width as usize;
276    let h = height as usize;
277    assert_eq!(gray.len(), w * h);
278    let mut out = vec![0u8; w * h * 4];
279    for y in 0..h {
280        for x in 0..w {
281            let g = gray[y * w + x];
282            let i = (y * w + x) * 4;
283            out[i + 0] = g;
284            out[i + 1] = g;
285            out[i + 2] = g;
286            out[i + 3] = 0u8;
287        }
288    }
289    SubpixelMask {
290        width,
291        height,
292        format: MaskFormat::Rgba8,
293        data: out,
294    }
295}
296
297/// 16-bit variants for higher precision masks. Channels are u16 in [0..65535],
298/// packed little-endian into the data buffer. Alpha is unused.
299pub fn grayscale_to_subpixel_rgb16(
300    width: u32,
301    height: u32,
302    gray: &[u8],
303    orientation: SubpixelOrientation,
304) -> SubpixelMask {
305    let w = width as usize;
306    let h = height as usize;
307    assert_eq!(gray.len(), w * h);
308    let mut out = vec![0u8; w * h * 8];
309    for y in 0..h {
310        for x in 0..w {
311            let c0 = gray[y * w + x] as f32 / 255.0;
312            let cl = if x > 0 {
313                gray[y * w + (x - 1)] as f32 / 255.0
314            } else {
315                c0
316            };
317            let cr = if x + 1 < w {
318                gray[y * w + (x + 1)] as f32 / 255.0
319            } else {
320                c0
321            };
322            let sample_left = (2.0 / 3.0) * c0 + (1.0 / 3.0) * cl;
323            let sample_center = c0;
324            let sample_right = (2.0 / 3.0) * c0 + (1.0 / 3.0) * cr;
325            let (r_cov, g_cov, b_cov) = match orientation {
326                SubpixelOrientation::RGB => (sample_left, sample_center, sample_right),
327                SubpixelOrientation::BGR => (sample_right, sample_center, sample_left),
328            };
329            let (r, g, b) = match orientation {
330                SubpixelOrientation::RGB => (r_cov, g_cov, b_cov),
331                SubpixelOrientation::BGR => (b_cov, g_cov, r_cov),
332            };
333            let i = (y * w + x) * 8;
334            let write_u16 = |buf: &mut [u8], idx: usize, v: u16| {
335                let b = v.to_le_bytes();
336                buf[idx] = b[0];
337                buf[idx + 1] = b[1];
338            };
339            write_u16(&mut out, i + 0, (r * 65535.0 + 0.5) as u16);
340            write_u16(&mut out, i + 2, (g * 65535.0 + 0.5) as u16);
341            write_u16(&mut out, i + 4, (b * 65535.0 + 0.5) as u16);
342            write_u16(&mut out, i + 6, 0u16);
343        }
344    }
345    SubpixelMask {
346        width,
347        height,
348        format: MaskFormat::Rgba16,
349        data: out,
350    }
351}
352
353pub fn grayscale_to_rgb_equal16(width: u32, height: u32, gray: &[u8]) -> SubpixelMask {
354    let w = width as usize;
355    let h = height as usize;
356    assert_eq!(gray.len(), w * h);
357    let mut out = vec![0u8; w * h * 8];
358    for y in 0..h {
359        for x in 0..w {
360            let g = (gray[y * w + x] as u16) * 257; // 255->65535 scale
361            let i = (y * w + x) * 8;
362            let b = g.to_le_bytes();
363            out[i + 0] = b[0];
364            out[i + 1] = b[1];
365            out[i + 2] = b[0];
366            out[i + 3] = b[1];
367            out[i + 4] = b[0];
368            out[i + 5] = b[1];
369            out[i + 6] = 0;
370            out[i + 7] = 0;
371        }
372    }
373    SubpixelMask {
374        width,
375        height,
376        format: MaskFormat::Rgba16,
377        data: out,
378    }
379}
380
381// Optional provider that consumes a patched fontdue fork emitting RGB masks directly.
382// Behind a feature flag so it doesn't affect default builds.
383#[cfg(feature = "fontdue-rgb-patch")]
384pub struct PatchedFontdueProvider {
385    font: fontdue_rgb::Font,
386}
387
388#[cfg(feature = "fontdue-rgb-patch")]
389impl PatchedFontdueProvider {
390    pub fn from_bytes(bytes: &[u8]) -> anyhow::Result<Self> {
391        let font = fontdue_rgb::Font::from_bytes(bytes, fontdue_rgb::FontSettings::default())?;
392        Ok(Self { font })
393    }
394}
395
396#[cfg(feature = "fontdue-rgb-patch")]
397impl TextProvider for PatchedFontdueProvider {
398    fn rasterize_run(&self, run: &crate::scene::TextRun) -> Vec<RasterizedGlyph> {
399        use fontdue_rgb::layout::{CoordinateSystem, Layout, LayoutSettings, TextStyle};
400        let mut layout = Layout::new(CoordinateSystem::PositiveYDown);
401        layout.reset(&LayoutSettings {
402            x: 0.0,
403            y: 0.0,
404            ..LayoutSettings::default()
405        });
406        layout.append(
407            &[&self.font],
408            &TextStyle::new(&run.text, run.size.max(1.0), 0),
409        );
410        let mut out = Vec::new();
411        for g in layout.glyphs() {
412            // Patched fontdue returns RGB masks directly (u8 or u16). Prefer 16-bit when available.
413            let mask = if let Some((w, h, data16)) = self
414                .font
415                .rasterize_rgb16_indexed(g.key.glyph_index, g.key.px)
416            {
417                GlyphMask::Subpixel(SubpixelMask {
418                    width: w as u32,
419                    height: h as u32,
420                    format: MaskFormat::Rgba16,
421                    data: data16,
422                })
423            } else {
424                let (w, h, data8) = self
425                    .font
426                    .rasterize_rgb8_indexed(g.key.glyph_index, g.key.px);
427                GlyphMask::Subpixel(SubpixelMask {
428                    width: w as u32,
429                    height: h as u32,
430                    format: MaskFormat::Rgba8,
431                    data: data8,
432                })
433            };
434            out.push(RasterizedGlyph {
435                offset: [g.x, g.y],
436                mask,
437            });
438        }
439        out
440    }
441}
442
443/// A glyph with its top-left offset relative to the run origin and a mask (subpixel or color).
444#[derive(Clone, Debug)]
445pub struct RasterizedGlyph {
446    pub offset: [f32; 2],
447    pub mask: GlyphMask,
448}
449
450/// Minimal shaped glyph information for paragraph-level wrapping.
451#[derive(Clone, Debug)]
452pub struct ShapedGlyph {
453    /// Glyph's starting UTF-8 byte index in the source text (Harfbuzz cluster).
454    pub cluster: u32,
455    /// Advance width in pixels.
456    pub x_advance: f32,
457}
458
459/// Shaped paragraph representation for efficient wrapping.
460#[derive(Clone, Debug)]
461pub struct ShapedParagraph {
462    pub glyphs: Vec<ShapedGlyph>,
463}
464
465/// Text provider interface. Implementations convert a `TextRun` into positioned glyph masks.
466pub trait TextProvider: Send + Sync {
467    fn rasterize_run(&self, run: &crate::scene::TextRun) -> Vec<RasterizedGlyph>;
468
469    /// Optional paragraph shaping hook for advanced wrappers.
470    ///
471    /// Implementors that can expose Harfbuzz/cosmic-text shaping results should
472    /// return glyphs with cluster indices and advances. The default implementation
473    /// returns `None`, in which case callers must fall back to approximate methods.
474    fn shape_paragraph(&self, _text: &str, _px: f32) -> Option<ShapedParagraph> {
475        None
476    }
477
478    /// Optional cache tag to distinguish providers in text caches.
479    /// The default implementation returns 0, which is sufficient when
480    /// a single provider is used with a given PassManager.
481    fn cache_tag(&self) -> u64 {
482        0
483    }
484
485    fn line_metrics(&self, px: f32) -> Option<LineMetrics> {
486        let _ = px;
487        None
488    }
489
490    /// Measure the total advance width of a styled text run (in the same pixel
491    /// units as `run.size`).  The default delegates to `shape_paragraph`,
492    /// ignoring weight/style/family.  Providers that support multiple font faces
493    /// should override this to select the correct face.
494    fn measure_run(&self, run: &crate::scene::TextRun) -> f32 {
495        if let Some(shaped) = self.shape_paragraph(&run.text, run.size) {
496            shaped
497                .glyphs
498                .iter()
499                .map(|g| g.x_advance)
500                .sum::<f32>()
501                .max(0.0)
502        } else {
503            run.text.chars().count() as f32 * run.size * 0.55
504        }
505    }
506
507    /// Register a web font from raw TTF/OTF bytes.
508    /// Returns `Ok(true)` if newly registered, `Ok(false)` if already present.
509    /// Default implementation returns `Ok(false)` (no-op).
510    fn register_web_font(
511        &self,
512        _family: &str,
513        _data: Vec<u8>,
514        _weight: u16,
515        _style: crate::scene::FontStyle,
516    ) -> anyhow::Result<bool> {
517        Ok(false)
518    }
519}
520
521/// Rasterize a text run using a global glyph-run cache.
522///
523/// This is intended for direct text rendering paths that repeatedly render the
524/// same text (e.g., during scrolling) and want to avoid re-shaping and
525/// re-rasterizing glyphs every frame. The cache key is based on:
526/// - text contents
527/// - run size in pixels
528/// - the concrete text provider instance
529pub fn rasterize_run_cached(
530    provider: &dyn TextProvider,
531    run: &crate::scene::TextRun,
532) -> std::sync::Arc<Vec<RasterizedGlyph>> {
533    use crate::scene::FontStyle as SceneFontStyle;
534    use std::collections::hash_map::DefaultHasher;
535    use std::hash::Hasher;
536
537    let mut hasher = DefaultHasher::new();
538    run.text.hash(&mut hasher);
539    let text_hash = hasher.finish();
540
541    // Encode style and family so that bold/italic/monospace runs don't
542    // collide in the cache with regular text of the same contents.
543    let style_bits: u8 = match run.style {
544        SceneFontStyle::Normal => 0,
545        SceneFontStyle::Italic => 1,
546        SceneFontStyle::Oblique => 2,
547    };
548
549    let family_hash: u64 = if let Some(ref family) = run.family {
550        let mut fh = DefaultHasher::new();
551        family.hash(&mut fh);
552        fh.finish()
553    } else {
554        0
555    };
556    let size_bits = run.size.to_bits();
557    let weight_bits = run.weight.to_bits();
558    // Use the concrete provider data pointer as a stable identifier for this run.
559    let provider_id = (provider as *const dyn TextProvider as *const ()) as usize;
560    let key = GlyphRunKey {
561        text_hash,
562        size_bits,
563        weight_bits,
564        style_bits,
565        family_hash,
566        provider_id,
567    };
568
569    let cache = global_glyph_run_cache();
570    if let Some(hit) = cache.get(&key) {
571        return hit;
572    }
573
574    let glyphs = provider.rasterize_run(run);
575    cache.insert(key, glyphs)
576}
577
578/// LEGACY: Simple fontdue-based provider.
579///
580/// **NOT RECOMMENDED**: Use [`JagTextProvider`] (harfrust + swash) instead.
581/// This provider is kept for compatibility and testing purposes only.
582///
583/// Limitations:
584/// - Basic ASCII-first layout
585/// - No advanced shaping features
586/// - Lower quality than swash rasterization
587pub struct SimpleFontdueProvider {
588    font: fontdue::Font,
589    orientation: SubpixelOrientation,
590}
591
592impl SimpleFontdueProvider {
593    pub fn from_bytes(bytes: &[u8], orientation: SubpixelOrientation) -> anyhow::Result<Self> {
594        let font = fontdue::Font::from_bytes(bytes, fontdue::FontSettings::default())
595            .map_err(|e| anyhow::anyhow!(e))?;
596        Ok(Self { font, orientation })
597    }
598}
599
600impl TextProvider for SimpleFontdueProvider {
601    fn rasterize_run(&self, run: &crate::scene::TextRun) -> Vec<RasterizedGlyph> {
602        use fontdue::layout::{CoordinateSystem, Layout, LayoutSettings, TextStyle};
603        let mut layout = Layout::new(CoordinateSystem::PositiveYDown);
604        layout.reset(&LayoutSettings {
605            x: 0.0,
606            y: 0.0,
607            ..LayoutSettings::default()
608        });
609        layout.append(
610            &[&self.font],
611            &TextStyle::new(&run.text, run.size.max(1.0), 0),
612        );
613
614        let mut out = Vec::new();
615        for g in layout.glyphs() {
616            // Rasterize individual glyph to grayscale
617            let (metrics, bitmap) = self.font.rasterize_indexed(g.key.glyph_index, g.key.px);
618            if metrics.width == 0 || metrics.height == 0 {
619                continue;
620            }
621            // Convert to subpixel mask
622            let mask = GlyphMask::Subpixel(grayscale_to_subpixel_rgb(
623                metrics.width as u32,
624                metrics.height as u32,
625                &bitmap,
626                self.orientation,
627            ));
628            // Layout already provides the glyph's top-left (x, y) in pixel space for the
629            // chosen CoordinateSystem. Using those directly avoids double-applying the
630            // font bearing which would incorrectly shift glyphs vertically (clipping
631            // descenders). We keep offsets relative to the run's origin; PassManager
632            // snaps the run once using line metrics.
633            let ox = g.x;
634            let oy = g.y;
635            out.push(RasterizedGlyph {
636                offset: [ox, oy],
637                mask,
638            });
639        }
640        out
641    }
642    fn line_metrics(&self, px: f32) -> Option<LineMetrics> {
643        self.font.horizontal_line_metrics(px).map(|lm| {
644            let ascent = lm.ascent;
645            // Fontdue typically reports descent as a negative number; normalize to positive magnitude.
646            let descent = lm.descent.abs();
647            let line_gap = lm.line_gap.max(0.0);
648            LineMetrics {
649                ascent,
650                descent,
651                line_gap,
652            }
653        })
654    }
655}
656
657/// LEGACY: Grayscale fontdue provider.
658///
659/// **NOT RECOMMENDED**: Use [`JagTextProvider`] (harfrust + swash) instead.
660/// This provider is kept for compatibility and testing purposes only.
661///
662/// Replicates grayscale coverage to RGB channels equally (no subpixel rendering).
663pub struct GrayscaleFontdueProvider {
664    font: fontdue::Font,
665}
666
667impl GrayscaleFontdueProvider {
668    pub fn from_bytes(bytes: &[u8]) -> anyhow::Result<Self> {
669        let font = fontdue::Font::from_bytes(bytes, fontdue::FontSettings::default())
670            .map_err(|e| anyhow::anyhow!(e))?;
671        Ok(Self { font })
672    }
673}
674
675impl TextProvider for GrayscaleFontdueProvider {
676    fn rasterize_run(&self, run: &crate::scene::TextRun) -> Vec<RasterizedGlyph> {
677        use fontdue::layout::{CoordinateSystem, Layout, LayoutSettings, TextStyle};
678        let mut layout = Layout::new(CoordinateSystem::PositiveYDown);
679        layout.reset(&LayoutSettings {
680            x: 0.0,
681            y: 0.0,
682            ..LayoutSettings::default()
683        });
684        layout.append(
685            &[&self.font],
686            &TextStyle::new(&run.text, run.size.max(1.0), 0),
687        );
688        let mut out = Vec::new();
689        for g in layout.glyphs() {
690            let (metrics, bitmap) = self.font.rasterize_indexed(g.key.glyph_index, g.key.px);
691            if metrics.width == 0 || metrics.height == 0 {
692                continue;
693            }
694            let mask = GlyphMask::Subpixel(grayscale_to_rgb_equal(
695                metrics.width as u32,
696                metrics.height as u32,
697                &bitmap,
698            ));
699            // See note above: use layout-provided top-left directly.
700            let ox = g.x;
701            let oy = g.y;
702            out.push(RasterizedGlyph {
703                offset: [ox, oy],
704                mask,
705            });
706        }
707        out
708    }
709    fn line_metrics(&self, px: f32) -> Option<LineMetrics> {
710        self.font.horizontal_line_metrics(px).map(|lm| {
711            let ascent = lm.ascent;
712            let descent = lm.descent.abs();
713            let line_gap = lm.line_gap.max(0.0);
714            LineMetrics {
715                ascent,
716                descent,
717                line_gap,
718            }
719        })
720    }
721}
722
723/// Simplified line metrics
724#[derive(Clone, Copy, Debug, Default)]
725pub struct LineMetrics {
726    pub ascent: f32,
727    pub descent: f32,
728    pub line_gap: f32,
729}
730
731// ---------------------------------------------------------------------------
732// CSS font-family parser
733// ---------------------------------------------------------------------------
734
735/// A single candidate in a parsed CSS font-family stack.
736#[derive(Debug, Clone, PartialEq, Eq)]
737enum FontFamilyCandidate {
738    /// A specific font name, e.g. `"Georgia"`.
739    Name(String),
740    /// A CSS generic family keyword.
741    Generic(GenericFamily),
742}
743
744#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
745enum GenericFamily {
746    Serif,
747    SansSerif,
748    Monospace,
749    SystemUi,
750    Cursive,
751    Fantasy,
752}
753
754/// Parse a CSS font-family value into an ordered list of candidates.
755///
756/// Handles quoted names (`"Times New Roman"`, `'Georgia'`), unquoted names,
757/// generic keywords (`serif`, `sans-serif`, `monospace`, `system-ui`,
758/// `cursive`, `fantasy`), and browser aliases (`-apple-system`,
759/// `BlinkMacSystemFont`).
760fn parse_font_family_stack(css_value: &str) -> Vec<FontFamilyCandidate> {
761    let mut result = Vec::new();
762    for part in css_value.split(',') {
763        let trimmed = part.trim();
764        if trimmed.is_empty() {
765            continue;
766        }
767        // Strip surrounding quotes
768        let name = if (trimmed.starts_with('"') && trimmed.ends_with('"'))
769            || (trimmed.starts_with('\'') && trimmed.ends_with('\''))
770        {
771            &trimmed[1..trimmed.len() - 1]
772        } else {
773            trimmed
774        };
775        let lower = name.to_ascii_lowercase();
776        let candidate = match lower.as_str() {
777            "serif" | "ui-serif" => FontFamilyCandidate::Generic(GenericFamily::Serif),
778            "sans-serif" | "ui-sans-serif" => {
779                FontFamilyCandidate::Generic(GenericFamily::SansSerif)
780            }
781            "monospace" | "ui-monospace" => FontFamilyCandidate::Generic(GenericFamily::Monospace),
782            "system-ui" | "-apple-system" | "blinkmacswissfont" | "blinkmacsystemfont" => {
783                FontFamilyCandidate::Generic(GenericFamily::SystemUi)
784            }
785            "cursive" => FontFamilyCandidate::Generic(GenericFamily::Cursive),
786            "fantasy" => FontFamilyCandidate::Generic(GenericFamily::Fantasy),
787            _ => FontFamilyCandidate::Name(name.to_string()),
788        };
789        result.push(candidate);
790    }
791    result
792}
793
794// ---------------------------------------------------------------------------
795// Cached font set for a resolved family
796// ---------------------------------------------------------------------------
797
798/// Font faces for a single resolved font family (regular + weight/style variants).
799#[derive(Clone)]
800struct CachedFontSet {
801    /// Upright (non-italic) faces keyed by CSS font-weight.
802    upright_faces: Vec<(u16, jag_text::FontFace)>,
803    /// Italic/oblique faces keyed by CSS font-weight.
804    italic_faces: Vec<(u16, jag_text::FontFace)>,
805}
806
807// ---------------------------------------------------------------------------
808// JagTextProvider
809// ---------------------------------------------------------------------------
810
811/// Text provider backed by jag-text (HarfBuzz) for shaping and swash for rasterization.
812///
813/// This uses a primary `jag-text` `FontFace` for text and an optional emoji font
814/// for color emoji fallback. Delegates shaping to `TextShaper::shape_ltr`, then
815/// rasterizes glyphs via swash bitmap images.
816///
817/// Supports CSS font-family stack resolution: when a `TextRun` specifies a
818/// `family` string (e.g. `"Georgia, 'Times New Roman', serif"`), the provider
819/// parses the stack and resolves candidates against the system font database,
820/// caching loaded fonts for subsequent requests.
821pub struct JagTextProvider {
822    /// Primary (regular) text font.
823    font: jag_text::FontFace,
824    /// Optional bold/semibold face for heavier weights.
825    bold_font: Option<jag_text::FontFace>,
826    /// Optional italic face for slanted text.
827    italic_font: Option<jag_text::FontFace>,
828    /// Optional monospace font for code spans.
829    mono_font: Option<jag_text::FontFace>,
830    /// Optional emoji font for fallback when primary font lacks emoji glyphs.
831    emoji_font: Option<jag_text::FontFace>,
832    orientation: SubpixelOrientation,
833    /// System font database kept alive for on-demand font resolution.
834    /// `None` when the provider was constructed from raw bytes.
835    font_db: Option<fontdb::Database>,
836    /// Cache of resolved font families, keyed by lowercase family name or
837    /// generic family keyword. Protected by a `Mutex` because the
838    /// `TextProvider` trait takes `&self`.
839    font_cache: std::sync::Mutex<std::collections::HashMap<String, CachedFontSet>>,
840}
841
842impl JagTextProvider {
843    pub fn from_bytes(bytes: &[u8], orientation: SubpixelOrientation) -> anyhow::Result<Self> {
844        let font = jag_text::FontFace::from_vec(bytes.to_vec(), 0)?;
845        Ok(Self {
846            font,
847            bold_font: None,
848            italic_font: None,
849            mono_font: None,
850            emoji_font: None,
851            orientation,
852            font_db: None,
853            font_cache: std::sync::Mutex::new(std::collections::HashMap::new()),
854        })
855    }
856
857    /// Create a provider with a primary font and an emoji font for fallback.
858    pub fn from_bytes_with_emoji(
859        bytes: &[u8],
860        emoji_bytes: &[u8],
861        orientation: SubpixelOrientation,
862    ) -> anyhow::Result<Self> {
863        let font = jag_text::FontFace::from_vec(bytes.to_vec(), 0)?;
864        let emoji_font = jag_text::FontFace::from_vec(emoji_bytes.to_vec(), 0)?;
865        Ok(Self {
866            font,
867            bold_font: None,
868            italic_font: None,
869            mono_font: None,
870            emoji_font: Some(emoji_font),
871            orientation,
872            font_db: None,
873            font_cache: std::sync::Mutex::new(std::collections::HashMap::new()),
874        })
875    }
876
877    /// Construct from a reasonable system sans-serif font using `fontdb`.
878    /// Also attempts to load a system emoji font for color emoji fallback.
879    pub fn from_system_fonts(orientation: SubpixelOrientation) -> anyhow::Result<Self> {
880        use fontdb::{Database, Family, Query, Source, Stretch, Style, Weight};
881
882        let mut db = Database::new();
883        db.load_system_fonts();
884
885        // Load primary text font (regular weight)
886        let id = db
887            .query(&Query {
888                families: &[
889                    Family::Name("Segoe UI".into()),
890                    Family::SansSerif,
891                    Family::Name("SF Pro Text".into()),
892                    Family::Name("Arial".into()),
893                    Family::Name("Helvetica Neue".into()),
894                ],
895                weight: Weight::NORMAL,
896                stretch: Stretch::Normal,
897                style: Style::Normal,
898                ..Query::default()
899            })
900            .ok_or_else(|| anyhow::anyhow!("no suitable system font found for jag-text"))?;
901
902        let face = db
903            .face(id)
904            .ok_or_else(|| anyhow::anyhow!("fontdb face missing for system font id"))?;
905
906        let bytes: Vec<u8> = match &face.source {
907            Source::File(path) => std::fs::read(path)?,
908            Source::Binary(data) => data.as_ref().as_ref().to_vec(),
909            Source::SharedFile(_, data) => data.as_ref().as_ref().to_vec(),
910        };
911
912        let font = jag_text::FontFace::from_vec(bytes, face.index as usize)?;
913
914        // Try to load a matching bold face from the same family (if available).
915        let primary_family = face.families.first().map(|(name, _lang)| name.clone());
916        let bold_font = primary_family
917            .as_deref()
918            .and_then(|family_name| {
919                db.query(&Query {
920                    families: &[Family::Name(family_name)],
921                    weight: Weight::BOLD,
922                    stretch: Stretch::Normal,
923                    style: Style::Normal,
924                    ..Query::default()
925                })
926            })
927            .and_then(|bold_id| db.face(bold_id))
928            .and_then(|bold_face| {
929                let bytes: Vec<u8> = match &bold_face.source {
930                    Source::File(path) => std::fs::read(path).ok()?,
931                    Source::Binary(data) => Some(data.as_ref().as_ref().to_vec())?,
932                    Source::SharedFile(_, data) => Some(data.as_ref().as_ref().to_vec())?,
933                };
934                jag_text::FontFace::from_vec(bytes, bold_face.index as usize).ok()
935            });
936
937        // Try to load a matching italic face from the same family (if available).
938        let italic_font = primary_family
939            .as_deref()
940            .and_then(|family_name| {
941                db.query(&Query {
942                    families: &[Family::Name(family_name)],
943                    weight: Weight::NORMAL,
944                    stretch: Stretch::Normal,
945                    style: Style::Italic,
946                    ..Query::default()
947                })
948            })
949            .and_then(|italic_id| db.face(italic_id))
950            .and_then(|italic_face| {
951                let bytes: Vec<u8> = match &italic_face.source {
952                    Source::File(path) => std::fs::read(path).ok()?,
953                    Source::Binary(data) => Some(data.as_ref().as_ref().to_vec())?,
954                    Source::SharedFile(_, data) => Some(data.as_ref().as_ref().to_vec())?,
955                };
956                jag_text::FontFace::from_vec(bytes, italic_face.index as usize).ok()
957            });
958
959        // Try to load a monospace font for code spans
960        let mono_font = db
961            .query(&Query {
962                families: &[
963                    Family::Monospace,
964                    // macOS
965                    Family::Name("SF Mono".into()),
966                    Family::Name("Menlo".into()),
967                    // Windows
968                    Family::Name("Cascadia Code".into()),
969                    Family::Name("Consolas".into()),
970                    // Linux
971                    Family::Name("DejaVu Sans Mono".into()),
972                    Family::Name("Liberation Mono".into()),
973                ],
974                weight: Weight::NORMAL,
975                stretch: Stretch::Normal,
976                style: Style::Normal,
977                ..Query::default()
978            })
979            .and_then(|mono_id| db.face(mono_id))
980            .and_then(|mono_face| {
981                let bytes: Vec<u8> = match &mono_face.source {
982                    Source::File(path) => std::fs::read(path).ok()?,
983                    Source::Binary(data) => Some(data.as_ref().as_ref().to_vec())?,
984                    Source::SharedFile(_, data) => Some(data.as_ref().as_ref().to_vec())?,
985                };
986                jag_text::FontFace::from_vec(bytes, mono_face.index as usize).ok()
987            });
988
989        // Try to load emoji font for fallback
990        let emoji_font = db
991            .query(&Query {
992                families: &[
993                    // macOS
994                    Family::Name("Apple Color Emoji".into()),
995                    // Windows
996                    Family::Name("Segoe UI Emoji".into()),
997                    // Linux
998                    Family::Name("Noto Color Emoji".into()),
999                ],
1000                weight: Weight::NORMAL,
1001                stretch: Stretch::Normal,
1002                style: Style::Normal,
1003                ..Query::default()
1004            })
1005            .and_then(|emoji_id| {
1006                let emoji_face = db.face(emoji_id)?;
1007                let emoji_bytes: Vec<u8> = match &emoji_face.source {
1008                    Source::File(path) => std::fs::read(path).ok()?,
1009                    Source::Binary(data) => Some(data.as_ref().as_ref().to_vec())?,
1010                    Source::SharedFile(_, data) => Some(data.as_ref().as_ref().to_vec())?,
1011                };
1012                jag_text::FontFace::from_vec(emoji_bytes, emoji_face.index as usize).ok()
1013            });
1014
1015        Ok(Self {
1016            font,
1017            bold_font,
1018            italic_font,
1019            mono_font,
1020            emoji_font,
1021            orientation,
1022            font_db: Some(db),
1023            font_cache: std::sync::Mutex::new(std::collections::HashMap::new()),
1024        })
1025    }
1026
1027    /// Load a `FontFace` from a `fontdb` face entry.
1028    fn load_face_from_db(face: &fontdb::FaceInfo) -> Option<jag_text::FontFace> {
1029        use fontdb::Source;
1030        let bytes: Vec<u8> = match &face.source {
1031            Source::File(path) => std::fs::read(path).ok()?,
1032            Source::Binary(data) => data.as_ref().as_ref().to_vec(),
1033            Source::SharedFile(_, data) => data.as_ref().as_ref().to_vec(),
1034        };
1035        jag_text::FontFace::from_vec(bytes, face.index as usize).ok()
1036    }
1037
1038    /// Register a web font from raw TTF/OTF bytes.
1039    ///
1040    /// **Idempotent**: returns `Ok(false)` if a font with the same
1041    /// family + weight + style is already registered.
1042    ///
1043    /// **Thread-safe**: font_cache is Mutex-protected.
1044    ///
1045    /// The `data` must be raw TTF or OTF — WOFF/WOFF2 must be decompressed
1046    /// before calling this method.
1047    pub fn register_web_font(
1048        &self,
1049        family: &str,
1050        data: Vec<u8>,
1051        weight: u16,
1052        style: crate::scene::FontStyle,
1053    ) -> anyhow::Result<bool> {
1054        let cache_key = family.to_lowercase();
1055        let is_italic = matches!(
1056            style,
1057            crate::scene::FontStyle::Italic | crate::scene::FontStyle::Oblique
1058        );
1059
1060        // Idempotent fast-path checks before parsing font bytes.
1061        {
1062            let cache = self.font_cache.lock().unwrap();
1063            if let Some(set) = cache.get(&cache_key) {
1064                let faces = if is_italic {
1065                    &set.italic_faces
1066                } else {
1067                    &set.upright_faces
1068                };
1069                if faces.iter().any(|(w, _)| *w == weight) {
1070                    return Ok(false);
1071                }
1072            }
1073        }
1074
1075        // Validate and create FontFace from raw TTF/OTF bytes
1076        let face = jag_text::FontFace::from_vec(data, 0)
1077            .map_err(|e| anyhow::anyhow!("invalid font data for '{}': {}", family, e))?;
1078
1079        // Insert into our font cache
1080        let mut cache = self.font_cache.lock().unwrap();
1081        if let Some(set) = cache.get_mut(&cache_key) {
1082            let faces = if is_italic {
1083                &mut set.italic_faces
1084            } else {
1085                &mut set.upright_faces
1086            };
1087            Self::insert_weighted_face(faces, weight, face);
1088        } else {
1089            let mut set = CachedFontSet {
1090                upright_faces: Vec::new(),
1091                italic_faces: Vec::new(),
1092            };
1093            if is_italic {
1094                set.italic_faces.push((weight, face));
1095            } else {
1096                set.upright_faces.push((weight, face));
1097            }
1098            cache.insert(cache_key, set);
1099        }
1100
1101        // Flush the global glyph rasterization cache so the next frame
1102        // re-rasterizes text using the newly registered web font face
1103        // instead of returning stale bitmaps from the old fallback font.
1104        invalidate_glyph_run_cache();
1105
1106        Ok(true)
1107    }
1108
1109    /// Resolve a single font family candidate against the `fontdb` database,
1110    /// loading regular + bold + italic variants. Returns `None` if the family
1111    /// is not installed.
1112    fn resolve_family(
1113        db: &fontdb::Database,
1114        candidate: &FontFamilyCandidate,
1115    ) -> Option<CachedFontSet> {
1116        use fontdb::{Family, Query, Stretch, Style, Weight};
1117
1118        let families: Vec<Family<'_>> = match candidate {
1119            FontFamilyCandidate::Name(name) => vec![Family::Name(name.as_str())],
1120            FontFamilyCandidate::Generic(g) => match g {
1121                GenericFamily::Serif => vec![
1122                    Family::Serif,
1123                    Family::Name("Georgia"),
1124                    Family::Name("Times New Roman"),
1125                    Family::Name("Times"),
1126                ],
1127                GenericFamily::SansSerif => vec![
1128                    Family::Name("Segoe UI"),
1129                    Family::SansSerif,
1130                    Family::Name("SF Pro Text"),
1131                    Family::Name("Arial"),
1132                    Family::Name("Helvetica Neue"),
1133                ],
1134                GenericFamily::Monospace => vec![
1135                    Family::Monospace,
1136                    Family::Name("SF Mono"),
1137                    Family::Name("Menlo"),
1138                    Family::Name("Cascadia Code"),
1139                    Family::Name("Consolas"),
1140                    Family::Name("DejaVu Sans Mono"),
1141                ],
1142                GenericFamily::SystemUi => vec![
1143                    // Prefer platform UI faces first so `system-ui` /
1144                    // `-apple-system` metrics match browser text wrapping.
1145                    Family::Name("Segoe UI"),
1146                    Family::Name("system-ui"),
1147                    Family::Name("-apple-system"),
1148                    Family::Name("BlinkMacSystemFont"),
1149                    Family::SansSerif,
1150                    Family::Name("SF Pro Text"),
1151                    Family::Name("SF Pro Display"),
1152                    Family::Name(".SF NS Text"),
1153                    Family::Name(".SF NS Display"),
1154                    Family::Name(".AppleSystemUIFont"),
1155                    Family::Name("Arial"),
1156                    Family::Name("Helvetica Neue"),
1157                ],
1158                GenericFamily::Cursive => vec![
1159                    Family::Cursive,
1160                    Family::Name("Snell Roundhand"),
1161                    Family::Name("Comic Sans MS"),
1162                ],
1163                GenericFamily::Fantasy => vec![
1164                    Family::Fantasy,
1165                    Family::Name("Papyrus"),
1166                    Family::Name("Impact"),
1167                ],
1168            },
1169        };
1170
1171        let regular_id = db.query(&Query {
1172            families: &families,
1173            weight: Weight::NORMAL,
1174            stretch: Stretch::Normal,
1175            style: Style::Normal,
1176        })?;
1177
1178        let regular_face = db.face(regular_id)?;
1179        let regular = Self::load_face_from_db(regular_face)?;
1180
1181        if std::env::var("JAG_TEXT_DEBUG_FAMILY").is_ok() {
1182            let resolved = regular_face
1183                .families
1184                .first()
1185                .map(|(name, _)| name.as_str())
1186                .unwrap_or("<unknown>");
1187            eprintln!("[TEXT] resolve_family {:?} -> {}", candidate, resolved);
1188        }
1189
1190        // Resolve bold + italic from the same resolved family name.
1191        let resolved_family = regular_face.families.first().map(|(name, _)| name.as_str());
1192
1193        let bold = resolved_family.and_then(|fam| {
1194            let id = db.query(&Query {
1195                families: &[Family::Name(fam)],
1196                weight: Weight::BOLD,
1197                stretch: Stretch::Normal,
1198                style: Style::Normal,
1199            })?;
1200            Self::load_face_from_db(db.face(id)?)
1201        });
1202        let italic = resolved_family.and_then(|fam| {
1203            let id = db.query(&Query {
1204                families: &[Family::Name(fam)],
1205                weight: Weight::NORMAL,
1206                stretch: Stretch::Normal,
1207                style: Style::Italic,
1208            })?;
1209            Self::load_face_from_db(db.face(id)?)
1210        });
1211
1212        let mut set = CachedFontSet {
1213            upright_faces: vec![(400, regular)],
1214            italic_faces: Vec::new(),
1215        };
1216        if let Some(bold_face) = bold {
1217            Self::insert_weighted_face(&mut set.upright_faces, 700, bold_face);
1218        }
1219        if let Some(italic_face) = italic {
1220            Self::insert_weighted_face(&mut set.italic_faces, 400, italic_face);
1221        }
1222
1223        Some(set)
1224    }
1225
1226    /// Cache key for a font family candidate (lowercase for case-insensitive matching).
1227    fn cache_key_for(candidate: &FontFamilyCandidate) -> String {
1228        match candidate {
1229            FontFamilyCandidate::Name(n) => n.to_ascii_lowercase(),
1230            FontFamilyCandidate::Generic(g) => match g {
1231                GenericFamily::Serif => "__generic_serif__".to_string(),
1232                GenericFamily::SansSerif => "__generic_sans-serif__".to_string(),
1233                GenericFamily::Monospace => "__generic_monospace__".to_string(),
1234                GenericFamily::SystemUi => "__generic_system-ui__".to_string(),
1235                GenericFamily::Cursive => "__generic_cursive__".to_string(),
1236                GenericFamily::Fantasy => "__generic_fantasy__".to_string(),
1237            },
1238        }
1239    }
1240
1241    fn insert_weighted_face(
1242        faces: &mut Vec<(u16, jag_text::FontFace)>,
1243        weight: u16,
1244        face: jag_text::FontFace,
1245    ) {
1246        if let Some(pos) = faces.iter().position(|(w, _)| *w == weight) {
1247            faces[pos] = (weight, face);
1248        } else {
1249            faces.push((weight, face));
1250        }
1251        faces.sort_by_key(|(w, _)| *w);
1252    }
1253
1254    fn pick_closest_weighted_face(
1255        faces: &[(u16, jag_text::FontFace)],
1256        requested_weight: u16,
1257    ) -> Option<jag_text::FontFace> {
1258        let mut best: Option<(u16, &jag_text::FontFace)> = None;
1259        for (weight, face) in faces {
1260            match best {
1261                None => best = Some((*weight, face)),
1262                Some((best_weight, _)) => {
1263                    let best_dist = (i32::from(best_weight) - i32::from(requested_weight)).abs();
1264                    let new_dist = (i32::from(*weight) - i32::from(requested_weight)).abs();
1265                    if new_dist < best_dist || (new_dist == best_dist && *weight > best_weight) {
1266                        best = Some((*weight, face));
1267                    }
1268                }
1269            }
1270        }
1271        best.map(|(_, face)| face.clone())
1272    }
1273
1274    /// Select the appropriate font face based on a `TextRun`'s family, weight,
1275    /// and style.
1276    ///
1277    /// When the run specifies a `family` string, parses it as a CSS font-family
1278    /// stack and walks the candidates in order, resolving each against the system
1279    /// font database. Resolved fonts are cached for subsequent requests.
1280    ///
1281    /// Returns a *cloned* `FontFace` (cheap — inner data is `Arc`).
1282    fn select_face(&self, run: &crate::scene::TextRun) -> jag_text::FontFace {
1283        use crate::scene::FontStyle as SceneFontStyle;
1284
1285        let requested_weight = run.weight.clamp(100.0, 900.0).round() as u16;
1286        let is_bold = requested_weight >= 600;
1287        let is_italic = matches!(run.style, SceneFontStyle::Italic | SceneFontStyle::Oblique);
1288
1289        // --- Try to resolve from the font-family string, if present. ---
1290        if let Some(ref family_str) = run.family {
1291            let candidates = parse_font_family_stack(family_str);
1292
1293            if let Some(db) = &self.font_db {
1294                for candidate in &candidates {
1295                    let key = Self::cache_key_for(candidate);
1296
1297                    // Check the cache first (short lock).
1298                    {
1299                        let cache = self.font_cache.lock().unwrap();
1300                        if let Some(set) = cache.get(&key) {
1301                            if let Some(face) = Self::pick_variant(set, requested_weight, is_italic)
1302                            {
1303                                return face;
1304                            }
1305                        }
1306                    }
1307
1308                    // Cache miss — resolve from fontdb.
1309                    if let Some(set) = Self::resolve_family(db, candidate) {
1310                        let face = Self::pick_variant(&set, requested_weight, is_italic)
1311                            .unwrap_or_else(|| self.font.clone());
1312                        self.font_cache.lock().unwrap().insert(key, set);
1313                        return face;
1314                    }
1315                }
1316            }
1317
1318            // If no fontdb or no candidates matched, check for the legacy
1319            // "monospace" shorthand before falling through to defaults.
1320            if family_str.eq_ignore_ascii_case("monospace") {
1321                if let Some(ref mono) = self.mono_font {
1322                    return mono.clone();
1323                }
1324            }
1325        }
1326
1327        // --- Fallback to the pre-loaded defaults. ---
1328        if is_italic {
1329            if let Some(ref italic) = self.italic_font {
1330                return italic.clone();
1331            }
1332        }
1333        if is_bold {
1334            if let Some(ref bold) = self.bold_font {
1335                return bold.clone();
1336            }
1337        }
1338        self.font.clone()
1339    }
1340
1341    /// Pick the best weight/style variant from a cached font set.
1342    fn pick_variant(
1343        set: &CachedFontSet,
1344        requested_weight: u16,
1345        italic: bool,
1346    ) -> Option<jag_text::FontFace> {
1347        if std::env::var("JAG_TEXT_DEBUG_FAMILY").is_ok() {
1348            eprintln!(
1349                "[TEXT] pick_variant weight={} italic={} upright={} italic_faces={}",
1350                requested_weight,
1351                italic,
1352                set.upright_faces.len(),
1353                set.italic_faces.len()
1354            );
1355        }
1356
1357        if italic {
1358            if let Some(face) =
1359                Self::pick_closest_weighted_face(&set.italic_faces, requested_weight)
1360            {
1361                return Some(face);
1362            }
1363        }
1364
1365        if let Some(face) = Self::pick_closest_weighted_face(&set.upright_faces, requested_weight) {
1366            return Some(face);
1367        }
1368
1369        if let Some(face) = Self::pick_closest_weighted_face(&set.italic_faces, requested_weight) {
1370            return Some(face);
1371        }
1372
1373        None
1374    }
1375
1376    /// Layout a paragraph using jag-text's `TextLayout` with optional width-based wrapping.
1377    ///
1378    /// This exposes jag-text's multi-line layout (including per-line baselines) so that
1379    /// callers can build GPU-ready glyph batches without relying on `PassManager`
1380    /// baseline heuristics.
1381    pub fn layout_paragraph(
1382        &self,
1383        text: &str,
1384        size_px: f32,
1385        max_width: Option<f32>,
1386    ) -> jag_text::layout::TextLayout {
1387        use jag_text::layout::{TextLayout, WrapMode};
1388
1389        let wrap = if max_width.is_some() {
1390            WrapMode::BreakWord
1391        } else {
1392            WrapMode::NoWrap
1393        };
1394
1395        TextLayout::with_wrap(
1396            text.to_string(),
1397            &self.font,
1398            size_px.max(1.0),
1399            max_width,
1400            wrap,
1401        )
1402    }
1403}
1404
1405impl TextProvider for JagTextProvider {
1406    fn rasterize_run(&self, run: &crate::scene::TextRun) -> Vec<RasterizedGlyph> {
1407        use jag_text::shaping::TextShaper;
1408        use swash::FontRef;
1409        use swash::scale::image::Content;
1410        use swash::scale::{Render, ScaleContext, Source, StrikeWith};
1411
1412        let size = run.size.max(1.0);
1413        let face = self.select_face(run);
1414
1415        // Shape the entire run with HarfBuzz so that advances include kerning,
1416        // ligatures, and contextual alternates. This makes pen positions match
1417        // what shape_paragraph / TextLayout produce for measurement and caret
1418        // positioning.
1419        let shaped = TextShaper::shape_ltr(&run.text, 0..run.text.len(), &face, 0, size);
1420
1421        // Build swash resources for rasterisation of shaped glyph IDs.
1422        let font_bytes = face.as_bytes();
1423        let font_ref = FontRef::from_index(&font_bytes, 0)
1424            .expect("jag-text FontFace bytes should be a valid swash FontRef");
1425
1426        let mut ctx = ScaleContext::new();
1427        let mut scaler = ctx.builder(font_ref).size(size).hint(true).build();
1428        let renderer = Render::new(&[
1429            Source::Outline,
1430            Source::Bitmap(StrikeWith::BestFit),
1431            Source::ColorBitmap(StrikeWith::BestFit),
1432        ]);
1433
1434        // Get emoji font bytes if available (we'll create FontRef per-use due to lifetime constraints)
1435        let emoji_bytes = self.emoji_font.as_ref().map(|f| f.as_bytes());
1436
1437        let mut out = Vec::new();
1438        let mut pen_x: f32 = 0.0;
1439
1440        for idx in 0..shaped.glyphs.len() {
1441            let glyph_id = shaped.glyphs[idx];
1442            let advance = shaped.advances[idx];
1443
1444            if glyph_id == 0 {
1445                // .notdef — try emoji fallback using the source character at this cluster
1446                let cluster_byte = shaped.clusters[idx] as usize;
1447                let emoji_rendered = emoji_bytes.as_ref().and_then(|eb| {
1448                    let ch = run.text[cluster_byte..].chars().next()?;
1449                    let emoji_font_ref = FontRef::from_index(eb, 0)?;
1450                    let emoji_gid = emoji_font_ref.charmap().map(ch);
1451                    if emoji_gid == 0 {
1452                        return None;
1453                    }
1454                    let mut emoji_ctx = ScaleContext::new();
1455                    let mut emoji_scaler = emoji_ctx
1456                        .builder(emoji_font_ref)
1457                        .size(size)
1458                        .hint(false)
1459                        .build();
1460                    let emoji_renderer = Render::new(&[
1461                        Source::ColorOutline(0),
1462                        Source::ColorBitmap(StrikeWith::BestFit),
1463                        Source::Bitmap(StrikeWith::BestFit),
1464                        Source::Outline,
1465                    ]);
1466                    let img = emoji_renderer.render(&mut emoji_scaler, emoji_gid)?;
1467                    let w = img.placement.width;
1468                    let h = img.placement.height;
1469                    if w == 0 || h == 0 {
1470                        return None;
1471                    }
1472                    let mask = match img.content {
1473                        Content::Mask => GlyphMask::Subpixel(grayscale_to_subpixel_rgb(
1474                            w,
1475                            h,
1476                            &img.data,
1477                            self.orientation,
1478                        )),
1479                        Content::SubpixelMask => GlyphMask::Subpixel(SubpixelMask {
1480                            width: w,
1481                            height: h,
1482                            format: MaskFormat::Rgba8,
1483                            data: img.data.clone(),
1484                        }),
1485                        Content::Color => GlyphMask::Color(ColorMask {
1486                            width: w,
1487                            height: h,
1488                            data: img.data.clone(),
1489                        }),
1490                    };
1491                    let ox = pen_x + img.placement.left as f32;
1492                    let oy = -img.placement.top as f32;
1493                    out.push(RasterizedGlyph {
1494                        offset: [ox, oy],
1495                        mask,
1496                    });
1497                    Some(w as f32)
1498                });
1499
1500                if let Some(emoji_width) = emoji_rendered {
1501                    pen_x += emoji_width;
1502                } else {
1503                    // No emoji fallback — skip with approximate advance
1504                    pen_x += size * 0.5;
1505                }
1506                continue;
1507            }
1508
1509            // Rasterize from primary font using the HarfBuzz-produced glyph ID.
1510            if let Some(img) = renderer.render(&mut scaler, glyph_id) {
1511                let w = img.placement.width;
1512                let h = img.placement.height;
1513                if w > 0 && h > 0 {
1514                    let mask = match img.content {
1515                        Content::Mask => GlyphMask::Subpixel(grayscale_to_subpixel_rgb(
1516                            w,
1517                            h,
1518                            &img.data,
1519                            self.orientation,
1520                        )),
1521                        Content::SubpixelMask => GlyphMask::Subpixel(SubpixelMask {
1522                            width: w,
1523                            height: h,
1524                            format: MaskFormat::Rgba8,
1525                            data: img.data.clone(),
1526                        }),
1527                        Content::Color => GlyphMask::Color(ColorMask {
1528                            width: w,
1529                            height: h,
1530                            data: img.data.clone(),
1531                        }),
1532                    };
1533
1534                    let ox = pen_x + img.placement.left as f32;
1535                    let oy = -img.placement.top as f32;
1536                    out.push(RasterizedGlyph {
1537                        offset: [ox, oy],
1538                        mask,
1539                    });
1540                }
1541            }
1542
1543            // Advance by the HarfBuzz-computed advance (includes kerning)
1544            pen_x += advance;
1545        }
1546
1547        out
1548    }
1549
1550    fn shape_paragraph(&self, text: &str, size_px: f32) -> Option<ShapedParagraph> {
1551        // Use jag-text layout to compute glyph advances; this matches the
1552        // shaping used for cursor movement and selection and avoids width
1553        // drift when centering text.
1554        let layout = self.layout_paragraph(text, size_px, None);
1555        let mut glyphs = Vec::new();
1556        for line in layout.lines() {
1557            for run in &line.runs {
1558                for (idx, adv) in run.advances.iter().enumerate() {
1559                    glyphs.push(ShapedGlyph {
1560                        cluster: run.clusters.get(idx).copied().unwrap_or(0),
1561                        x_advance: *adv,
1562                    });
1563                }
1564            }
1565        }
1566        Some(ShapedParagraph { glyphs })
1567    }
1568
1569    fn line_metrics(&self, px: f32) -> Option<LineMetrics> {
1570        let m = self.font.scaled_metrics(px.max(1.0));
1571        Some(LineMetrics {
1572            ascent: m.ascent,
1573            descent: m.descent,
1574            line_gap: m.line_gap,
1575        })
1576    }
1577
1578    fn measure_run(&self, run: &crate::scene::TextRun) -> f32 {
1579        use jag_text::shaping::TextShaper;
1580
1581        let size = run.size.max(1.0);
1582        let face = self.select_face(run);
1583
1584        // Shape with HarfBuzz — returns the same advances used by rasterize_run,
1585        // so measurement and rendering always agree.
1586        let shaped = TextShaper::shape_ltr(&run.text, 0..run.text.len(), &face, 0, size);
1587        if std::env::var("JAG_TEXT_DEBUG_FAMILY").is_ok()
1588            && (run.text.contains("Z-Ordering")
1589                || run.text.contains("Hit Testing")
1590                || run.text.contains("Depth Buffer")
1591                || run.text.contains(" System Fonts")
1592                || run.text.contains(" Opacity")
1593                || run.text.contains(" Text Runs")
1594                || run.text.contains(" Inline Block"))
1595        {
1596            eprintln!(
1597                "[TEXT] measure_run text={:?} size={} weight={} width={}",
1598                run.text, run.size, run.weight, shaped.width
1599            );
1600        }
1601        shaped.width
1602    }
1603
1604    fn register_web_font(
1605        &self,
1606        family: &str,
1607        data: Vec<u8>,
1608        weight: u16,
1609        style: crate::scene::FontStyle,
1610    ) -> anyhow::Result<bool> {
1611        // Delegate to the concrete JagTextProvider implementation.
1612        JagTextProvider::register_web_font(self, family, data, weight, style)
1613    }
1614}
1615
1616// Advanced shaper: integrate cosmic-text for shaping + swash rasterization (optional feature)
1617#[cfg(feature = "cosmic_text_shaper")]
1618mod cosmic_provider {
1619    use super::*;
1620    use std::collections::HashMap;
1621    use std::sync::Mutex;
1622
1623    use cosmic_text::{Attrs, Buffer, FontSystem, Metrics, Shaping, SwashCache};
1624
1625    /// Legacy cosmic-text provider for compatibility.
1626    ///
1627    /// **NOT RECOMMENDED**: Use [`JagTextProvider`] (harfrust + swash) instead.
1628    /// Only kept for testing/comparison purposes.
1629    ///
1630    /// A text provider backed by cosmic-text for shaping and swash for rasterization.
1631    /// Produces RGB subpixel masks from swash grayscale coverage.
1632    pub struct CosmicTextProvider {
1633        font_system: Mutex<FontSystem>,
1634        swash_cache: Mutex<SwashCache>,
1635        orientation: SubpixelOrientation,
1636        // Cache approximate line metrics per pixel size to aid baseline snapping
1637        metrics_cache: Mutex<HashMap<u32, LineMetrics>>, // key: px rounded
1638    }
1639
1640    impl CosmicTextProvider {
1641        /// Construct with a custom font (preferred for demo parity with SimpleFontdueProvider)
1642        pub fn from_bytes(bytes: &[u8], orientation: SubpixelOrientation) -> anyhow::Result<Self> {
1643            use std::sync::Arc;
1644            let src = cosmic_text::fontdb::Source::Binary(Arc::new(bytes.to_vec()));
1645            let fs = FontSystem::new_with_fonts([src]);
1646            Ok(Self {
1647                font_system: Mutex::new(fs),
1648                swash_cache: Mutex::new(SwashCache::new()),
1649                orientation,
1650                metrics_cache: Mutex::new(HashMap::new()),
1651            })
1652        }
1653
1654        /// Construct using system fonts (fallbacks handled by cosmic-text)
1655        #[allow(dead_code)]
1656        pub fn from_system_fonts(orientation: SubpixelOrientation) -> Self {
1657            Self {
1658                font_system: Mutex::new(FontSystem::new()),
1659                swash_cache: Mutex::new(SwashCache::new()),
1660                orientation,
1661                metrics_cache: Mutex::new(HashMap::new()),
1662            }
1663        }
1664
1665        fn shape_once(fs: &mut FontSystem, buffer: &mut Buffer, text: &str, px: f32) {
1666            let mut b = buffer.borrow_with(fs);
1667            b.set_metrics_and_size(Metrics::new(px, (px * 1.2).max(px + 2.0)), None, None);
1668            b.set_text(text, &Attrs::new(), Shaping::Advanced, None);
1669            b.shape_until_scroll(true);
1670        }
1671    }
1672
1673    impl TextProvider for CosmicTextProvider {
1674        fn rasterize_run(&self, run: &crate::scene::TextRun) -> Vec<RasterizedGlyph> {
1675            let mut out = Vec::new();
1676            let mut fs = self.font_system.lock().unwrap();
1677            // Shape into a temporary buffer first; drop borrow before rasterization
1678            let mut buffer = Buffer::new(
1679                &mut fs,
1680                Metrics::new(run.size.max(1.0), (run.size * 1.2).max(run.size + 2.0)),
1681            );
1682            Self::shape_once(&mut fs, &mut buffer, &run.text, run.size.max(1.0));
1683            drop(fs);
1684
1685            // Iterate runs and rasterize glyphs
1686            let runs = buffer.layout_runs().collect::<Vec<_>>();
1687            let mut fs = self.font_system.lock().unwrap();
1688            let mut cache = self.swash_cache.lock().unwrap();
1689            for lr in runs.iter() {
1690                for g in lr.glyphs.iter() {
1691                    // Compute glyph position relative to the run baseline (not absolute).
1692                    // cosmic-text's own draw path uses: final_y = run.line_y + physical.y + image_y.
1693                    // Here we want offsets relative to baseline, so omit run.line_y.
1694                    let pg = g.physical((0.0, 0.0), 1.0);
1695                    if let Some(img) = cache.get_image(&mut fs, pg.cache_key) {
1696                        let w = img.placement.width as u32;
1697                        let h = img.placement.height as u32;
1698                        if w == 0 || h == 0 {
1699                            continue;
1700                        }
1701                        match img.content {
1702                            cosmic_text::SwashContent::Mask => {
1703                                let mask = GlyphMask::Subpixel(grayscale_to_subpixel_rgb(
1704                                    w,
1705                                    h,
1706                                    &img.data,
1707                                    self.orientation,
1708                                ));
1709                                // Placement to top-left relative to baseline-origin
1710                                let ox = pg.x as f32 + img.placement.left as f32;
1711                                let oy = pg.y as f32 - img.placement.top as f32;
1712                                out.push(RasterizedGlyph {
1713                                    offset: [ox, oy],
1714                                    mask,
1715                                });
1716                            }
1717                            cosmic_text::SwashContent::Color => {
1718                                // Preserve color emoji RGBA data (already premultiplied)
1719                                let mask = GlyphMask::Color(ColorMask {
1720                                    width: w,
1721                                    height: h,
1722                                    data: img.data.clone(),
1723                                });
1724                                let ox = pg.x as f32 + img.placement.left as f32;
1725                                let oy = pg.y as f32 - img.placement.top as f32;
1726                                out.push(RasterizedGlyph {
1727                                    offset: [ox, oy],
1728                                    mask,
1729                                });
1730                            }
1731                            cosmic_text::SwashContent::SubpixelMask => {
1732                                // Fallback: treat as grayscale for now (rare)
1733                                let mask = GlyphMask::Subpixel(grayscale_to_subpixel_rgb(
1734                                    w,
1735                                    h,
1736                                    &img.data,
1737                                    self.orientation,
1738                                ));
1739                                let ox = pg.x as f32 + img.placement.left as f32;
1740                                let oy = pg.y as f32 - img.placement.top as f32;
1741                                out.push(RasterizedGlyph {
1742                                    offset: [ox, oy],
1743                                    mask,
1744                                });
1745                            }
1746                        }
1747                    }
1748                }
1749            }
1750            out
1751        }
1752
1753        fn line_metrics(&self, px: f32) -> Option<LineMetrics> {
1754            // Cache by integer pixel size to avoid repeated shaping
1755            let key = px.max(1.0).round() as u32;
1756            if let Some(m) = self.metrics_cache.lock().unwrap().get(&key).copied() {
1757                return Some(m);
1758            }
1759            let mut fs = self.font_system.lock().unwrap();
1760            // Shape a representative string and read layout line ascent/descent
1761            let mut buffer =
1762                Buffer::new(&mut fs, Metrics::new(px.max(1.0), (px * 1.2).max(px + 2.0)));
1763            // Borrow with fs to access line_layout API
1764            {
1765                let mut b = buffer.borrow_with(&mut fs);
1766                b.set_metrics_and_size(
1767                    Metrics::new(px.max(1.0), (px * 1.2).max(px + 2.0)),
1768                    None,
1769                    None,
1770                );
1771                b.set_text("Ag", &Attrs::new(), Shaping::Advanced, None);
1772                b.shape_until_scroll(true);
1773                if let Some(lines) = b.line_layout(0) {
1774                    if let Some(ll) = lines.get(0) {
1775                        let ascent = ll.max_ascent;
1776                        let descent = ll.max_descent;
1777                        let line_gap = (px * 1.2 - (ascent + descent)).max(0.0);
1778                        let lm = LineMetrics {
1779                            ascent,
1780                            descent,
1781                            line_gap,
1782                        };
1783                        self.metrics_cache.lock().unwrap().insert(key, lm);
1784                        return Some(lm);
1785                    }
1786                }
1787            }
1788            // Fallback heuristic
1789            let ascent = px * 0.8;
1790            let descent = px * 0.2;
1791            let line_gap = (px * 1.2 - (ascent + descent)).max(0.0);
1792            let lm = LineMetrics {
1793                ascent,
1794                descent,
1795                line_gap,
1796            };
1797            self.metrics_cache.lock().unwrap().insert(key, lm);
1798            Some(lm)
1799        }
1800    }
1801
1802    pub use CosmicTextProvider as Provider;
1803}
1804
1805#[cfg(feature = "cosmic_text_shaper")]
1806pub use cosmic_provider::Provider as CosmicTextProvider;
1807
1808// High-quality rasterizer: shape via cosmic-text, rasterize via FreeType LCD + hinting (optional)
1809#[cfg(feature = "freetype_ffi")]
1810mod freetype_provider {
1811    use super::*;
1812    use std::collections::HashMap;
1813    use std::sync::Mutex;
1814
1815    use cosmic_text::{Attrs, Buffer, FontSystem, Metrics, Shaping};
1816    use freetype;
1817
1818    /// Text provider that uses cosmic-text for shaping and FreeType for hinted LCD rasterization.
1819    pub struct FreeTypeProvider {
1820        font_system: Mutex<FontSystem>,
1821        orientation: SubpixelOrientation,
1822        // Keep font bytes; create FT library/face on demand to avoid Send/Sync issues
1823        ft_bytes: Vec<u8>,
1824        // Cache simple line metrics per integer pixel size
1825        metrics_cache: Mutex<HashMap<u32, LineMetrics>>, // key: px rounded
1826    }
1827
1828    impl FreeTypeProvider {
1829        pub fn from_bytes(bytes: &[u8], orientation: SubpixelOrientation) -> anyhow::Result<Self> {
1830            use std::sync::Arc;
1831            let src = cosmic_text::fontdb::Source::Binary(Arc::new(bytes.to_vec()));
1832            let fs = FontSystem::new_with_fonts([src]);
1833            // Initialize FreeType and construct a memory face
1834            let data = bytes.to_vec();
1835            Ok(Self {
1836                font_system: Mutex::new(fs),
1837                orientation,
1838                ft_bytes: data,
1839                metrics_cache: Mutex::new(HashMap::new()),
1840            })
1841        }
1842
1843        fn shape_once(fs: &mut FontSystem, buffer: &mut Buffer, text: &str, px: f32) {
1844            buffer.set_metrics_and_size(fs, Metrics::new(px, (px * 1.2).max(px + 2.0)), None, None);
1845            buffer.set_text(fs, text, &Attrs::new(), Shaping::Advanced, None);
1846            buffer.shape_until_scroll(fs, true);
1847        }
1848    }
1849
1850    impl TextProvider for FreeTypeProvider {
1851        fn rasterize_run(&self, run: &crate::scene::TextRun) -> Vec<RasterizedGlyph> {
1852            let mut out = Vec::new();
1853            // Shape with cosmic-text
1854            let mut fs = self.font_system.lock().unwrap();
1855            let mut buffer = Buffer::new(
1856                &mut fs,
1857                Metrics::new(run.size.max(1.0), (run.size * 1.2).max(run.size + 2.0)),
1858            );
1859            Self::shape_once(&mut fs, &mut buffer, &run.text, run.size.max(1.0));
1860            drop(fs);
1861
1862            // Iterate runs and rasterize glyphs using FreeType
1863            let runs = buffer.layout_runs().collect::<Vec<_>>();
1864            for lr in runs.iter() {
1865                for g in lr.glyphs.iter() {
1866                    // Map to physical glyph to access cache_key (contains glyph_id)
1867                    let pg = g.physical((0.0, 0.0), 1.0);
1868                    let glyph_index = pg.cache_key.glyph_id as u32;
1869                    let (w, h, ox, oy, data) = {
1870                        // Create FT library/face on demand to keep provider Send+Sync-compatible
1871                        if let Ok(lib) = freetype::Library::init() {
1872                            let _ = lib.set_lcd_filter(freetype::LcdFilter::LcdFilterDefault);
1873                            if let Ok(face) = lib.new_memory_face(self.ft_bytes.clone(), 0) {
1874                                // Use char size with 26.6 precision for better spacing parity
1875                                let target_ppem = (run.size.max(1.0) * 64.0) as isize; // 26.6 fixed
1876                                let _ = face.set_char_size(0, target_ppem, 72, 72);
1877                                let _ = face.set_pixel_sizes(0, run.size.max(1.0).ceil() as u32);
1878                                // Load & render glyph in LCD mode with hinting
1879                                use freetype::face::LoadFlag;
1880                                use freetype::render_mode::RenderMode;
1881                                let _ = face.load_glyph(
1882                                    glyph_index as u32,
1883                                    LoadFlag::DEFAULT | LoadFlag::TARGET_LCD | LoadFlag::COLOR,
1884                                );
1885                                let _ = face.glyph().render_glyph(RenderMode::Lcd);
1886                                let slot = face.glyph();
1887                                let bmp = slot.bitmap();
1888                                let width = (bmp.width() as u32).saturating_div(3); // LCD has 3 bytes per pixel horizontally
1889                                let height = bmp.rows() as u32;
1890                                if width == 0 || height == 0 {
1891                                    (0, 0, 0.0f32, 0.0f32, Vec::new())
1892                                } else {
1893                                    let left = slot.bitmap_left();
1894                                    let top = slot.bitmap_top();
1895                                    let ox = pg.x as f32 + left as f32;
1896                                    let oy = pg.y as f32 - top as f32;
1897                                    // Convert FT's LCD bitmap (packed RGBRGB...) into our RGBA mask rows
1898                                    let pitch = bmp.pitch().abs() as usize;
1899                                    let src = bmp.buffer();
1900                                    let mut rgba = vec![0u8; (width * height * 4) as usize];
1901                                    for row in 0..height as usize {
1902                                        let row_start = row * pitch;
1903                                        let row_end = row_start + (width as usize * 3);
1904                                        let src_row = &src[row_start..row_end];
1905                                        for x in 0..width as usize {
1906                                            let r = src_row[3 * x + 0];
1907                                            let g = src_row[3 * x + 1];
1908                                            let b = src_row[3 * x + 2];
1909                                            let i = (row * (width as usize) + x) * 4;
1910                                            match self.orientation {
1911                                                SubpixelOrientation::RGB => {
1912                                                    rgba[i + 0] = r;
1913                                                    rgba[i + 1] = g;
1914                                                    rgba[i + 2] = b;
1915                                                }
1916                                                SubpixelOrientation::BGR => {
1917                                                    rgba[i + 0] = b;
1918                                                    rgba[i + 1] = g;
1919                                                    rgba[i + 2] = r;
1920                                                }
1921                                            }
1922                                            rgba[i + 3] = 0;
1923                                        }
1924                                    }
1925                                    (width, height, ox, oy, rgba)
1926                                }
1927                            } else {
1928                                (0, 0, 0.0, 0.0, Vec::new())
1929                            }
1930                        } else {
1931                            (0, 0, 0.0, 0.0, Vec::new())
1932                        }
1933                    };
1934                    if w > 0 && h > 0 {
1935                        out.push(RasterizedGlyph {
1936                            offset: [ox, oy],
1937                            mask: SubpixelMask {
1938                                width: w,
1939                                height: h,
1940                                format: MaskFormat::Rgba8,
1941                                data,
1942                            },
1943                        });
1944                    }
1945                }
1946            }
1947            out
1948        }
1949
1950        fn line_metrics(&self, px: f32) -> Option<LineMetrics> {
1951            let key = px.max(1.0).round() as u32;
1952            if let Some(m) = self.metrics_cache.lock().unwrap().get(&key).copied() {
1953                return Some(m);
1954            }
1955            // Use FreeType size metrics if available
1956            if let Ok(lib) = freetype::Library::init() {
1957                if let Ok(face) = lib.new_memory_face(self.ft_bytes.clone(), 0) {
1958                    let target_ppem = (px.max(1.0) * 64.0) as isize; // 26.6 fixed
1959                    let _ = face.set_char_size(0, target_ppem, 72, 72);
1960                    if let Some(sm) = face.size_metrics() {
1961                        // Values are in 26.6ths of a point; convert to pixels
1962                        let asc = (sm.ascender >> 6) as f32;
1963                        let desc = ((-sm.descender) >> 6) as f32;
1964                        let height = (sm.height >> 6) as f32;
1965                        let line_gap = (height - (asc + desc)).max(0.0);
1966                        let lm = LineMetrics {
1967                            ascent: asc,
1968                            descent: desc,
1969                            line_gap,
1970                        };
1971                        self.metrics_cache.lock().unwrap().insert(key, lm);
1972                        return Some(lm);
1973                    }
1974                }
1975            }
1976            // Fallback heuristic
1977            let ascent = px * 0.8;
1978            let descent = px * 0.2;
1979            let line_gap = (px * 1.2 - (ascent + descent)).max(0.0);
1980            let lm = LineMetrics {
1981                ascent,
1982                descent,
1983                line_gap,
1984            };
1985            self.metrics_cache.lock().unwrap().insert(key, lm);
1986            Some(lm)
1987        }
1988    }
1989
1990    pub use FreeTypeProvider as Provider;
1991}
1992
1993#[cfg(feature = "freetype_ffi")]
1994pub use freetype_provider::Provider as FreeTypeProvider;
1995
1996#[cfg(test)]
1997mod tests {
1998    use super::*;
1999
2000    #[test]
2001    fn parse_simple_font_family() {
2002        let result = parse_font_family_stack("Georgia");
2003        assert_eq!(result, vec![FontFamilyCandidate::Name("Georgia".into())]);
2004    }
2005
2006    #[test]
2007    fn parse_font_stack_with_generic() {
2008        let result = parse_font_family_stack("Georgia, \"Times New Roman\", Times, serif");
2009        assert_eq!(
2010            result,
2011            vec![
2012                FontFamilyCandidate::Name("Georgia".into()),
2013                FontFamilyCandidate::Name("Times New Roman".into()),
2014                FontFamilyCandidate::Name("Times".into()),
2015                FontFamilyCandidate::Generic(GenericFamily::Serif),
2016            ]
2017        );
2018    }
2019
2020    #[test]
2021    fn parse_sans_serif_stack() {
2022        let result = parse_font_family_stack(
2023            "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
2024        );
2025        assert_eq!(
2026            result,
2027            vec![
2028                FontFamilyCandidate::Generic(GenericFamily::SystemUi),
2029                FontFamilyCandidate::Generic(GenericFamily::SystemUi),
2030                FontFamilyCandidate::Name("Segoe UI".into()),
2031                FontFamilyCandidate::Name("Roboto".into()),
2032                FontFamilyCandidate::Generic(GenericFamily::SansSerif),
2033            ]
2034        );
2035    }
2036
2037    #[test]
2038    fn parse_monospace_stack() {
2039        let result = parse_font_family_stack("'SF Mono', ui-monospace, monospace");
2040        assert_eq!(
2041            result,
2042            vec![
2043                FontFamilyCandidate::Name("SF Mono".into()),
2044                FontFamilyCandidate::Generic(GenericFamily::Monospace),
2045                FontFamilyCandidate::Generic(GenericFamily::Monospace),
2046            ]
2047        );
2048    }
2049
2050    #[test]
2051    fn parse_empty_and_whitespace() {
2052        assert!(parse_font_family_stack("").is_empty());
2053        assert!(parse_font_family_stack("  ,  , ").is_empty());
2054    }
2055
2056    #[test]
2057    fn generic_families_case_insensitive() {
2058        let result = parse_font_family_stack("SERIF, Sans-Serif, MONOSPACE");
2059        assert_eq!(
2060            result,
2061            vec![
2062                FontFamilyCandidate::Generic(GenericFamily::Serif),
2063                FontFamilyCandidate::Generic(GenericFamily::SansSerif),
2064                FontFamilyCandidate::Generic(GenericFamily::Monospace),
2065            ]
2066        );
2067    }
2068
2069    #[test]
2070    fn cache_key_case_insensitive() {
2071        let k1 = JagTextProvider::cache_key_for(&FontFamilyCandidate::Name("Georgia".into()));
2072        let k2 = JagTextProvider::cache_key_for(&FontFamilyCandidate::Name("georgia".into()));
2073        assert_eq!(k1, k2);
2074    }
2075
2076    #[test]
2077    fn cache_key_generic_distinct() {
2078        let serif =
2079            JagTextProvider::cache_key_for(&FontFamilyCandidate::Generic(GenericFamily::Serif));
2080        let sans =
2081            JagTextProvider::cache_key_for(&FontFamilyCandidate::Generic(GenericFamily::SansSerif));
2082        assert_ne!(serif, sans);
2083    }
2084
2085    #[test]
2086    fn register_web_font_invalid_data_fails() {
2087        let provider = JagTextProvider::from_system_fonts(SubpixelOrientation::RGB);
2088        if provider.is_err() {
2089            // Skip on systems without fonts (CI containers)
2090            return;
2091        }
2092        let provider = provider.unwrap();
2093
2094        // Invalid font data should return error
2095        let result = provider.register_web_font(
2096            "TestFont",
2097            vec![0, 0, 0, 0],
2098            400,
2099            crate::scene::FontStyle::Normal,
2100        );
2101        assert!(result.is_err(), "Invalid font data should return error");
2102    }
2103}