use skia_safe::{self as skia, textlayout, Font, FontMgr}; use std::collections::{HashMap, HashSet}; use crate::error::{Error, Result}; use crate::shapes::{FontFamily, FontStyle}; use crate::uuid::Uuid; pub static DEFAULT_EMOJI_FONT: &str = "noto-color-emoji"; const DEFAULT_FONT_BYTES: &[u8] = include_bytes!("../fonts/sourcesanspro-regular.ttf"); const UI_FONT_BYTES: &[u8] = include_bytes!("../fonts/WorkSans-Numeric.ttf"); pub fn default_font() -> String { let family = FontFamily::new(default_font_uuid(), 400, FontStyle::Normal); format!("{}", family) } fn default_font_uuid() -> Uuid { Uuid::nil() } pub struct FontStore { font_mgr: FontMgr, font_provider: textlayout::TypefaceFontProvider, font_collection: textlayout::FontCollection, debug_font: Font, ui_font: Font, fallback_fonts: HashSet, /// Source URL registered when the font was fetched (SVG export references /// this in `@font-face` rules). source_urls: HashMap, } impl FontStore { pub fn try_new() -> Result { // custom_empty: FreeType-backed manager with no system scan. FontMgr::new() // calls SkFontMgr_NewSystem, which on wasm tries to opendir font paths and // aborts when Emscripten is built with -sFILESYSTEM=0 (exporter + browser). let font_mgr = FontMgr::custom_empty().ok_or(Error::CriticalError( "Failed to create empty FontMgr".to_string(), ))?; let font_provider = load_default_provider(&font_mgr); let mut font_collection = skia::textlayout::FontCollection::new(); font_collection.set_default_font_manager(FontMgr::from(font_provider.clone()), None); let debug_typeface = font_provider .match_family_style(default_font().as_str(), skia::FontStyle::default()) .ok_or(Error::CriticalError( "Failed to match default font".to_string(), ))?; let debug_font = skia::Font::new(debug_typeface, 12.0); let ui_typeface = font_mgr .new_from_data(skia::Data::new_copy(UI_FONT_BYTES), None) .ok_or(Error::CriticalError("Failed to load UI font".to_string()))?; let ui_font = skia::Font::new(ui_typeface, 12.0); Ok(Self { font_mgr, font_provider, font_collection, debug_font, ui_font, fallback_fonts: HashSet::new(), source_urls: HashMap::new(), }) } pub fn set_scale_debug_font(&mut self, dpr: f32) { let debug_font = skia::Font::new(self.debug_font.typeface(), 12.0 * dpr); self.debug_font = debug_font; } pub fn font_provider(&self) -> &textlayout::TypefaceFontProvider { &self.font_provider } pub fn font_collection(&self) -> &textlayout::FontCollection { &self.font_collection } pub fn debug_font(&self) -> &Font { &self.debug_font } pub fn ui_font(&self) -> &Font { &self.ui_font } pub fn add( &mut self, family: FontFamily, font_data: &[u8], is_emoji: bool, is_fallback: bool, ) -> Result<()> { let alias = format!("{}", family); if self.has_family(&family, is_emoji) { if is_fallback { self.fallback_fonts.insert(alias); } return Ok(()); } let typeface = self .font_mgr .new_from_data(skia::Data::new_copy(font_data), None) .ok_or(Error::CriticalError( "Failed to create typeface".to_string(), ))?; let font_name = if is_emoji { DEFAULT_EMOJI_FONT } else { alias.as_str() }; self.font_provider.register_typeface(typeface, font_name); self.font_collection.clear_caches(); if is_fallback { self.fallback_fonts.insert(alias); } Ok(()) } /// Upgrade an already-uploaded family to take part in character fallback. /// Font bytes are cached apart from a face's role, so a family may arrive /// as a document font and later be requested as a fallback. pub fn mark_as_fallback(&mut self, family: &FontFamily) { if self.has_family(family, false) { self.fallback_fonts.insert(format!("{}", family)); } } pub fn has_family(&self, family: &FontFamily, is_emoji: bool) -> bool { let alias = format!("{}", family); let font_name = if is_emoji { DEFAULT_EMOJI_FONT } else { alias.as_str() }; self.font_provider.family_names().any(|x| x == font_name) } pub fn get_fallback(&self) -> &HashSet { &self.fallback_fonts } pub fn set_source_url(&mut self, alias: &str, url: String) { if !url.is_empty() { self.source_urls.insert(alias.to_string(), url); } } pub fn source_url(&self, alias: &str) -> Option<&str> { self.source_urls.get(alias).map(String::as_str) } /// Builds `@font-face` CSS rules for the given registered aliases. /// /// Each rule references the source URL registered for the alias at load /// time. Aliases without a registered URL are omitted. pub fn font_face_css_for_aliases(&self, aliases: &HashSet) -> String { let mut seen: HashSet = HashSet::new(); let mut css = String::new(); for alias in aliases { let Some(typeface) = self .font_provider .match_family_style(alias, skia::FontStyle::default()) else { continue; }; let family = typeface.family_name(); let style = typeface.font_style(); // Skia's SVG backend derives `` font descriptors from the // typeface's own `SkFontStyle` using a quirky bucketed table (see // `skia_svg_font_weight`). We must mirror it exactly here so each // `@font-face` pairs with the `` elements that reference it; // otherwise, when several weights of the same family coexist, the // browser cannot match the weight and silently falls back to 400. let weight = skia_svg_font_weight(*style.weight()); let slant = match style.slant() { skia::font_style::Slant::Italic => "italic", skia::font_style::Slant::Oblique => "oblique", _ => "normal", }; let stretch = skia_svg_font_stretch(*style.width()); let dedup_key = format!("{family}|{weight}|{slant}|{stretch:?}"); if !seen.insert(dedup_key) { continue; } let stretch_decl = stretch .map(|s| format!("font-stretch:{s};")) .unwrap_or_default(); let Some(url) = self.source_url(alias) else { continue; }; let src = font_face_src_from_url(url); css.push_str(&format!( "@font-face{{font-family:\"{family}\";font-style:{slant};font-weight:{weight};{stretch_decl}src:{src};}}", )); } css } } fn font_face_src_from_url(url: &str) -> String { let format = font_format_from_url(url); format!("url(\"{}\") format(\"{format}\")", css_escape_url(url)) } fn font_format_from_url(url: &str) -> &'static str { let path = url .split('#') .next() .unwrap_or(url) .split('?') .next() .unwrap_or(url); if path.ends_with(".woff2") { "woff2" } else if path.ends_with(".woff") { "woff" } else if path.ends_with(".otf") { "opentype" } else { "truetype" } } fn css_escape_url(url: &str) -> String { url.replace('\\', "\\\\").replace('"', "\\\"") } /// Reproduces the `font-weight` string that `SkSVGDevice::addTextAttributes` /// writes on `` elements for a given typeface weight. fn skia_svg_font_weight(weight: i32) -> &'static str { // Skia's table is ["100","200","300","normal","400","500","600","bold", // "800","900"]; we substitute "400" for the omitted-normal bucket so the // descriptor still resolves to weight 400. const WEIGHTS: [&str; 10] = [ "100", "200", "300", "400", "400", "500", "600", "bold", "800", "900", ]; let index = ((weight.clamp(100, 900) - 50) / 100) as usize; WEIGHTS[index] } /// Reproduces the `font-stretch` value `SkSVGDevice` writes for a typeface /// width, returning `None` for the normal width (which Skia omits). fn skia_svg_font_stretch(width: i32) -> Option<&'static str> { const STRETCHES: [&str; 9] = [ "ultra-condensed", "extra-condensed", "condensed", "semi-condensed", "normal", "semi-expanded", "expanded", "extra-expanded", "ultra-expanded", ]; let index = width - 1; if index == 4 { return None; } STRETCHES.get(usize::try_from(index).ok()?).copied() } fn load_default_provider(font_mgr: &FontMgr) -> skia::textlayout::TypefaceFontProvider { let mut font_provider = skia::textlayout::TypefaceFontProvider::new(); let family = FontFamily::new(default_font_uuid(), 400, FontStyle::Normal); let font = font_mgr .new_from_data(skia::Data::new_copy(DEFAULT_FONT_BYTES), None) .expect("Failed to load font"); font_provider.register_typeface(font, family.alias().as_str()); font_provider } #[cfg(test)] mod tests { use super::*; use crate::shapes::{FontFamily, FontStyle}; use crate::uuid::Uuid; #[test] fn font_face_css_uses_registered_url() { let mut store = FontStore::try_new().expect("font store"); let family = FontFamily::new(Uuid::nil(), 400, FontStyle::Normal); let alias = family.alias(); store.set_source_url(&alias, "https://example.com/fonts/source.ttf".to_string()); let mut aliases = HashSet::new(); aliases.insert(alias); let css = store.font_face_css_for_aliases(&aliases); assert!(css.contains("url(\"https://example.com/fonts/source.ttf\")")); assert!(css.contains("format(\"truetype\")")); assert!(!css.contains("base64,")); } #[test] fn font_face_css_skips_without_registered_url() { let store = FontStore::try_new().expect("font store"); let family = FontFamily::new(Uuid::nil(), 400, FontStyle::Normal); let mut aliases = HashSet::new(); aliases.insert(family.alias()); let css = store.font_face_css_for_aliases(&aliases); assert!(css.is_empty()); } const TEST_FONT: &[u8] = include_bytes!("../fonts/sourcesanspro-regular.ttf"); #[test] fn uploaded_family_can_be_upgraded_to_fallback() { let mut store = FontStore::try_new().unwrap(); let family = FontFamily::new(Uuid::nil(), 400, FontStyle::Normal); let alias = format!("{}", family); store.add(family, TEST_FONT, false, false).unwrap(); assert!(!store.get_fallback().contains(&alias)); store.mark_as_fallback(&family); assert!(store.get_fallback().contains(&alias)); } }