we (web engine): Experimental web browser project to understand the limits of Claude
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement @font-face: parsing, font loading, and registry integration

Add support for @font-face CSS rules to load and use web fonts:

- CSS parser: parse @font-face blocks with font-family, src (url() with
optional format()), font-weight (normal/bold/numeric), font-style
(normal/italic/oblique), and font-display descriptors
- Text crate: extend FontRegistry with web font registration from raw
bytes, with web fonts taking priority over system fonts in lookups
- Browser crate: new font_loader module that collects @font-face rules
from stylesheets, fetches font files via the resource loader, and
registers them in the FontRegistry
- Integration: page loading pipeline now loads web fonts after CSS
collection, and the rendering font is selected from the registry
(preferring web fonts when available)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+915 -10
+228
crates/browser/src/font_loader.rs
··· 1 + //! Web font loading: fetch and register `@font-face` fonts. 2 + //! 3 + //! Scans a parsed stylesheet for `@font-face` rules, fetches the font files 4 + //! from the declared `src` URLs, and registers them in the `FontRegistry` 5 + //! so they take priority over system fonts during text rendering. 6 + 7 + use we_css::parser::{FontDisplay, FontFaceRule, FontFaceStyle, Rule, Stylesheet}; 8 + use we_text::font::registry::{FontRegistry, WebFontState}; 9 + use we_url::Url; 10 + 11 + use crate::loader::{Resource, ResourceLoader}; 12 + 13 + /// Result of loading web fonts for a page. 14 + #[derive(Debug)] 15 + pub struct WebFontResult { 16 + /// Number of fonts successfully loaded. 17 + pub loaded: usize, 18 + /// Number of fonts that failed to load. 19 + pub failed: usize, 20 + } 21 + 22 + /// Collect all `@font-face` rules from a stylesheet and fetch their font files. 23 + /// 24 + /// For each `@font-face` rule, tries each `src` URL in order until one succeeds. 25 + /// Successfully loaded fonts are registered in the `FontRegistry` under the 26 + /// declared `font-family` name. 27 + /// 28 + /// `font-display` is recorded but currently only `swap` behavior is implemented 29 + /// (use fallback immediately, swap when loaded). Since loading is synchronous, 30 + /// all fonts are either loaded or failed before rendering begins. 31 + pub fn load_web_fonts( 32 + stylesheet: &Stylesheet, 33 + loader: &mut ResourceLoader, 34 + base_url: &Url, 35 + registry: &mut FontRegistry, 36 + ) -> WebFontResult { 37 + let mut result = WebFontResult { 38 + loaded: 0, 39 + failed: 0, 40 + }; 41 + 42 + let font_face_rules = collect_font_face_rules(&stylesheet.rules); 43 + 44 + for rule in font_face_rules { 45 + if try_load_font_face(rule, loader, base_url, registry) { 46 + result.loaded += 1; 47 + } else { 48 + result.failed += 1; 49 + } 50 + } 51 + 52 + result 53 + } 54 + 55 + /// Recursively collect all `@font-face` rules from a rule list (including 56 + /// those nested inside `@media` blocks). 57 + fn collect_font_face_rules(rules: &[Rule]) -> Vec<&FontFaceRule> { 58 + let mut result = Vec::new(); 59 + for rule in rules { 60 + match rule { 61 + Rule::FontFace(ff) => result.push(ff), 62 + Rule::Media(media) => { 63 + result.extend(collect_font_face_rules(&media.rules)); 64 + } 65 + _ => {} 66 + } 67 + } 68 + result 69 + } 70 + 71 + /// Try to load a single `@font-face` rule. Tries each source URL in order. 72 + /// 73 + /// Returns `true` if the font was successfully loaded and registered. 74 + fn try_load_font_face( 75 + rule: &FontFaceRule, 76 + loader: &mut ResourceLoader, 77 + base_url: &Url, 78 + registry: &mut FontRegistry, 79 + ) -> bool { 80 + let italic = rule.style == FontFaceStyle::Italic || rule.style == FontFaceStyle::Oblique; 81 + 82 + for source in &rule.sources { 83 + // Try to fetch the font data. 84 + let data = match fetch_font_data(loader, &source.url, base_url) { 85 + Some(d) => d, 86 + None => continue, 87 + }; 88 + 89 + // Register the font in the registry. 90 + let state = registry.register_web_font(&rule.family, data, rule.weight, italic); 91 + if state == WebFontState::Loaded { 92 + return true; 93 + } 94 + // If parsing failed, try the next source. 95 + } 96 + 97 + false 98 + } 99 + 100 + /// Fetch raw font data from a URL. 101 + fn fetch_font_data(loader: &mut ResourceLoader, url: &str, base_url: &Url) -> Option<Vec<u8>> { 102 + let resource = loader.fetch_url(url, Some(base_url)).ok()?; 103 + match resource { 104 + Resource::Other { data, .. } => Some(data), 105 + Resource::Image { data, .. } => Some(data), 106 + // Some servers might serve fonts as CSS or other types — try the raw body. 107 + Resource::Css { text, .. } => Some(text.into_bytes()), 108 + Resource::Script { text, .. } => Some(text.into_bytes()), 109 + Resource::Html { text, .. } => Some(text.into_bytes()), 110 + } 111 + } 112 + 113 + /// Extract the `font-display` value from a rule for future use. 114 + /// 115 + /// Currently all loading is synchronous, so font-display has no behavioral 116 + /// effect. This function is provided for when async loading is added. 117 + pub fn font_display_strategy(display: FontDisplay) -> &'static str { 118 + match display { 119 + FontDisplay::Auto => "auto", 120 + FontDisplay::Block => "block", 121 + FontDisplay::Swap => "swap", 122 + FontDisplay::Fallback => "fallback", 123 + FontDisplay::Optional => "optional", 124 + } 125 + } 126 + 127 + // --------------------------------------------------------------------------- 128 + // Tests 129 + // --------------------------------------------------------------------------- 130 + 131 + #[cfg(test)] 132 + mod tests { 133 + use super::*; 134 + use we_css::parser::Parser; 135 + 136 + #[test] 137 + fn collect_font_face_rules_from_stylesheet() { 138 + let css = r#" 139 + @font-face { 140 + font-family: "MyFont"; 141 + src: url("myfont.ttf"); 142 + font-weight: bold; 143 + font-style: italic; 144 + font-display: swap; 145 + } 146 + p { color: red; } 147 + @font-face { 148 + font-family: "OtherFont"; 149 + src: url("other.woff2") format("woff2"), url("other.ttf") format("truetype"); 150 + } 151 + "#; 152 + let stylesheet = Parser::parse(css); 153 + let rules = collect_font_face_rules(&stylesheet.rules); 154 + assert_eq!(rules.len(), 2); 155 + 156 + assert_eq!(rules[0].family, "MyFont"); 157 + assert_eq!(rules[0].weight, 700); 158 + assert_eq!(rules[0].style, FontFaceStyle::Italic); 159 + assert_eq!(rules[0].display, FontDisplay::Swap); 160 + assert_eq!(rules[0].sources.len(), 1); 161 + assert_eq!(rules[0].sources[0].url, "myfont.ttf"); 162 + 163 + assert_eq!(rules[1].family, "OtherFont"); 164 + assert_eq!(rules[1].weight, 400); 165 + assert_eq!(rules[1].style, FontFaceStyle::Normal); 166 + assert_eq!(rules[1].sources.len(), 2); 167 + assert_eq!(rules[1].sources[0].url, "other.woff2"); 168 + assert_eq!(rules[1].sources[0].format.as_deref(), Some("woff2")); 169 + assert_eq!(rules[1].sources[1].url, "other.ttf"); 170 + assert_eq!(rules[1].sources[1].format.as_deref(), Some("truetype")); 171 + } 172 + 173 + #[test] 174 + fn collect_font_face_rules_nested_in_media() { 175 + let css = r#" 176 + @media screen { 177 + @font-face { 178 + font-family: "ScreenFont"; 179 + src: url("screen.ttf"); 180 + } 181 + } 182 + "#; 183 + let stylesheet = Parser::parse(css); 184 + let rules = collect_font_face_rules(&stylesheet.rules); 185 + assert_eq!(rules.len(), 1); 186 + assert_eq!(rules[0].family, "ScreenFont"); 187 + } 188 + 189 + #[test] 190 + fn font_display_strategy_values() { 191 + assert_eq!(font_display_strategy(FontDisplay::Auto), "auto"); 192 + assert_eq!(font_display_strategy(FontDisplay::Block), "block"); 193 + assert_eq!(font_display_strategy(FontDisplay::Swap), "swap"); 194 + assert_eq!(font_display_strategy(FontDisplay::Fallback), "fallback"); 195 + assert_eq!(font_display_strategy(FontDisplay::Optional), "optional"); 196 + } 197 + 198 + #[test] 199 + fn load_web_fonts_empty_stylesheet() { 200 + let stylesheet = Stylesheet { rules: vec![] }; 201 + let mut loader = ResourceLoader::new(); 202 + let base = Url::parse("http://example.com/").unwrap(); 203 + let mut registry = FontRegistry::new(); 204 + 205 + let result = load_web_fonts(&stylesheet, &mut loader, &base, &mut registry); 206 + assert_eq!(result.loaded, 0); 207 + assert_eq!(result.failed, 0); 208 + } 209 + 210 + #[test] 211 + fn load_web_fonts_failed_fetch() { 212 + let css = r#" 213 + @font-face { 214 + font-family: "MissingFont"; 215 + src: url("http://nonexistent.test/font.ttf"); 216 + } 217 + "#; 218 + let stylesheet = Parser::parse(css); 219 + let mut loader = ResourceLoader::new(); 220 + let base = Url::parse("http://example.com/").unwrap(); 221 + let mut registry = FontRegistry::new(); 222 + 223 + let result = load_web_fonts(&stylesheet, &mut loader, &base, &mut registry); 224 + assert_eq!(result.loaded, 0); 225 + assert_eq!(result.failed, 1); 226 + assert!(!registry.has_web_font("MissingFont")); 227 + } 228 + }
+1
crates/browser/src/lib.rs
··· 1 1 //! Event loop, resource loading, navigation, UI chrome. 2 2 3 3 pub mod css_loader; 4 + pub mod font_loader; 4 5 pub mod img_loader; 5 6 pub mod loader; 6 7 pub mod script_loader;
+31 -8
crates/browser/src/main.rs
··· 2 2 use std::collections::HashMap; 3 3 4 4 use we_browser::css_loader::collect_stylesheets; 5 + use we_browser::font_loader::load_web_fonts; 5 6 use we_browser::img_loader::{collect_images, ImageStore}; 6 7 use we_browser::loader::{Resource, ResourceLoader, ABOUT_BLANK_HTML}; 7 8 use we_browser::script_loader::execute_page_scripts; ··· 16 17 use we_render::{build_display_list_with_page_scroll, RenderBackend, ScrollState}; 17 18 use we_style::computed::resolve_styles; 18 19 use we_svg::{render_svg, svg_intrinsic_size}; 19 - use we_text::font::{self, Font}; 20 + use we_text::font::{self, Font, FontRegistry}; 20 21 use we_url::Url; 21 22 22 23 // --------------------------------------------------------------------------- ··· 29 30 doc: Document, 30 31 stylesheet: Stylesheet, 31 32 images: ImageStore, 33 + /// Font registry with both system and web fonts (from `@font-face`). 34 + font_registry: FontRegistry, 32 35 } 33 36 34 37 /// Browser state kept in thread-local storage so the resize handler can ··· 409 412 } 410 413 } 411 414 412 - /// Load a page: fetch HTML, parse DOM, execute scripts, collect CSS and images. 415 + /// Load a page: fetch HTML, parse DOM, execute scripts, collect CSS, load web fonts, and images. 413 416 fn load_page(loaded: LoadedHtml) -> PageState { 414 417 let doc = parse_html(&loaded.text); 415 418 ··· 420 423 421 424 // Fetch external stylesheets and merge with inline <style> elements. 422 425 let stylesheet = collect_stylesheets(&doc, &mut loader, &loaded.base_url); 426 + 427 + // Load web fonts from @font-face rules in the stylesheet. 428 + let mut font_registry = FontRegistry::new(); 429 + let font_result = load_web_fonts( 430 + &stylesheet, 431 + &mut loader, 432 + &loaded.base_url, 433 + &mut font_registry, 434 + ); 435 + if font_result.loaded > 0 { 436 + eprintln!( 437 + "[we] Loaded {} web font(s) ({} failed)", 438 + font_result.loaded, font_result.failed 439 + ); 440 + } 423 441 424 442 // Fetch and decode images referenced by <img> elements. 425 443 let images = collect_images(&doc, &mut loader, &loaded.base_url); ··· 428 446 doc, 429 447 stylesheet, 430 448 images, 449 + font_registry, 431 450 } 432 451 } 433 452 ··· 445 464 }, 446 465 }; 447 466 448 - // Parse DOM and fetch subresources (CSS, images). 467 + // Parse DOM and fetch subresources (CSS, images, web fonts). 449 468 let page = load_page(loaded); 450 469 451 - // Load a system font for text rendering. 452 - let font = match font::load_system_font() { 453 - Ok(f) => f, 454 - Err(e) => { 455 - eprintln!("Error loading system font: {:?}", e); 470 + // Select rendering font: prefer a web font from the registry, fall back to system font. 471 + let font = page 472 + .font_registry 473 + .find_best_font() 474 + .or_else(|| font::load_system_font().ok()); 475 + let font = match font { 476 + Some(f) => f, 477 + None => { 478 + eprintln!("Error: no fonts available for rendering"); 456 479 std::process::exit(1); 457 480 } 458 481 };
+447
crates/css/src/parser.rs
··· 23 23 Media(MediaRule), 24 24 Import(ImportRule), 25 25 Keyframes(KeyframesRule), 26 + FontFace(FontFaceRule), 26 27 } 27 28 28 29 /// A `@keyframes` rule with a name and a list of keyframes. ··· 52 53 pub url: String, 53 54 } 54 55 56 + /// A `@font-face` rule describing a web font. 57 + #[derive(Debug, Clone, PartialEq)] 58 + pub struct FontFaceRule { 59 + /// The font family name declared for this font face. 60 + pub family: String, 61 + /// `src` descriptor: list of font sources (currently only `url()` references). 62 + pub sources: Vec<FontFaceSource>, 63 + /// `font-weight`: numeric weight (100–900). Defaults to 400. 64 + pub weight: u16, 65 + /// `font-style`: normal, italic, or oblique. 66 + pub style: FontFaceStyle, 67 + /// `font-display`: controls rendering behavior during font load. 68 + pub display: FontDisplay, 69 + } 70 + 71 + /// A single source in a `@font-face` `src` descriptor. 72 + #[derive(Debug, Clone, PartialEq)] 73 + pub struct FontFaceSource { 74 + /// URL to the font file. 75 + pub url: String, 76 + /// Optional `format()` hint (e.g., "truetype", "opentype", "woff2"). 77 + pub format: Option<String>, 78 + } 79 + 80 + /// `font-style` descriptor values for `@font-face`. 81 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 82 + pub enum FontFaceStyle { 83 + Normal, 84 + Italic, 85 + Oblique, 86 + } 87 + 88 + /// `font-display` descriptor values for `@font-face`. 89 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 90 + pub enum FontDisplay { 91 + Auto, 92 + Block, 93 + Swap, 94 + Fallback, 95 + Optional, 96 + } 97 + 55 98 /// A comma-separated list of selectors. 56 99 #[derive(Debug, Clone, PartialEq)] 57 100 pub struct SelectorList { ··· 246 289 "media" => self.parse_media_rule(), 247 290 "import" => self.parse_import_rule(), 248 291 "keyframes" | "-webkit-keyframes" | "-moz-keyframes" => self.parse_keyframes_rule(), 292 + "font-face" => self.parse_font_face_rule(), 249 293 _ => { 250 294 // Unknown at-rule: skip to end of block or semicolon 251 295 self.skip_at_rule_body(); ··· 401 445 } 402 446 403 447 Some(Rule::Keyframes(KeyframesRule { name, keyframes })) 448 + } 449 + 450 + fn parse_font_face_rule(&mut self) -> Option<Rule> { 451 + self.skip_whitespace(); 452 + 453 + // Expect `{` 454 + if !matches!(self.peek(), Token::LeftBrace) { 455 + self.skip_at_rule_body(); 456 + return None; 457 + } 458 + self.advance(); 459 + 460 + // Parse descriptors inside the block. 461 + let declarations = self.parse_declaration_list_until_brace(); 462 + 463 + // Consume `}` 464 + if matches!(self.peek(), Token::RightBrace) { 465 + self.advance(); 466 + } 467 + 468 + // Extract font-family descriptor (required). 469 + let mut family = None; 470 + let mut sources = Vec::new(); 471 + let mut weight: u16 = 400; 472 + let mut style = FontFaceStyle::Normal; 473 + let mut display = FontDisplay::Auto; 474 + 475 + for decl in &declarations { 476 + match decl.property.as_str() { 477 + "font-family" => { 478 + family = extract_font_face_family(&decl.value); 479 + } 480 + "src" => { 481 + sources = parse_font_face_src(&decl.value); 482 + } 483 + "font-weight" => { 484 + weight = parse_font_face_weight(&decl.value); 485 + } 486 + "font-style" => { 487 + style = parse_font_face_style(&decl.value); 488 + } 489 + "font-display" => { 490 + display = parse_font_face_display(&decl.value); 491 + } 492 + _ => {} 493 + } 494 + } 495 + 496 + let family = family?; 497 + if sources.is_empty() { 498 + return None; 499 + } 500 + 501 + Some(Rule::FontFace(FontFaceRule { 502 + family, 503 + sources, 504 + weight, 505 + style, 506 + display, 507 + })) 404 508 } 405 509 406 510 fn parse_keyframe_block(&mut self) -> Option<Keyframe> { ··· 1067 1171 } 1068 1172 1069 1173 // --------------------------------------------------------------------------- 1174 + // @font-face descriptor helpers 1175 + // --------------------------------------------------------------------------- 1176 + 1177 + /// Extract the family name from a `font-family` descriptor value. 1178 + fn extract_font_face_family(values: &[ComponentValue]) -> Option<String> { 1179 + for v in values { 1180 + match v { 1181 + ComponentValue::String(s) => return Some(s.clone()), 1182 + ComponentValue::Ident(s) => return Some(s.clone()), 1183 + _ => {} 1184 + } 1185 + } 1186 + None 1187 + } 1188 + 1189 + /// Parse the `src` descriptor into a list of font sources. 1190 + /// 1191 + /// Supports `url("...")` and `url("...") format("...")` syntax. 1192 + /// Multiple sources are comma-separated. 1193 + fn parse_font_face_src(values: &[ComponentValue]) -> Vec<FontFaceSource> { 1194 + let mut sources = Vec::new(); 1195 + let mut i = 0; 1196 + 1197 + while i < values.len() { 1198 + match &values[i] { 1199 + ComponentValue::Function(name, args) if name.eq_ignore_ascii_case("url") => { 1200 + let url = extract_string_from_args(args); 1201 + i += 1; 1202 + // Check for optional format() hint. 1203 + let format = skip_ws_and_parse_format(values, &mut i); 1204 + if let Some(url) = url { 1205 + sources.push(FontFaceSource { url, format }); 1206 + } 1207 + } 1208 + ComponentValue::Comma => { 1209 + i += 1; 1210 + } 1211 + _ => { 1212 + i += 1; 1213 + } 1214 + } 1215 + } 1216 + 1217 + sources 1218 + } 1219 + 1220 + /// Extract a string or ident from function arguments. 1221 + fn extract_string_from_args(args: &[ComponentValue]) -> Option<String> { 1222 + for arg in args { 1223 + match arg { 1224 + ComponentValue::String(s) => return Some(s.clone()), 1225 + ComponentValue::Ident(s) => return Some(s.clone()), 1226 + _ => {} 1227 + } 1228 + } 1229 + None 1230 + } 1231 + 1232 + /// Skip whitespace tokens and try to parse a `format()` function. 1233 + fn skip_ws_and_parse_format(values: &[ComponentValue], i: &mut usize) -> Option<String> { 1234 + // Skip whitespace. 1235 + while *i < values.len() && matches!(values[*i], ComponentValue::Whitespace) { 1236 + *i += 1; 1237 + } 1238 + // Check for format(). 1239 + if *i < values.len() { 1240 + if let ComponentValue::Function(name, args) = &values[*i] { 1241 + if name.eq_ignore_ascii_case("format") { 1242 + *i += 1; 1243 + return extract_string_from_args(args); 1244 + } 1245 + } 1246 + } 1247 + None 1248 + } 1249 + 1250 + /// Parse `font-weight` descriptor: `normal`, `bold`, or numeric (100–900). 1251 + fn parse_font_face_weight(values: &[ComponentValue]) -> u16 { 1252 + for v in values { 1253 + match v { 1254 + ComponentValue::Ident(s) if s.eq_ignore_ascii_case("normal") => return 400, 1255 + ComponentValue::Ident(s) if s.eq_ignore_ascii_case("bold") => return 700, 1256 + ComponentValue::Number(n, _) => { 1257 + let w = *n as u16; 1258 + return w.clamp(1, 1000); 1259 + } 1260 + _ => {} 1261 + } 1262 + } 1263 + 400 1264 + } 1265 + 1266 + /// Parse `font-style` descriptor: `normal`, `italic`, `oblique`. 1267 + fn parse_font_face_style(values: &[ComponentValue]) -> FontFaceStyle { 1268 + for v in values { 1269 + if let ComponentValue::Ident(s) = v { 1270 + if s.eq_ignore_ascii_case("italic") { 1271 + return FontFaceStyle::Italic; 1272 + } 1273 + if s.eq_ignore_ascii_case("oblique") { 1274 + return FontFaceStyle::Oblique; 1275 + } 1276 + if s.eq_ignore_ascii_case("normal") { 1277 + return FontFaceStyle::Normal; 1278 + } 1279 + } 1280 + } 1281 + FontFaceStyle::Normal 1282 + } 1283 + 1284 + /// Parse `font-display` descriptor. 1285 + fn parse_font_face_display(values: &[ComponentValue]) -> FontDisplay { 1286 + for v in values { 1287 + if let ComponentValue::Ident(s) = v { 1288 + match s.to_ascii_lowercase().as_str() { 1289 + "auto" => return FontDisplay::Auto, 1290 + "block" => return FontDisplay::Block, 1291 + "swap" => return FontDisplay::Swap, 1292 + "fallback" => return FontDisplay::Fallback, 1293 + "optional" => return FontDisplay::Optional, 1294 + _ => {} 1295 + } 1296 + } 1297 + } 1298 + FontDisplay::Auto 1299 + } 1300 + 1301 + // --------------------------------------------------------------------------- 1070 1302 // Tests 1071 1303 // --------------------------------------------------------------------------- 1072 1304 ··· 1723 1955 assert!(matches!(ss.rules[0], Rule::Style(_))); 1724 1956 assert!(matches!(ss.rules[1], Rule::Keyframes(_))); 1725 1957 assert!(matches!(ss.rules[2], Rule::Style(_))); 1958 + } 1959 + 1960 + // -- @font-face ----------------------------------------------------------- 1961 + 1962 + #[test] 1963 + fn font_face_basic() { 1964 + let ss = Parser::parse( 1965 + r#"@font-face { 1966 + font-family: "MyFont"; 1967 + src: url("myfont.ttf"); 1968 + }"#, 1969 + ); 1970 + assert_eq!(ss.rules.len(), 1); 1971 + let ff = match &ss.rules[0] { 1972 + Rule::FontFace(ff) => ff, 1973 + _ => panic!("expected FontFace rule"), 1974 + }; 1975 + assert_eq!(ff.family, "MyFont"); 1976 + assert_eq!(ff.sources.len(), 1); 1977 + assert_eq!(ff.sources[0].url, "myfont.ttf"); 1978 + assert!(ff.sources[0].format.is_none()); 1979 + assert_eq!(ff.weight, 400); 1980 + assert_eq!(ff.style, FontFaceStyle::Normal); 1981 + assert_eq!(ff.display, FontDisplay::Auto); 1982 + } 1983 + 1984 + #[test] 1985 + fn font_face_all_descriptors() { 1986 + let ss = Parser::parse( 1987 + r#"@font-face { 1988 + font-family: "WebFont"; 1989 + src: url("webfont.woff2") format("woff2"); 1990 + font-weight: 700; 1991 + font-style: italic; 1992 + font-display: swap; 1993 + }"#, 1994 + ); 1995 + assert_eq!(ss.rules.len(), 1); 1996 + let ff = match &ss.rules[0] { 1997 + Rule::FontFace(ff) => ff, 1998 + _ => panic!("expected FontFace rule"), 1999 + }; 2000 + assert_eq!(ff.family, "WebFont"); 2001 + assert_eq!(ff.sources.len(), 1); 2002 + assert_eq!(ff.sources[0].url, "webfont.woff2"); 2003 + assert_eq!(ff.sources[0].format.as_deref(), Some("woff2")); 2004 + assert_eq!(ff.weight, 700); 2005 + assert_eq!(ff.style, FontFaceStyle::Italic); 2006 + assert_eq!(ff.display, FontDisplay::Swap); 2007 + } 2008 + 2009 + #[test] 2010 + fn font_face_bold_keyword() { 2011 + let ss = Parser::parse( 2012 + r#"@font-face { 2013 + font-family: "BoldFont"; 2014 + src: url("bold.ttf"); 2015 + font-weight: bold; 2016 + }"#, 2017 + ); 2018 + let ff = match &ss.rules[0] { 2019 + Rule::FontFace(ff) => ff, 2020 + _ => panic!("expected FontFace rule"), 2021 + }; 2022 + assert_eq!(ff.weight, 700); 2023 + } 2024 + 2025 + #[test] 2026 + fn font_face_normal_weight_keyword() { 2027 + let ss = Parser::parse( 2028 + r#"@font-face { 2029 + font-family: "NormalFont"; 2030 + src: url("normal.ttf"); 2031 + font-weight: normal; 2032 + }"#, 2033 + ); 2034 + let ff = match &ss.rules[0] { 2035 + Rule::FontFace(ff) => ff, 2036 + _ => panic!("expected FontFace rule"), 2037 + }; 2038 + assert_eq!(ff.weight, 400); 2039 + } 2040 + 2041 + #[test] 2042 + fn font_face_oblique_style() { 2043 + let ss = Parser::parse( 2044 + r#"@font-face { 2045 + font-family: "ObliqueFont"; 2046 + src: url("oblique.ttf"); 2047 + font-style: oblique; 2048 + }"#, 2049 + ); 2050 + let ff = match &ss.rules[0] { 2051 + Rule::FontFace(ff) => ff, 2052 + _ => panic!("expected FontFace rule"), 2053 + }; 2054 + assert_eq!(ff.style, FontFaceStyle::Oblique); 2055 + } 2056 + 2057 + #[test] 2058 + fn font_face_multiple_sources() { 2059 + let ss = Parser::parse( 2060 + r#"@font-face { 2061 + font-family: "MultiFmt"; 2062 + src: url("font.woff2") format("woff2"), url("font.ttf") format("truetype"); 2063 + }"#, 2064 + ); 2065 + let ff = match &ss.rules[0] { 2066 + Rule::FontFace(ff) => ff, 2067 + _ => panic!("expected FontFace rule"), 2068 + }; 2069 + assert_eq!(ff.sources.len(), 2); 2070 + assert_eq!(ff.sources[0].url, "font.woff2"); 2071 + assert_eq!(ff.sources[0].format.as_deref(), Some("woff2")); 2072 + assert_eq!(ff.sources[1].url, "font.ttf"); 2073 + assert_eq!(ff.sources[1].format.as_deref(), Some("truetype")); 2074 + } 2075 + 2076 + #[test] 2077 + fn font_face_display_values() { 2078 + for (keyword, expected) in [ 2079 + ("auto", FontDisplay::Auto), 2080 + ("block", FontDisplay::Block), 2081 + ("swap", FontDisplay::Swap), 2082 + ("fallback", FontDisplay::Fallback), 2083 + ("optional", FontDisplay::Optional), 2084 + ] { 2085 + let css = format!( 2086 + r#"@font-face {{ 2087 + font-family: "F"; 2088 + src: url("f.ttf"); 2089 + font-display: {}; 2090 + }}"#, 2091 + keyword 2092 + ); 2093 + let ss = Parser::parse(&css); 2094 + let ff = match &ss.rules[0] { 2095 + Rule::FontFace(ff) => ff, 2096 + _ => panic!("expected FontFace rule"), 2097 + }; 2098 + assert_eq!(ff.display, expected, "font-display: {}", keyword); 2099 + } 2100 + } 2101 + 2102 + #[test] 2103 + fn font_face_missing_family_skipped() { 2104 + let ss = Parser::parse( 2105 + r#"@font-face { 2106 + src: url("nofamily.ttf"); 2107 + }"#, 2108 + ); 2109 + assert!( 2110 + ss.rules.is_empty(), 2111 + "should skip @font-face without font-family" 2112 + ); 2113 + } 2114 + 2115 + #[test] 2116 + fn font_face_missing_src_skipped() { 2117 + let ss = Parser::parse( 2118 + r#"@font-face { 2119 + font-family: "NoSrc"; 2120 + }"#, 2121 + ); 2122 + assert!(ss.rules.is_empty(), "should skip @font-face without src"); 2123 + } 2124 + 2125 + #[test] 2126 + fn font_face_unquoted_family() { 2127 + let ss = Parser::parse( 2128 + r#"@font-face { 2129 + font-family: CustomFont; 2130 + src: url("custom.ttf"); 2131 + }"#, 2132 + ); 2133 + assert_eq!(ss.rules.len(), 1); 2134 + let ff = match &ss.rules[0] { 2135 + Rule::FontFace(ff) => ff, 2136 + _ => panic!("expected FontFace rule"), 2137 + }; 2138 + assert_eq!(ff.family, "CustomFont"); 2139 + } 2140 + 2141 + #[test] 2142 + fn font_face_among_other_rules() { 2143 + let ss = Parser::parse( 2144 + r#" 2145 + body { color: black; } 2146 + @font-face { 2147 + font-family: "TestFont"; 2148 + src: url("test.ttf"); 2149 + } 2150 + p { margin: 0; } 2151 + "#, 2152 + ); 2153 + assert_eq!(ss.rules.len(), 3); 2154 + assert!(matches!(ss.rules[0], Rule::Style(_))); 2155 + assert!(matches!(ss.rules[1], Rule::FontFace(_))); 2156 + assert!(matches!(ss.rules[2], Rule::Style(_))); 2157 + } 2158 + 2159 + #[test] 2160 + fn font_face_numeric_weight() { 2161 + let ss = Parser::parse( 2162 + r#"@font-face { 2163 + font-family: "W300"; 2164 + src: url("w300.ttf"); 2165 + font-weight: 300; 2166 + }"#, 2167 + ); 2168 + let ff = match &ss.rules[0] { 2169 + Rule::FontFace(ff) => ff, 2170 + _ => panic!("expected FontFace rule"), 2171 + }; 2172 + assert_eq!(ff.weight, 300); 1726 2173 } 1727 2174 }
+3
crates/style/src/matching.rs
··· 330 330 Rule::Keyframes(_) => { 331 331 // Keyframes are resolved at a higher level; skip here. 332 332 } 333 + Rule::FontFace(_) => { 334 + // Font-face rules are resolved at a higher level; skip here. 335 + } 333 336 } 334 337 } 335 338 }
+1 -1
crates/text/src/font/mod.rs
··· 14 14 15 15 pub use cache::GlyphCache; 16 16 pub use rasterizer::GlyphBitmap; 17 - pub use registry::{FontEntry, FontRegistry}; 17 + pub use registry::{FontEntry, FontRegistry, WebFontEntry, WebFontState}; 18 18 pub use tables::cmap::CmapTable; 19 19 pub use tables::glyf::{Contour, GlyphOutline, Point}; 20 20 pub use tables::head::HeadTable;
+204 -1
crates/text/src/font/registry.rs
··· 25 25 pub italic: bool, 26 26 } 27 27 28 + /// A web font registered via `@font-face`, parsed from downloaded bytes. 29 + #[derive(Debug, Clone)] 30 + pub struct WebFontEntry { 31 + /// The declared font-family name from `@font-face`. 32 + pub family: String, 33 + /// Numeric font weight (100–900). 34 + pub weight: u16, 35 + /// Whether this face is italic. 36 + pub italic: bool, 37 + /// The raw font data (owned). 38 + pub data: Vec<u8>, 39 + } 40 + 41 + /// Loading state for a web font. 42 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 43 + pub enum WebFontState { 44 + /// Font is currently being fetched. 45 + Loading, 46 + /// Font has been loaded and parsed successfully. 47 + Loaded, 48 + /// Font failed to load or parse. 49 + Failed, 50 + } 51 + 28 52 /// A registry of system fonts, indexed by family name. 29 53 pub struct FontRegistry { 30 54 /// Map from lowercase family name to a list of font entries. 31 55 families: HashMap<String, Vec<FontEntry>>, 56 + /// Web fonts registered via `@font-face`, indexed by lowercase family name. 57 + web_fonts: HashMap<String, Vec<WebFontEntry>>, 32 58 } 33 59 34 60 impl Default for FontRegistry { ··· 91 117 } 92 118 } 93 119 94 - FontRegistry { families } 120 + FontRegistry { 121 + families, 122 + web_fonts: HashMap::new(), 123 + } 124 + } 125 + 126 + /// Register a web font from raw bytes under the given family name. 127 + /// 128 + /// The font data is validated by attempting to parse it. Returns the loading 129 + /// state: `Loaded` on success, `Failed` if the data can't be parsed. 130 + pub fn register_web_font( 131 + &mut self, 132 + family: &str, 133 + data: Vec<u8>, 134 + weight: u16, 135 + italic: bool, 136 + ) -> WebFontState { 137 + // Validate that the data is a parseable font. 138 + if Font::parse(data.clone()).is_err() { 139 + return WebFontState::Failed; 140 + } 141 + 142 + let key = family.to_ascii_lowercase(); 143 + let entry = WebFontEntry { 144 + family: family.to_owned(), 145 + weight, 146 + italic, 147 + data, 148 + }; 149 + self.web_fonts.entry(key).or_default().push(entry); 150 + WebFontState::Loaded 151 + } 152 + 153 + /// Check if a web font is registered for the given family name. 154 + pub fn has_web_font(&self, family: &str) -> bool { 155 + let key = family.to_ascii_lowercase(); 156 + self.web_fonts.contains_key(&key) 95 157 } 96 158 97 159 /// Find a font by family name. Returns the first match (prefers Regular). 98 160 /// 161 + /// Web fonts registered via `@font-face` are checked before system fonts. 99 162 /// The family name match is case-insensitive. 100 163 pub fn find_font(&self, family: &str) -> Option<Font> { 101 164 let key = family.to_ascii_lowercase(); 165 + 166 + // Check web fonts first. 167 + if let Some(web_entries) = self.web_fonts.get(&key) { 168 + // Prefer weight 400 (Regular). 169 + let entry = web_entries 170 + .iter() 171 + .find(|e| e.weight == 400 && !e.italic) 172 + .or_else(|| web_entries.first()); 173 + if let Some(entry) = entry { 174 + if let Ok(font) = Font::parse(entry.data.clone()) { 175 + return Some(font); 176 + } 177 + } 178 + } 179 + 102 180 let entries = self.families.get(&key)?; 103 181 104 182 // Prefer the Regular face. ··· 112 190 113 191 /// Find a font by family name with bold/italic style preference. 114 192 /// 193 + /// Web fonts registered via `@font-face` are checked before system fonts. 115 194 /// Falls back through: exact match -> any with same bold -> any in family. 116 195 pub fn find_font_with_style(&self, family: &str, bold: bool, italic: bool) -> Option<Font> { 117 196 let key = family.to_ascii_lowercase(); 197 + 198 + // Check web fonts first. 199 + if let Some(web_entries) = self.web_fonts.get(&key) { 200 + let target_weight: u16 = if bold { 700 } else { 400 }; 201 + let entry = web_entries 202 + .iter() 203 + .find(|e| e.weight == target_weight && e.italic == italic) 204 + .or_else(|| web_entries.iter().find(|e| e.weight == target_weight)) 205 + .or_else(|| web_entries.first()); 206 + if let Some(entry) = entry { 207 + if let Ok(font) = Font::parse(entry.data.clone()) { 208 + return Some(font); 209 + } 210 + } 211 + } 212 + 118 213 let entries = self.families.get(&key)?; 119 214 120 215 // Exact style match. ··· 169 264 None 170 265 } 171 266 267 + /// Find the first available web font, or fall back to system fonts. 268 + /// 269 + /// If any web fonts have been registered via `@font-face`, returns the 270 + /// first one. Otherwise, falls back to system font defaults. 271 + pub fn find_best_font(&self) -> Option<Font> { 272 + // Try web fonts first. 273 + for entries in self.web_fonts.values() { 274 + if let Some(entry) = entries.first() { 275 + if let Ok(font) = Font::parse(entry.data.clone()) { 276 + return Some(font); 277 + } 278 + } 279 + } 280 + // Fall back to system fonts. 281 + self.find_fallback() 282 + } 283 + 172 284 /// Number of distinct font families in the registry. 173 285 pub fn family_count(&self) -> usize { 174 286 self.families.len() ··· 478 590 reg.find_font("NonexistentFontFamily12345").is_none(), 479 591 "should return None for nonexistent family" 480 592 ); 593 + } 594 + 595 + // -- Web font registration ------------------------------------------------ 596 + 597 + fn load_test_font_data() -> Option<Vec<u8>> { 598 + // Use a real system font as test data. 599 + let path = Path::new("/System/Library/Fonts/Geneva.ttf"); 600 + if path.exists() { 601 + std::fs::read(path).ok() 602 + } else { 603 + None 604 + } 605 + } 606 + 607 + #[test] 608 + fn register_web_font_success() { 609 + let data = match load_test_font_data() { 610 + Some(d) => d, 611 + None => return, 612 + }; 613 + let mut reg = FontRegistry::new(); 614 + let state = reg.register_web_font("TestWebFont", data, 400, false); 615 + assert_eq!(state, WebFontState::Loaded); 616 + assert!(reg.has_web_font("TestWebFont")); 617 + } 618 + 619 + #[test] 620 + fn register_web_font_invalid_data() { 621 + let mut reg = FontRegistry::new(); 622 + let state = reg.register_web_font("BadFont", vec![0, 1, 2, 3], 400, false); 623 + assert_eq!(state, WebFontState::Failed); 624 + assert!(!reg.has_web_font("BadFont")); 625 + } 626 + 627 + #[test] 628 + fn web_font_takes_priority_over_system() { 629 + if !has_system_fonts() { 630 + return; 631 + } 632 + let data = match load_test_font_data() { 633 + Some(d) => d, 634 + None => return, 635 + }; 636 + let mut reg = FontRegistry::new(); 637 + // Register a web font under "Helvetica" (which also exists as a system font). 638 + let state = reg.register_web_font("Helvetica", data, 400, false); 639 + assert_eq!(state, WebFontState::Loaded); 640 + 641 + // find_font should still succeed (web font found first). 642 + let font = reg.find_font("Helvetica"); 643 + assert!(font.is_some(), "should find web font for Helvetica"); 644 + } 645 + 646 + #[test] 647 + fn web_font_case_insensitive() { 648 + let data = match load_test_font_data() { 649 + Some(d) => d, 650 + None => return, 651 + }; 652 + let mut reg = FontRegistry::new(); 653 + reg.register_web_font("MyCustomFont", data, 400, false); 654 + assert!(reg.has_web_font("mycustomfont")); 655 + assert!(reg.has_web_font("MYCUSTOMFONT")); 656 + } 657 + 658 + #[test] 659 + fn find_web_font_with_style() { 660 + let data = match load_test_font_data() { 661 + Some(d) => d, 662 + None => return, 663 + }; 664 + let mut reg = FontRegistry::new(); 665 + reg.register_web_font("StyledFont", data.clone(), 400, false); 666 + reg.register_web_font("StyledFont", data, 700, false); 667 + 668 + // Should find the bold variant. 669 + let font = reg.find_font_with_style("StyledFont", true, false); 670 + assert!(font.is_some(), "should find bold web font"); 671 + } 672 + 673 + #[test] 674 + fn find_font_returns_web_font_for_new_family() { 675 + let data = match load_test_font_data() { 676 + Some(d) => d, 677 + None => return, 678 + }; 679 + let mut reg = FontRegistry::new(); 680 + reg.register_web_font("BrandNewFamily", data, 400, false); 681 + 682 + let font = reg.find_font("BrandNewFamily"); 683 + assert!(font.is_some(), "should find web font by family name"); 481 684 } 482 685 }