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}