Skip to main content

rosace_render/
font.rs

1use std::cell::RefCell;
2use std::collections::HashMap;
3use std::sync::Arc;
4
5use swash::scale::{Render, ScaleContext, Source};
6use swash::{CacheKey, FontRef, GlyphId};
7
8/// Text weight. Maps onto real font faces: `SemiBold`/`Bold` use the bold
9/// face when one was found; `Light`/`Regular`/`Medium` use the regular face.
10/// Before this existed the field was silently ignored — headings were never
11/// actually bold.
12#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
13pub enum FontWeight {
14    Light,
15    #[default]
16    Regular,
17    Medium,
18    SemiBold,
19    Bold,
20}
21
22impl FontWeight {
23    #[inline]
24    fn wants_bold(self) -> bool {
25        matches!(self, FontWeight::SemiBold | FontWeight::Bold)
26    }
27}
28
29/// Which face a glyph resolved to: primary regular/bold, the registered
30/// icon face, or a Unicode fallback face by index.
31type FaceKey = u8;
32const FACE_REGULAR: FaceKey = 0;
33const FACE_BOLD: FaceKey = 1;
34const FACE_ICON: FaceKey = 2;
35const FACE_FALLBACK_BASE: FaceKey = 128;
36
37type GlyphCacheKey = (FaceKey, char, u32); // (face, char, px.to_bits())
38type ColorGlyphKey = (char, u32); // (char, px.to_bits())
39type KernKey = (FaceKey, char, char);
40/// `(raw kern value in design units, units_per_em)` — `None` = no kerning
41/// for this pair (still a cached, not-yet-parsed-again fact).
42type KernEntry = Option<(i16, u16)>;
43
44/// Rasterized glyph metrics — deliberately the SAME shape/field names
45/// `fontdue::Metrics` had (D127 rasterizer migration, 2026-08-03): every
46/// consumer (`canvas.rs`'s CPU blit and GPU-atlas paths) reads `.width`/
47/// `.height`/`.xmin`/`.ymin`/`.advance_width` off this — keeping the shape
48/// identical meant the whole blit/atlas pipeline needed ZERO changes for
49/// the fontdue -> swash swap, only this file (glyph PRODUCTION) changed.
50#[derive(Debug, Clone, Copy, Default)]
51pub struct GlyphMetrics {
52    pub xmin: i32,
53    pub ymin: i32,
54    pub width: usize,
55    pub height: usize,
56    pub advance_width: f32,
57}
58
59/// Shared rasterized glyph: metrics + coverage bitmap.
60pub type CachedGlyph = Arc<(GlyphMetrics, Vec<u8>)>;
61
62/// An owned font face. `swash::FontRef` only ever BORROWS a byte slice —
63/// it's not meant to be stored long-term — so we keep the bytes ourselves
64/// and reconstruct a `FontRef` on demand via [`Self::as_ref`], preserving
65/// `offset`/`key` exactly as swash's own doc comment on `FontRef`
66/// recommends (a fresh `FontRef::from_index` call on every access would
67/// mint a new `CacheKey` each time and defeat swash's internal caching).
68pub struct OwnedFace {
69    data: Arc<Vec<u8>>,
70    offset: u32,
71    key: CacheKey,
72    /// `Some(weight)` when this face is a variable font that needs an
73    /// explicit `wght` axis instanced to render at all correctly — see
74    /// `system_ui()`'s doc for why (fontdue, the previous rasterizer,
75    /// couldn't do this at all; this is the whole reason for this
76    /// migration). `None` for an ordinary static face — no variation
77    /// settings needed, and passing an empty settings list is harmless
78    /// either way.
79    variable_weight: Option<f32>,
80}
81
82impl OwnedFace {
83    /// Load an in-memory face at `wght: 400` if variable — the right
84    /// default for icon fonts (`icon.rs`'s own bundled Material Symbols
85    /// face is itself variable, "FILL 0, wght 400" being its documented
86    /// intended default instance) and any other single-weight custom face.
87    pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
88        Self::new(bytes.to_vec(), 0, 400.0)
89    }
90
91    /// `weight` is the `wght` axis value to request IF this turns out to be
92    /// a variable font (e.g. `400.0` for a "regular" candidate, `700.0` for
93    /// a "bold" one) — ignored entirely for static faces.
94    fn new(bytes: Vec<u8>, index: u32, weight: f32) -> Option<Self> {
95        let data = Arc::new(bytes);
96        let (offset, key, is_variable) = {
97            let font = FontRef::from_index(&data, index as usize)?;
98            (font.offset, font.key, font.variations().len() > 0)
99        };
100        Some(Self {
101            data,
102            offset,
103            key,
104            variable_weight: is_variable.then_some(weight),
105        })
106    }
107
108    /// Reconstructs a cheap `swash::FontRef` borrowing this face's bytes,
109    /// preserving `offset`/`key` (see this struct's doc). Named `font_ref`,
110    /// not `as_ref`, to avoid silently resolving to `Arc<OwnedFace>`'s OWN
111    /// unrelated `as_ref()` (`AsRef<OwnedFace>`) at call sites that hold an
112    /// `&Arc<OwnedFace>` — a real footgun caught by the compiler once, not
113    /// worth re-risking with a same-named method.
114    fn font_ref(&self) -> FontRef<'_> {
115        FontRef { data: &self.data, offset: self.offset, key: self.key }
116    }
117}
118
119enum Fallback {
120    Untried(&'static str),
121    Missing,
122    Loaded(OwnedFace),
123}
124
125/// Color-emoji fallback face (Phase 32 Step 4, D115): raw bytes retained —
126/// swash only rasterizes vector OUTLINES via the `Source::Outline` path we
127/// use, but color emoji glyphs live in a bitmap table (`sbix` on macOS:
128/// literally an embedded PNG per glyph per size, "up to the caller to
129/// decode" per `ttf-parser`'s own doc comment), so decoding needs
130/// `ttf_parser::Face` directly, re-parsed from these bytes on each lookup
131/// (parsing itself is cheap — no re-reading the outline tables swash
132/// already indexed).
133enum EmojiFallback {
134    Untried,
135    Missing,
136    Loaded(Arc<Vec<u8>>),
137}
138
139/// One decoded color glyph: real advance width (font units, ttf_parser's
140/// own metric — NOT approximated from bitmap size) + a premultiplied RGBA8
141/// bitmap (`tiny_skia::Pixmap::decode_png`'s own convention — the SAME one
142/// `rosace-render::image`'s `Image` widget already decodes PNGs with, so
143/// this reuses an already-consistent pixel-format contract, not a new one)
144/// at whatever `sbix` strike size `glyph_raster_image` picked (nearest
145/// available, not rescaled to the exact requested px — a named
146/// simplification; see `color_glyph_rgba`'s doc).
147pub struct ColorGlyph {
148    pub advance: f32,
149    pub width: u32,
150    pub height: u32,
151    /// `Arc`, not a plain `Vec` — matches `rosace_render::canvas::ImagePixels`
152    /// (the `Image` widget's own blit-source wrapper), so the GPU-shapes path
153    /// clones a refcount instead of the pixel bytes every repaint frame.
154    pub rgba: Arc<Vec<u8>>,
155}
156
157/// Emoji fallback candidates per platform, in priority order — mirrors
158/// `FALLBACK_PATHS`'s own per-platform-paths convention. Only macOS is
159/// covered today (Apple Color Emoji, `sbix`); Windows (Segoe UI Emoji,
160/// COLR/CPAL — a different table `ttf-parser` also supports via
161/// `paint_color_glyph`, not wired here) and Linux (Noto Color Emoji, CBDT)
162/// are a named, honest gap, not silently assumed to work.
163const EMOJI_FALLBACK_PATHS: &[&str] = &[
164    "/System/Library/Fonts/Apple Color Emoji.ttc",
165];
166
167/// Common emoji Unicode blocks — used to decide whether a character should
168/// even ATTEMPT the color-glyph path (most text never does, so this check
169/// must be cheap and must not itself trigger loading the emoji font).
170/// Deliberately covers the well-known blocks, not a byte-for-byte match of
171/// Unicode's own emoji-data.txt (that table also includes plain digits/`#`
172/// as "emoji-capable" via keycap sequences — out of scope for this pass).
173fn is_emoji_codepoint(c: char) -> bool {
174    matches!(c as u32,
175        0x1F300..=0x1FAFF // Misc Symbols&Pictographs, Emoticons, Transport, Supplemental Symbols&Pictographs, Symbols&Pictographs Ext-A
176        | 0x2600..=0x27BF // Misc Symbols, Dingbats
177        | 0x2190..=0x21FF // Arrows (subset render as emoji with presentation)
178        | 0x2B00..=0x2BFF // Misc Symbols and Arrows
179        | 0x1F1E6..=0x1F1FF // Regional indicators (flags)
180    )
181}
182
183pub struct FontCache {
184    font: OwnedFace,
185    /// Real bold face when the platform provides one; None → bold renders
186    /// with the regular face (as before).
187    bold: Option<OwnedFace>,
188    /// In-memory icon face (D115/Phase 32 Step 2) — registered once by the
189    /// widget layer, consulted when the primary faces miss a codepoint and
190    /// BEFORE the disk fallback chain: icon fonts live in the Private Use
191    /// Area, where system fallback faces (Apple Symbols et al.) carry their
192    /// own unrelated glyphs.
193    icon: RefCell<Option<Arc<OwnedFace>>>,
194    /// Unicode fallback faces, loaded lazily on the first glyph miss —
195    /// Arial Unicode alone is ~20 MB, so we don't parse it until a CJK or
196    /// symbol codepoint actually appears.
197    fallbacks: RefCell<Vec<Fallback>>,
198    /// (char, wants_bold) → resolved face. Routing is per-character.
199    route_cache: RefCell<HashMap<(char, bool), FaceKey>>,
200    glyph_cache: RefCell<HashMap<GlyphCacheKey, CachedGlyph>>,
201    metrics_cache: RefCell<HashMap<GlyphCacheKey, f32>>,
202    /// Color-emoji fallback face (Phase 32 Step 4) — raw bytes, loaded
203    /// lazily on the first emoji-range character (same "don't pay for it
204    /// until needed" principle as `fallbacks` above).
205    emoji: RefCell<EmojiFallback>,
206    /// Decoded color glyphs, keyed like `glyph_cache` — PNG decode is real
207    /// work (unlike a cached rasterize, which is already cheap), so this
208    /// cache matters more, not less.
209    color_glyph_cache: RefCell<HashMap<ColorGlyphKey, Option<Arc<ColorGlyph>>>>,
210    /// Raw (design-units, NOT px-scaled) kern value per `(face, left, right)`
211    /// pair — `None` means "no kerning for this pair" (still cached, so a
212    /// miss doesn't re-parse every call). Design-units instead of px-keyed
213    /// like `metrics_cache`/`glyph_cache`: kerning is size-independent
214    /// until the final `/ units_per_em * px` scale, so one cache entry
215    /// serves EVERY font size a pair is ever asked about, not just one.
216    /// Exists because `kern_weighted` re-parses the whole font's `kern`
217    /// table via `ttf_parser::Face::parse` on every call — found live
218    /// (2026-08-03): with no caching at all, that ran on every character
219    /// pair of every string on every single paint frame and made the app
220    /// "super slow" — a real regression this cache fixes, not a
221    /// premature optimization.
222    kern_cache: RefCell<HashMap<KernKey, KernEntry>>,
223    /// swash's scaling context — owns its own internal LRU caches/scratch
224    /// buffers (per swash's own docs: "keep one instance per thread"). One
225    /// per `FontCache`, `RefCell`-wrapped to match every other cache field
226    /// here (`FontCache` is already `!Sync` via those).
227    scale_ctx: RefCell<ScaleContext>,
228}
229
230/// Unicode fallback candidates per platform. Order = priority. Coverage:
231/// Arial Unicode (huge BMP incl. CJK), Apple Symbols (arrows, misc),
232/// Noto (Linux), Segoe Symbol / MS Gothic (Windows).
233const FALLBACK_PATHS: &[&str] = &[
234    "/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
235    "/System/Library/Fonts/Apple Symbols.ttf",
236    "/System/Library/Fonts/Supplemental/Zapf Dingbats.ttf",
237    "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
238    "/usr/share/fonts/truetype/noto/NotoSansSymbols-Regular.ttf",
239    "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
240    "C:\\Windows\\Fonts\\seguisym.ttf",
241    "C:\\Windows\\Fonts\\msgothic.ttc",
242];
243
244/// Bold-face candidates paired with nothing in particular — the first that
245/// exists wins. (macOS ships most UI families as .ttc collections without a
246/// reliable index → member mapping, so we use the standalone bold files.)
247const BOLD_PATHS: &[&str] = &[
248    "/System/Library/Fonts/Supplemental/Arial Bold.ttf",
249    "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
250    "/usr/share/fonts/truetype/ubuntu/Ubuntu-B.ttf",
251    "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
252    "C:\\Windows\\Fonts\\segoeuib.ttf",
253    "C:\\Windows\\Fonts\\arialbd.ttf",
254    // Android — stable AOSP path since Android 4.x on every stock/AOSP-based
255    // device (D127 "environment" track: real system font, read not bundled).
256    "/system/fonts/Roboto-Bold.ttf",
257];
258
259impl FontCache {
260    fn build(font: OwnedFace, bold: Option<OwnedFace>) -> Self {
261        Self {
262            font,
263            bold,
264            icon: RefCell::new(None),
265            fallbacks: RefCell::new(
266                FALLBACK_PATHS.iter().map(|p| Fallback::Untried(p)).collect(),
267            ),
268            route_cache: RefCell::new(HashMap::new()),
269            glyph_cache: RefCell::new(HashMap::new()),
270            metrics_cache: RefCell::new(HashMap::new()),
271            emoji: RefCell::new(EmojiFallback::Untried),
272            color_glyph_cache: RefCell::new(HashMap::new()),
273            kern_cache: RefCell::new(HashMap::new()),
274            scale_ctx: RefCell::new(ScaleContext::new()),
275        }
276    }
277
278    pub fn from_bytes(bytes: &[u8]) -> Self {
279        let font = OwnedFace::new(bytes.to_vec(), 0, 400.0)
280            .expect("invalid font bytes");
281        Self::build(font, None)
282    }
283
284    /// Load a font from a bundled **asset** by logical name — resolved
285    /// per-platform via [`rosace_core::asset`] (dev: `assets/<name>`; mobile:
286    /// the app bundle). Returns `None` if the asset is missing or not a valid
287    /// font, so callers can fall back to [`default`](Self::default)/[`embedded`].
288    ///
289    /// ```ignore
290    /// let brand = FontCache::from_asset("fonts/Brand.ttf")
291    ///     .unwrap_or_else(FontCache::default);
292    /// ```
293    pub fn from_asset(name: impl rosace_core::asset::AssetRef) -> Option<Self> {
294        let bytes = rosace_core::asset::bytes(name)?;
295        let font = OwnedFace::new(bytes, 0, 400.0)?;
296        Some(Self::build(font, None))
297    }
298
299    /// A fallback font compiled into the binary — DejaVu Sans (permissive
300    /// Bitstream Vera license). Used when no system font is available, most
301    /// importantly on the web/wasm target where `system_ui()` finds nothing.
302    /// Guarantees text always renders on every platform.
303    pub fn embedded() -> Self {
304        const DEJAVU_SANS: &[u8] =
305            include_bytes!("../assets/fonts/DejaVuSans.ttf");
306        Self::from_bytes(DEJAVU_SANS)
307    }
308
309    /// The DEFAULT app font (Phase 32, user-decided): bundled Inter (SIL
310    /// OFL — this crate's own `assets/fonts/inter/LICENSE-OFL.txt`), the same pleasant,
311    /// screen-tuned face on EVERY platform with clearly differentiable
312    /// weights — Regular for body, real Bold (700) for emphasis. Replaces
313    /// "whatever the OS ships" as the default (`system_ui()` remains
314    /// available as an opt-in); also replaces the short-lived
315    /// Medium-by-default experiment, which read slightly bold.
316    ///
317    /// Italic faces (`Inter-Italic`/`Inter-BoldItalic`) are bundled
318    /// alongside but not yet wired — the text pipeline has no italic
319    /// axis yet (tracked in `PHASE_32.md`).
320    pub fn bundled() -> Self {
321        const INTER_REGULAR: &[u8] =
322            include_bytes!("../assets/fonts/inter/Inter-Regular.ttf");
323        const INTER_BOLD: &[u8] =
324            include_bytes!("../assets/fonts/inter/Inter-Bold.ttf");
325        let regular = OwnedFace::new(INTER_REGULAR.to_vec(), 0, 400.0)
326            .expect("bundled Inter Regular is valid");
327        let bold = OwnedFace::new(INTER_BOLD.to_vec(), 0, 700.0)
328            .expect("bundled Inter Bold is valid");
329        Self::build(regular, Some(bold))
330    }
331
332    fn load_first(paths: &[&str], weight: f32) -> Option<OwnedFace> {
333        for path in paths {
334            if let Ok(bytes) = std::fs::read(path) {
335                if let Some(f) = OwnedFace::new(bytes, 0, weight) {
336                    return Some(f);
337                }
338            }
339        }
340        None
341    }
342
343    /// Score how well `name` (a face's full/typographic name) matches the
344    /// weight we're looking for. Higher is better; `None` means "not a
345    /// candidate at all" for this weight.
346    ///
347    /// This exists because `.ttc` collections (how macOS ships every UI
348    /// family — Avenir Next, Helvetica Neue, ...) do NOT put the Regular
349    /// face at index 0. Naively loading a `.ttc` at index 0 silently picks
350    /// WHATEVER face happens to be first — on Avenir Next.ttc that's
351    /// actually "Avenir Next Bold". Loading that as "regular" and then
352    /// falling back to an unrelated Arial Bold for "bold" produces two
353    /// different type families where the nominal "bold" face is visually
354    /// THINNER than the nominal "regular" one — bold becomes visually
355    /// indistinguishable (or reversed) from regular. Real fix: read the
356    /// name table and pick the actual matching face for each weight, from
357    /// the same family when possible.
358    fn weight_score(name: &str, want_bold: bool) -> Option<i32> {
359        let n = name.to_ascii_lowercase();
360        if n.contains("italic") || n.contains("oblique") {
361            return None;
362        }
363        if want_bold {
364            if n.ends_with("bold") && !n.contains("semi") && !n.contains("demi")
365                && !n.contains("ultra") && !n.contains("extra")
366            {
367                return Some(3);
368            }
369            if n.contains("bold") { return Some(2); }
370            if n.contains("heavy") || n.contains("black") { return Some(1); }
371            None
372        } else {
373            if n.ends_with("regular") || n == "regular" { return Some(3); }
374            if !n.contains("bold") && !n.contains("black") && !n.contains("heavy")
375                && !n.contains("light") && !n.contains("thin") && !n.contains("medium")
376                && !n.contains("demi") && !n.contains("semi") && !n.contains("condensed")
377                && !n.contains("narrow") && !n.contains("ultra") && !n.contains("extra")
378            {
379                return Some(2);
380            }
381            Some(0)
382        }
383    }
384
385    fn face_name(bytes: &[u8], index: u32) -> Option<String> {
386        let face = ttf_parser::Face::parse(bytes, index).ok()?;
387        face.names().into_iter()
388            .find(|n| n.name_id == 4 && n.is_unicode())
389            .and_then(|n| n.to_string())
390    }
391
392    /// Pick the best-matching face index in `bytes` for `want_bold`, or
393    /// `None` if it isn't a (usable) collection / no good candidate exists.
394    fn best_face_index(bytes: &[u8], want_bold: bool) -> Option<u32> {
395        let n = ttf_parser::fonts_in_collection(bytes).unwrap_or(1);
396        let mut best: Option<(i32, u32)> = None;
397        for i in 0..n {
398            let Some(name) = Self::face_name(bytes, i) else { continue };
399            let Some(score) = Self::weight_score(&name, want_bold) else { continue };
400            if best.map(|(s, _)| score > s).unwrap_or(true) {
401                best = Some((score, i));
402            }
403        }
404        best.map(|(_, i)| i)
405    }
406
407    fn load_face(bytes: &[u8], index: u32, weight: f32) -> Option<OwnedFace> {
408        OwnedFace::new(bytes.to_vec(), index, weight)
409    }
410
411    /// Load a system proportional / UI font plus (when available) a real
412    /// bold face and the Unicode fallback chain. Prefers a same-family
413    /// bold face found inside the regular candidate's own file (see
414    /// [`Self::weight_score`]); only falls back to the unrelated
415    /// `BOLD_PATHS` standalone files when the chosen family has no bold
416    /// member of its own (e.g. plain `Arial.ttf`, which IS the regular
417    /// face and needs the separate `Arial Bold.ttf`).
418    pub fn system_ui() -> Option<Self> {
419        let candidates = [
420            // macOS — the REAL San Francisco file. Previously excluded here
421            // (fontdue, the old rasterizer, had zero variable-font support
422            // and rendered this as broken hairlines) — the whole point of
423            // the fontdue -> swash migration (D127, 2026-08-03) was to make
424            // this candidate usable: `OwnedFace` detects the variable `wght`
425            // axis and `glyph_weighted` instances it explicitly (400/700)
426            // instead of reading swash's/skrifa's un-instanced default.
427            "/System/Library/Fonts/SFNS.ttf",
428            "/System/Library/Fonts/Avenir Next.ttc",
429            "/System/Library/Fonts/HelveticaNeue.ttc",
430            "/System/Library/Fonts/Helvetica.ttc",
431            "/System/Library/Fonts/Supplemental/Arial.ttf",
432            // Linux
433            "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
434            "/usr/share/fonts/truetype/ubuntu/Ubuntu-R.ttf",
435            "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
436            // Windows
437            "C:\\Windows\\Fonts\\segoeui.ttf",
438            "C:\\Windows\\Fonts\\arial.ttf",
439            // Android — Roboto has shipped at this exact path on every
440            // stock/AOSP-based device since Android 4.x (D127 "environment"
441            // track). A real system-font read, not a bundled/redistributed
442            // copy — same reasoning as the desktop paths above.
443            "/system/fonts/Roboto-Regular.ttf",
444        ];
445        for path in candidates {
446            let Ok(bytes) = std::fs::read(path) else { continue };
447            let reg_idx = Self::best_face_index(&bytes, false).unwrap_or(0);
448            let Some(regular) = Self::load_face(&bytes, reg_idx, 400.0) else { continue };
449            let bold = Self::best_face_index(&bytes, true)
450                .and_then(|i| Self::load_face(&bytes, i, 700.0))
451                .or_else(|| Self::load_first(BOLD_PATHS, 700.0));
452            return Some(Self::build(regular, bold));
453        }
454        None
455    }
456
457    /// Load a system monospace font (Menlo, Courier, DejaVu Mono, etc.).
458    pub fn system_mono() -> Option<Self> {
459        let candidates = [
460            "/System/Library/Fonts/Menlo.ttc",
461            "/System/Library/Fonts/Monaco.ttf",
462            "/System/Library/Fonts/Supplemental/Courier New.ttf",
463            "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
464            "/usr/share/fonts/truetype/ubuntu/UbuntuMono-R.ttf",
465            "/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
466            "C:\\Windows\\Fonts\\consola.ttf",
467            "/system/fonts/DroidSansMono.ttf",
468        ];
469        let regular = Self::load_first(&candidates, 400.0)?;
470        Some(Self::build(regular, None))
471    }
472
473    // ── Icon face (D115/Phase 32 Step 2) ─────────────────────────────────
474
475    /// Install an in-memory icon face — glyphs the primary faces miss route
476    /// to it before the disk fallback chain, so icon-font codepoints (PUA)
477    /// flow through the ordinary text path: physical-px rasterization,
478    /// glyph cache, and the GPU glyph atlas, with zero new draw commands.
479    ///
480    /// Idempotent: the first registration wins; later calls are no-ops.
481    /// Registration clears the route cache so codepoints resolved earlier
482    /// (as tofu) re-route to the new face.
483    pub fn set_icon_face(&self, font: Arc<OwnedFace>) {
484        {
485            let mut slot = self.icon.borrow_mut();
486            if slot.is_some() {
487                return;
488            }
489            *slot = Some(font);
490        }
491        self.route_cache.borrow_mut().clear();
492    }
493
494    /// True once an icon face is installed — lets callers skip
495    /// re-registration on every paint.
496    pub fn has_icon_face(&self) -> bool {
497        self.icon.borrow().is_some()
498    }
499
500    // ── Face routing (Unicode fallback, D-text) ──────────────────────────
501
502    /// Resolve which face renders `c` at `weight`: bold face when requested
503    /// and it has the glyph; else regular; else the first fallback face that
504    /// covers the codepoint (loaded lazily); else regular (tofu).
505    fn resolve(&self, c: char, weight: FontWeight) -> FaceKey {
506        let wants_bold = weight.wants_bold() && self.bold.is_some();
507        let key = (c, wants_bold);
508        if let Some(&f) = self.route_cache.borrow().get(&key) {
509            return f;
510        }
511
512        let face = if wants_bold && self.bold.as_ref().unwrap().font_ref().charmap().map(c) != 0 {
513            FACE_BOLD
514        } else if self.font.font_ref().charmap().map(c) != 0 {
515            FACE_REGULAR
516        } else if self
517            .icon
518            .borrow()
519            .as_ref()
520            .is_some_and(|f| f.font_ref().charmap().map(c) != 0)
521        {
522            FACE_ICON
523        } else {
524            let mut found = FACE_REGULAR; // tofu in the primary face
525            let mut fallbacks = self.fallbacks.borrow_mut();
526            for (i, slot) in fallbacks.iter_mut().enumerate() {
527                if let Fallback::Untried(path) = slot {
528                    *slot = match std::fs::read(path).ok().and_then(|b| OwnedFace::new(b, 0, 400.0)) {
529                        Some(f) => Fallback::Loaded(f),
530                        None => Fallback::Missing,
531                    };
532                }
533                if let Fallback::Loaded(f) = slot {
534                    if f.font_ref().charmap().map(c) != 0 {
535                        found = FACE_FALLBACK_BASE + i as FaceKey;
536                        break;
537                    }
538                }
539            }
540            found
541        };
542
543        self.route_cache.borrow_mut().insert(key, face);
544        face
545    }
546
547    /// Run `f` with the resolved face's `OwnedFace`.
548    fn with_face<R>(&self, face: FaceKey, f: impl FnOnce(&OwnedFace) -> R) -> R {
549        if face == FACE_BOLD {
550            if let Some(b) = &self.bold {
551                return f(b);
552            }
553        } else if face == FACE_ICON {
554            let icon = self.icon.borrow();
555            if let Some(i) = icon.as_ref() {
556                return f(i);
557            }
558        } else if face >= FACE_FALLBACK_BASE {
559            let fallbacks = self.fallbacks.borrow();
560            if let Some(Fallback::Loaded(fb)) = fallbacks.get((face - FACE_FALLBACK_BASE) as usize) {
561                return f(fb);
562            }
563        }
564        f(&self.font)
565    }
566
567    // ── Glyphs ────────────────────────────────────────────────────────────
568
569    /// Rasterizes `c` from `owned` at `px` — the one place that actually
570    /// talks to swash's scaler, producing metrics and the coverage bitmap
571    /// from the SAME `Render` call (a second call would re-rasterize the
572    /// same glyph twice for no reason). Applies the face's `wght` variation
573    /// axis when it's a variable font (see `OwnedFace::variable_weight`'s doc).
574    fn rasterize_glyph(&self, owned: &OwnedFace, c: char, px: f32) -> (GlyphMetrics, Vec<u8>) {
575        let font_ref = owned.font_ref();
576        let glyph_id: GlyphId = font_ref.charmap().map(c);
577        let advance = font_ref.glyph_metrics(&[]).scale(px).advance_width(glyph_id);
578        let mut ctx = self.scale_ctx.borrow_mut();
579        let mut builder = ctx.builder(font_ref).size(px).hint(true);
580        if let Some(w) = owned.variable_weight {
581            builder = builder.variations(&[("wght", w)]);
582        }
583        let mut scaler = builder.build();
584        let Some(image) = Render::new(&[Source::Outline]).render(&mut scaler, glyph_id) else {
585            return (GlyphMetrics { advance_width: advance, ..Default::default() }, Vec::new());
586        };
587        let metrics = GlyphMetrics {
588            xmin: image.placement.left,
589            // swash's `Placement.top` is the offset from the glyph origin
590            // (baseline) to the bitmap's top edge, positive = ABOVE the
591            // baseline (font/outline Y-up convention) — opposite of
592            // fontdue's `ymin` (bottom-edge offset). `ymin` here is
593            // reconstructed as `top - height` so downstream code
594            // (`layout_glyphs`'s `base_y - ymin - height`) keeps working
595            // unchanged. Verified against real rendered output, not just
596            // read from swash's source — see this migration's live-test step.
597            ymin: image.placement.top - image.placement.height as i32,
598            width: image.placement.width as usize,
599            height: image.placement.height as usize,
600            advance_width: advance,
601        };
602        (metrics, image.data)
603    }
604
605    /// Shared handle to the cached glyph for `c` at `px`/`weight` —
606    /// routed through the bold face and Unicode fallbacks.
607    pub fn glyph_weighted(&self, c: char, px: f32, weight: FontWeight) -> CachedGlyph {
608        let face = self.resolve(c, weight);
609        let key = (face, c, px.to_bits());
610        {
611            let cache = self.glyph_cache.borrow();
612            if let Some(entry) = cache.get(&key) {
613                return Arc::clone(entry);
614            }
615        }
616        let (metrics, bytes) = self.with_face(face, |f| self.rasterize_glyph(f, c, px));
617        let entry = Arc::new((metrics, bytes));
618        self.glyph_cache.borrow_mut().insert(key, Arc::clone(&entry));
619        entry
620    }
621
622    /// Regular-weight glyph (hot path for plain text).
623    pub fn glyph(&self, c: char, px: f32) -> CachedGlyph {
624        self.glyph_weighted(c, px, FontWeight::Regular)
625    }
626
627    /// Rasterize a single character (copies the bitmap — prefer
628    /// [`FontCache::glyph`] in hot paths).
629    pub fn rasterize(&self, c: char, px: f32) -> (GlyphMetrics, Vec<u8>) {
630        let glyph = self.glyph(c, px);
631        (glyph.0, glyph.1.clone())
632    }
633
634    /// Lazily load the color-emoji fallback face's raw bytes (first
635    /// emoji-range character only — same principle as `fallbacks`).
636    fn emoji_bytes(&self) -> Option<Arc<Vec<u8>>> {
637        {
638            match &*self.emoji.borrow() {
639                EmojiFallback::Loaded(b) => return Some(Arc::clone(b)),
640                EmojiFallback::Missing => return None,
641                EmojiFallback::Untried => {}
642            }
643        }
644        let found = EMOJI_FALLBACK_PATHS.iter()
645            .find_map(|p| std::fs::read(p).ok())
646            .map(Arc::new);
647        *self.emoji.borrow_mut() = match &found {
648            Some(b) => EmojiFallback::Loaded(Arc::clone(b)),
649            None => EmojiFallback::Missing,
650        };
651        found
652    }
653
654    /// Real color glyph for `c` at `px`, if `c` is in an emoji range AND the
655    /// emoji fallback face actually has a color bitmap for it (`sbix` only
656    /// today — see `EMOJI_FALLBACK_PATHS`'s doc for the Windows/Linux gap).
657    /// `None` for anything else, including a plain character that happens
658    /// to fail this lookup — callers fall through to the normal outline path.
659    pub fn color_glyph_rgba(&self, c: char, px: f32) -> Option<Arc<ColorGlyph>> {
660        if !is_emoji_codepoint(c) { return None; }
661
662        let cache_key = (c, px.to_bits());
663        if let Some(hit) = self.color_glyph_cache.borrow().get(&cache_key) {
664            return hit.clone();
665        }
666
667        let result = (|| {
668            let bytes = self.emoji_bytes()?;
669            let face = ttf_parser::Face::parse(&bytes, 0).ok()?;
670            let gid = face.glyph_index(c)?;
671            // NOT `face.is_color_glyph(gid)` — that method checks ONLY the
672            // `COLR`/`CPAL` layered-vector table (confirmed by reading
673            // ttf-parser's own source: `self.tables().colr...`), never
674            // `sbix`. Apple Color Emoji uses `sbix` exclusively, so that
675            // gate was always false here and this function always bailed —
676            // a real bug caught only by noticing the LIVE app rendered tofu
677            // boxes for real emoji despite an isolated unit test "passing"
678            // (its own graceful-skip-if-font-missing branch silently
679            // absorbed the same bug as a false "not installed" negative,
680            // instead of catching it — a real lesson, not just a fix).
681            // `glyph_raster_image` returning `Some` IS already proof this
682            // glyph has a real color bitmap; no separate gate is needed.
683            let img = face.glyph_raster_image(gid, px.round().clamp(1.0, u16::MAX as f32) as u16)?;
684            if img.format != ttf_parser::RasterImageFormat::PNG { return None; }
685            let pixmap = tiny_skia::Pixmap::decode_png(img.data).ok()?;
686            let units_per_em = face.units_per_em() as f32;
687            let advance = face.glyph_hor_advance(gid)
688                .map(|a| a as f32 / units_per_em * px)
689                .unwrap_or(pixmap.width() as f32);
690            Some(Arc::new(ColorGlyph {
691                advance,
692                width: pixmap.width(),
693                height: pixmap.height(),
694                rgba: Arc::new(pixmap.data().to_vec()),
695            }))
696        })();
697
698        self.color_glyph_cache.borrow_mut().insert(cache_key, result.clone());
699        result
700    }
701
702    /// Kerning between `left` and `right` at `px`/`weight`. Zero when the
703    /// pair spans different faces (fallback boundaries have no kern data),
704    /// or when the face has no `kern` table. Reads the `kern` table
705    /// directly via `ttf_parser` (already a dependency here for name-table/
706    /// collection-index introspection) — swash's own shaping module targets
707    /// full GPOS-based complex-script shaping, a bigger API than the simple
708    /// pairwise advance this UI-text layout model needs.
709    pub fn kern_weighted(&self, left: char, right: char, px: f32, weight: FontWeight) -> f32 {
710        let fl = self.resolve(left, weight);
711        if fl != self.resolve(right, weight) {
712            return 0.0;
713        }
714        let cache_key = (fl, left, right);
715        let cached = {
716            let cache = self.kern_cache.borrow();
717            cache.get(&cache_key).copied()
718        };
719        let entry = match cached {
720            Some(v) => v,
721            None => {
722                let v = self.with_face(fl, |owned| {
723                    let Ok(face) = ttf_parser::Face::parse(&owned.data, 0) else { return None };
724                    let (Some(l), Some(r)) = (face.glyph_index(left), face.glyph_index(right)) else { return None };
725                    let upem = face.units_per_em();
726                    let table = face.tables().kern?;
727                    let raw = table.subtables.into_iter().find_map(|st| st.glyphs_kerning(l, r))?;
728                    Some((raw, upem))
729                });
730                self.kern_cache.borrow_mut().insert(cache_key, v);
731                v
732            }
733        };
734        let Some((raw, units_per_em)) = entry else { return 0.0 };
735        if units_per_em == 0 { return 0.0; }
736        raw as f32 / units_per_em as f32 * px
737    }
738
739    pub fn kern(&self, left: char, right: char, px: f32) -> f32 {
740        self.kern_weighted(left, right, px, FontWeight::Regular)
741    }
742
743    /// Pixel advance width at `px`/`weight`. Cached, fallback-routed.
744    pub fn advance_width_weighted(&self, c: char, px: f32, weight: FontWeight) -> f32 {
745        let face = self.resolve(c, weight);
746        let key = (face, c, px.to_bits());
747        {
748            let cache = self.metrics_cache.borrow();
749            if let Some(&w) = cache.get(&key) {
750                return w;
751            }
752        }
753        let w = self.with_face(face, |owned| {
754            let font_ref = owned.font_ref();
755            let glyph_id = font_ref.charmap().map(c);
756            font_ref.glyph_metrics(&[]).scale(px).advance_width(glyph_id)
757        });
758        self.metrics_cache.borrow_mut().insert(key, w);
759        w
760    }
761
762    pub fn advance_width(&self, c: char, px: f32) -> f32 {
763        self.advance_width_weighted(c, px, FontWeight::Regular)
764    }
765
766    /// Total pixel width of a string at `px`/`weight` — advances plus
767    /// kerning, in lockstep with `SkiaCanvas::draw_text_weighted` so
768    /// measured and painted widths agree.
769    pub fn measure_text_weighted(&self, text: &str, px: f32, weight: FontWeight) -> f32 {
770        let px = px * rosace_core::media_query::use_media_query().text_scale;
771        let mut width = 0.0;
772        let mut prev: Option<char> = None;
773        for c in text.chars() {
774            if let Some(p) = prev {
775                width += self.kern_weighted(p, c, px, weight);
776            }
777            width += self.advance_width_weighted(c, px, weight);
778            prev = Some(c);
779        }
780        width
781    }
782
783    pub fn measure_text(&self, text: &str, px: f32) -> f32 {
784        self.measure_text_weighted(text, px, FontWeight::Regular)
785    }
786
787    /// Distance from the top of the line box to the baseline, in pixels.
788    /// Always from the primary face — mixed-face runs share one baseline.
789    pub fn ascender(&self, px: f32) -> i32 {
790        let font_ref = self.font.font_ref();
791        let m = font_ref.metrics(&[]).scale(px);
792        if m.ascent > 0.0 { m.ascent.round() as i32 } else { (px * 0.78) as i32 }
793    }
794
795    /// Full line height (ascender + descender + gap) in pixels.
796    pub fn line_height(&self, px: f32) -> f32 {
797        let font_ref = self.font.font_ref();
798        let m = font_ref.metrics(&[]).scale(px);
799        let total = m.ascent + m.descent + m.leading;
800        if total > 0.0 { total } else { px * 1.2 }
801    }
802}
803
804/// One glyph placed by [`layout_glyphs`]: the cached rasterization plus its
805/// top-left pixel position and a stable atlas key (D109/Phase 27 Step 4).
806pub struct PlacedGlyph {
807    pub glyph: CachedGlyph,
808    /// Top-left of the glyph bitmap, physical px.
809    pub x: i32,
810    pub y: i32,
811    /// Stable across frames: `px_bits << 32 | char << 1 | wants_bold`.
812    /// Face routing is deterministic per `(char, bold)`, so this fully
813    /// identifies the rasterization without exposing `FaceKey`.
814    pub key: u64,
815    /// `Some` for a color-emoji glyph (Phase 32 Step 4) — `glyph` above is
816    /// then a cheap zero-size placeholder (never read) and consumers must
817    /// blit this RGBA bitmap directly instead of using `glyph`'s coverage
818    /// mask. Reuses the same premultiplied-RGBA-quad pipeline
819    /// `DrawCommand::BlitRgba` (the `Image` widget) already established in
820    /// both the CPU and GPU-shapes paths, rather than adding a second
821    /// coverage-atlas page — real color rendering with no new render
822    /// primitive.
823    pub color_rgba: Option<Arc<ColorGlyph>>,
824}
825
826/// The one glyph-placement walk (kerning, baseline, bearing) shared by the
827/// CPU blit path (`SkiaCanvas::draw_text_weighted`) and the GPU atlas
828/// collect path — they MUST agree glyph-for-glyph, so the math lives once.
829///
830/// `origin` is the line box's top-left in physical px (the baseline is
831/// derived via [`FontCache::ascender`]); zero-size glyphs (spaces) advance
832/// the cursor but emit nothing.
833pub fn layout_glyphs(
834    font: &FontCache,
835    text: &str,
836    origin_x: f32,
837    origin_y: f32,
838    px: f32,
839    weight: FontWeight,
840) -> Vec<PlacedGlyph> {
841    let base_y = origin_y.round() as i32 + font.ascender(px);
842    let mut cursor_x = origin_x;
843    let mut prev: Option<char> = None;
844    let mut out = Vec::with_capacity(text.len());
845    let bold = weight.wants_bold() as u64;
846
847    for ch in text.chars() {
848        // Variation selectors (U+FE00-U+FE0F, e.g. the "emoji presentation"
849        // VS-16 that commonly follows a symbol like U+2600 SUN to request
850        // its color form — as in "☀️" — real bug found live: this demo's
851        // own "☀️ sunny" text rendered a stray tofu box for the selector
852        // itself, since it has no visible glyph in ANY face and wasn't
853        // being recognized as a zero-width modifier). Invisible by
854        // definition — skip entirely, no glyph lookup, no cursor advance.
855        if matches!(ch as u32, 0xFE00..=0xFE0F) {
856            continue; // invisible modifier — `prev` stays the last REAL glyph for correct kerning after it
857        }
858        if let Some(p) = prev {
859            cursor_x += font.kern_weighted(p, ch, px, weight);
860        }
861        prev = Some(ch);
862
863        // Color-emoji check first: a real emoji codepoint should never fall
864        // through to the outline rasterizer (the primary UI font has no
865        // glyph for it at all, or — worse — a plain monochrome fallback
866        // shape that isn't the real emoji).
867        if let Some(cg) = font.color_glyph_rgba(ch, px) {
868            let gx = cursor_x.round() as i32;
869            let gy = base_y - cg.height as i32; // bottom-aligned to baseline, left-aligned to cursor
870            let key = ((px.to_bits() as u64) << 32) | ((ch as u64) << 1) | bold | (1 << 63);
871            let placeholder: CachedGlyph = Arc::new((GlyphMetrics::default(), Vec::new()));
872            let advance = cg.advance;
873            out.push(PlacedGlyph { glyph: placeholder, x: gx, y: gy, key, color_rgba: Some(cg) });
874            cursor_x += advance;
875            continue;
876        }
877
878        let glyph = font.glyph_weighted(ch, px, weight);
879        let advance = glyph.0.advance_width;
880        if glyph.0.width != 0 && glyph.0.height != 0 {
881            let gx = cursor_x.round() as i32 + glyph.0.xmin;
882            let gy = base_y - glyph.0.ymin - glyph.0.height as i32;
883            let key = ((px.to_bits() as u64) << 32) | ((ch as u64) << 1) | bold;
884            out.push(PlacedGlyph { glyph, x: gx, y: gy, key, color_rgba: None });
885        }
886        cursor_x += advance;
887    }
888    out
889}
890
891#[cfg(test)]
892mod color_glyph_tests {
893    use super::*;
894
895    #[test]
896    fn is_emoji_codepoint_covers_common_emoji_but_not_plain_text() {
897        assert!(is_emoji_codepoint('😀')); // U+1F600, Emoticons block
898        assert!(is_emoji_codepoint('🎉')); // U+1F389, Misc Symbols & Pictographs
899        assert!(is_emoji_codepoint('☀'));  // U+2600, Misc Symbols
900        assert!(!is_emoji_codepoint('a'));
901        assert!(!is_emoji_codepoint('#'));
902        assert!(!is_emoji_codepoint(' '));
903    }
904
905    /// Real integration test, not a mock: decodes an ACTUAL emoji glyph from
906    /// whatever color-emoji font this machine has installed (this repo's
907    /// dev machines are macOS, where `/System/Library/Fonts/Apple Color
908    /// Emoji.ttc` is always present) — the exit bar this Phase 32 Step 4
909    /// task is actually held to ("a real running app renders a string
910    /// containing at least one emoji correctly, real color").
911    ///
912    /// The skip path checks the font FILE's existence directly, separately
913    /// from whether decode succeeded — a lesson from a real bug this test
914    /// almost hid: an earlier version skipped whenever `color_glyph_rgba`
915    /// returned `None`, which is EXACTLY what it did (every time) while a
916    /// real bug (`is_color_glyph` checking the wrong table) was silently
917    /// swallowing every lookup on this very machine, where the font
918    /// genuinely IS installed. A skip must only fire for the environment
919    /// gap it claims to be about, or it stops being a skip and becomes a
920    /// blindfold.
921    #[test]
922    fn color_glyph_rgba_decodes_a_real_emoji_on_this_machine() {
923        if EMOJI_FALLBACK_PATHS.iter().all(|p| !std::path::Path::new(p).exists()) {
924            eprintln!("no color-emoji font file on this machine — skipping (not a failure)");
925            return;
926        }
927        let font = FontCache::embedded();
928        let cg = font.color_glyph_rgba('😀', 32.0)
929            .expect("font file exists but color_glyph_rgba returned None — a real bug, not an environment gap");
930        assert!(cg.width > 0 && cg.height > 0, "decoded bitmap must have real dimensions");
931        assert_eq!(cg.rgba.len(), (cg.width * cg.height * 4) as usize, "RGBA8 buffer must match width*height*4");
932        assert!(cg.advance > 0.0, "a real emoji must have a positive advance width");
933        // At least one non-transparent, non-black pixel — a real decoded
934        // photo/icon, not an all-zero buffer silently accepted as "success".
935        let has_real_color = cg.rgba.chunks_exact(4).any(|p| p[3] > 0 && (p[0] > 20 || p[1] > 20 || p[2] > 20));
936        assert!(has_real_color, "decoded emoji must contain real non-black visible pixels");
937    }
938
939    #[test]
940    fn color_glyph_rgba_returns_none_for_plain_text() {
941        let font = FontCache::embedded();
942        assert!(font.color_glyph_rgba('a', 16.0).is_none());
943    }
944
945    #[test]
946    fn layout_glyphs_places_a_real_emoji_with_color_rgba_set() {
947        // px=16.0 deliberately: the exact size that exposed the
948        // `is_color_glyph` bug live (the earlier isolated test used 32.0,
949        // which happens to be an exact sbix strike — this one must NOT
950        // rely on that coincidence, since the real app renders at 16/24px).
951        if EMOJI_FALLBACK_PATHS.iter().all(|p| !std::path::Path::new(p).exists()) {
952            eprintln!("no color-emoji font file on this machine — skipping (not a failure)");
953            return;
954        }
955        let font = FontCache::embedded();
956        let placed = layout_glyphs(&font, "hi 😀 there", 0.0, 0.0, 16.0, FontWeight::Regular);
957        let pg = placed.iter().find(|pg| pg.color_rgba.is_some())
958            .expect("font file exists but no placed glyph had color_rgba set — a real bug, not an environment gap");
959        let cg = pg.color_rgba.as_ref().unwrap();
960        assert!(cg.width > 0 && cg.height > 0);
961        // Regardless of emoji decode availability, the surrounding plain
962        // text must still be placed normally.
963        assert!(placed.iter().any(|pg| pg.color_rgba.is_none()), "plain characters must still be placed");
964    }
965
966    #[test]
967    fn variation_selector_16_produces_no_placed_glyph() {
968        // Real bug found live: "☀️" (U+2600 SUN + U+FE0F VARIATION
969        // SELECTOR-16, requesting the emoji/color presentation) rendered a
970        // stray tofu box for the selector itself — it has no visible glyph
971        // in any face and wasn't recognized as a zero-width modifier.
972        // A bare selector with nothing else in the string must place NOTHING.
973        let font = FontCache::embedded();
974        let placed = layout_glyphs(&font, "\u{FE0F}", 0.0, 0.0, 16.0, FontWeight::Regular);
975        assert!(placed.is_empty(), "a lone variation selector must never produce a placed glyph");
976
977        // "☀️" must place AT MOST one glyph (the sun itself, color or
978        // monochrome depending on font availability) — never two, which
979        // would mean the selector also got its own tofu-box glyph.
980        let placed = layout_glyphs(&font, "\u{2600}\u{FE0F}", 0.0, 0.0, 16.0, FontWeight::Regular);
981        assert!(placed.len() <= 1, "the selector must not add a second placed glyph, got {}", placed.len());
982    }
983}