From eb2ff3b88a230316dd7b174770caf45b832ca3db Mon Sep 17 00:00:00 2001 From: Pierre Le Fevre Date: Fri, 17 Jul 2026 13:19:24 +0800 Subject: [PATCH] Improve Opera font fallback handling References isu issue 281. Adds isu issue 396 for the remaining WOFF2 and per-run font-selection blocker. --- .isu/issues.json | 16 ++- crates/browser/src/font_loader.rs | 80 ++++++++++++--- crates/css/src/parser.rs | 87 ++++++++++++++--- crates/render/src/atlas.rs | 32 ++++++ crates/text/src/font/registry.rs | 157 +++++++++++++++++++++++++++++- 5 files changed, 343 insertions(+), 29 deletions(-) diff --git a/.isu/issues.json b/.isu/issues.json index 029c4d0..efe8330 100644 --- a/.isu/issues.json +++ b/.isu/issues.json @@ -1,5 +1,5 @@ { - "next_id": 396, + "next_id": 397, "issues": [ { "id": 1, @@ -4829,6 +4829,20 @@ "author": "piefev", "state": "open", "created_at": "2026-07-17T04:53:41Z" + }, + { + "id": 396, + "repo": "we", + "title": "Opera parity blocked on WOFF2 and per-run font selection", + "body": "Parent: isu issue 281. Follow-up from the local @font-face fallback and synthetic bold pass.\\n\\nThe Opera scenario now parses local(...) font-face sources and uses CSS font-weight for synthetic bold overstrike, but it still cannot remove the xfail.\\n\\nRepro:\\n cargo run -p we-e2e -- --scenario crates/e2e/scenarios/real-web/opera.com.we --out-dir crates/e2e/artifacts\\n\\nObserved after this pass:\\n- desktop L59: 39.29% match (745858/1228500 px differ, tol=4, max_diff=0.1000%)\\n- mobile L70: 51.66% match (159103/329160 px differ, tol=4, max_diff=0.1000%)\\n- warnings still report nine web fonts and nine post-load web fonts failed to load in each viewport.\\n\\nRoot remaining font blocker:\\n- The cached Opera files are WOFF2 containers (wOF2 magic) and Font::parse only accepts raw sfnt TrueType/OpenType data.\\n- Inline local fallback @font-face rules now resolve to system fonts, but the layout/render path still chooses a single page-wide Font instead of resolving CSS font-family and weight per TextLine, so real fallback face selection is incomplete.\\n\\nOther isu issue 395 blockers still apply: the cookie panel and desktop header icons require deterministic Opera hydration/icon snapshot coverage.\\n\\nArtifacts:\\n- crates/e2e/artifacts/real-web/opera.com/desktop.png\\n- crates/e2e/artifacts/real-web/opera.com/desktop.png.diff.png\\n- crates/e2e/artifacts/real-web/opera.com/mobile.png\\n- crates/e2e/artifacts/real-web/opera.com/mobile.png.diff.png\\n\\nAcceptance: implement WOFF2 decoding or a supported WOFF-to-sfnt path for cached web fonts, then select the resolved CSS font family/weight/style per text run so opera.com.we can use the intended Be Vietnam Pro/Inter faces and issue 281 can continue toward removing its xfail.", + "labels": [ + "real-web", + "text" + ], + "assigned": [], + "author": "piefev", + "state": "open", + "created_at": "2026-07-17T05:14:26Z" } ] } diff --git a/crates/browser/src/font_loader.rs b/crates/browser/src/font_loader.rs index abb9e14..a342651 100644 --- a/crates/browser/src/font_loader.rs +++ b/crates/browser/src/font_loader.rs @@ -4,7 +4,9 @@ //! from the declared `src` URLs, and registers them in the `FontRegistry` //! so they take priority over system fonts during text rendering. -use we_css::parser::{FontDisplay, FontFaceRule, FontFaceStyle, Rule, Stylesheet}; +use we_css::parser::{ + FontDisplay, FontFaceRule, FontFaceSourceKind, FontFaceStyle, Rule, Stylesheet, +}; use we_text::font::registry::{FontRegistry, WebFontState}; use we_url::{Origin, Url}; @@ -80,7 +82,7 @@ fn collect_font_face_rules(rules: &[Rule]) -> Vec<&FontFaceRule> { result } -/// Try to load a single `@font-face` rule. Tries each source URL in order. +/// Try to load a single `@font-face` rule. Tries each source in order. /// /// Returns `true` if the font was successfully loaded and registered. fn try_load_font_face( @@ -93,18 +95,29 @@ fn try_load_font_face( let italic = rule.style == FontFaceStyle::Italic || rule.style == FontFaceStyle::Oblique; for source in &rule.sources { - // Try to fetch the font data. - let data = match fetch_font_data(loader, &source.url, base_url, document_origin) { - Some(d) => d, - None => continue, - }; - - // Register the font in the registry. - let state = registry.register_web_font(&rule.family, data, rule.weight, italic); - if state == WebFontState::Loaded { - return true; + match &source.source { + FontFaceSourceKind::Local(local_name) => { + let state = + registry.register_local_font(&rule.family, local_name, rule.weight, italic); + if state == WebFontState::Loaded { + return true; + } + } + FontFaceSourceKind::Url(url) => { + // Try to fetch the font data. + let data = match fetch_font_data(loader, url, base_url, document_origin) { + Some(d) => d, + None => continue, + }; + + // Register the font in the registry. + let state = registry.register_web_font(&rule.family, data, rule.weight, italic); + if state == WebFontState::Loaded { + return true; + } + } } - // If parsing failed, try the next source. + // If lookup, fetch, or parsing failed, try the next source. } false @@ -183,15 +196,24 @@ mod tests { assert_eq!(rules[0].style, FontFaceStyle::Italic); assert_eq!(rules[0].display, FontDisplay::Swap); assert_eq!(rules[0].sources.len(), 1); - assert_eq!(rules[0].sources[0].url, "myfont.ttf"); + assert_eq!( + rules[0].sources[0].source, + FontFaceSourceKind::Url("myfont.ttf".to_string()) + ); assert_eq!(rules[1].family, "OtherFont"); assert_eq!(rules[1].weight, 400); assert_eq!(rules[1].style, FontFaceStyle::Normal); assert_eq!(rules[1].sources.len(), 2); - assert_eq!(rules[1].sources[0].url, "other.woff2"); + assert_eq!( + rules[1].sources[0].source, + FontFaceSourceKind::Url("other.woff2".to_string()) + ); assert_eq!(rules[1].sources[0].format.as_deref(), Some("woff2")); - assert_eq!(rules[1].sources[1].url, "other.ttf"); + assert_eq!( + rules[1].sources[1].source, + FontFaceSourceKind::Url("other.ttf".to_string()) + ); assert_eq!(rules[1].sources[1].format.as_deref(), Some("truetype")); } @@ -250,4 +272,30 @@ mod tests { assert_eq!(result.failed, 1); assert!(!registry.has_web_font("MissingFont")); } + + #[test] + fn load_web_fonts_local_source() { + if !std::path::Path::new("/System/Library/Fonts").exists() { + return; + } + + let css = r#" + @font-face { + font-family: "LocalFallback"; + src: local("Arial"); + } + "#; + let stylesheet = Parser::parse(css); + let mut loader = ResourceLoader::new(); + let base = Url::parse("http://example.com/").unwrap(); + let mut registry = FontRegistry::new(); + + let result = load_web_fonts(&stylesheet, &mut loader, &base, &mut registry); + if result.loaded == 0 { + return; + } + + assert_eq!(result.failed, 0); + assert!(registry.has_web_font("LocalFallback")); + } } diff --git a/crates/css/src/parser.rs b/crates/css/src/parser.rs index c6b91a2..9a01db9 100644 --- a/crates/css/src/parser.rs +++ b/crates/css/src/parser.rs @@ -58,7 +58,7 @@ pub struct ImportRule { pub struct FontFaceRule { /// The font family name declared for this font face. pub family: String, - /// `src` descriptor: list of font sources (currently only `url()` references). + /// `src` descriptor: list of font sources. pub sources: Vec, /// `font-weight`: numeric weight (100–900). Defaults to 400. pub weight: u16, @@ -71,12 +71,21 @@ pub struct FontFaceRule { /// A single source in a `@font-face` `src` descriptor. #[derive(Debug, Clone, PartialEq)] pub struct FontFaceSource { - /// URL to the font file. - pub url: String, + /// Source value: either a URL to fetch or a local font face name. + pub source: FontFaceSourceKind, /// Optional `format()` hint (e.g., "truetype", "opentype", "woff2"). pub format: Option, } +/// The source kind for a single `@font-face src` entry. +#[derive(Debug, Clone, PartialEq)] +pub enum FontFaceSourceKind { + /// A network or relative URL source. + Url(String), + /// A platform-local font face name from `local(...)`. + Local(String), +} + /// `font-style` descriptor values for `@font-face`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FontFaceStyle { @@ -1258,8 +1267,8 @@ fn extract_font_face_family(values: &[ComponentValue]) -> Option { /// Parse the `src` descriptor into a list of font sources. /// -/// Supports `url("...")` and `url("...") format("...")` syntax. -/// Multiple sources are comma-separated. +/// Supports `url("...")`, `local("...")`, and optional `format("...")` +/// syntax. Multiple sources are comma-separated. fn parse_font_face_src(values: &[ComponentValue]) -> Vec { let mut sources = Vec::new(); let mut i = 0; @@ -1272,7 +1281,21 @@ fn parse_font_face_src(values: &[ComponentValue]) -> Vec { // Check for optional format() hint. let format = skip_ws_and_parse_format(values, &mut i); if let Some(url) = url { - sources.push(FontFaceSource { url, format }); + sources.push(FontFaceSource { + source: FontFaceSourceKind::Url(url), + format, + }); + } + } + ComponentValue::Function(name, args) if name.eq_ignore_ascii_case("local") => { + let local_name = extract_string_from_args(args); + i += 1; + let format = skip_ws_and_parse_format(values, &mut i); + if let Some(local_name) = local_name { + sources.push(FontFaceSource { + source: FontFaceSourceKind::Local(local_name), + format, + }); } } ComponentValue::Comma => { @@ -2204,7 +2227,10 @@ mod tests { }; assert_eq!(ff.family, "MyFont"); assert_eq!(ff.sources.len(), 1); - assert_eq!(ff.sources[0].url, "myfont.ttf"); + assert_eq!( + ff.sources[0].source, + FontFaceSourceKind::Url("myfont.ttf".to_string()) + ); assert!(ff.sources[0].format.is_none()); assert_eq!(ff.weight, 400); assert_eq!(ff.style, FontFaceStyle::Normal); @@ -2229,7 +2255,10 @@ mod tests { }; assert_eq!(ff.family, "WebFont"); assert_eq!(ff.sources.len(), 1); - assert_eq!(ff.sources[0].url, "webfont.woff2"); + assert_eq!( + ff.sources[0].source, + FontFaceSourceKind::Url("webfont.woff2".to_string()) + ); assert_eq!(ff.sources[0].format.as_deref(), Some("woff2")); assert_eq!(ff.weight, 700); assert_eq!(ff.style, FontFaceStyle::Italic); @@ -2297,10 +2326,43 @@ mod tests { _ => panic!("expected FontFace rule"), }; assert_eq!(ff.sources.len(), 2); - assert_eq!(ff.sources[0].url, "font.woff2"); + assert_eq!( + ff.sources[0].source, + FontFaceSourceKind::Url("font.woff2".to_string()) + ); assert_eq!(ff.sources[0].format.as_deref(), Some("woff2")); - assert_eq!(ff.sources[1].url, "font.ttf"); + assert_eq!( + ff.sources[1].source, + FontFaceSourceKind::Url("font.ttf".to_string()) + ); + assert_eq!(ff.sources[1].format.as_deref(), Some("truetype")); + } + + #[test] + fn font_face_local_source() { + let ss = Parser::parse( + r#"@font-face { + font-family: "Fallback"; + src: local("Arial Bold"), url("fallback.ttf") format("truetype"); + font-weight: 700; + }"#, + ); + let ff = match &ss.rules[0] { + Rule::FontFace(ff) => ff, + _ => panic!("expected FontFace rule"), + }; + assert_eq!(ff.sources.len(), 2); + assert_eq!( + ff.sources[0].source, + FontFaceSourceKind::Local("Arial Bold".to_string()) + ); + assert!(ff.sources[0].format.is_none()); + assert_eq!( + ff.sources[1].source, + FontFaceSourceKind::Url("fallback.ttf".to_string()) + ); assert_eq!(ff.sources[1].format.as_deref(), Some("truetype")); + assert_eq!(ff.weight, 700); } #[test] @@ -2382,7 +2444,10 @@ mod tests { _ => panic!("expected FontFace rule"), }; assert_eq!(ff.sources.len(), 1); - assert_eq!(ff.sources[0].url, "plain.ttf"); + assert_eq!( + ff.sources[0].source, + FontFaceSourceKind::Url("plain.ttf".to_string()) + ); } #[test] diff --git a/crates/render/src/atlas.rs b/crates/render/src/atlas.rs index bf0387c..1ea348e 100644 --- a/crates/render/src/atlas.rs +++ b/crates/render/src/atlas.rs @@ -422,12 +422,37 @@ impl GlyphAtlas { color, page: region.page, }); + + if line.bold { + // Until the text pipeline selects real bold faces per run, + // approximate CSS bold by overstriking the glyph mask with a + // tiny horizontal offset. Advances stay unchanged, matching + // browser synthetic-bold behavior closely enough for fallback + // faces and web-font load failures. + let offset = synthetic_bold_offset(size_px); + quads.push(TexturedQuad { + x: gx + offset, + y: gy, + width: region.width as f32 / scale, + height: region.height as f32 / scale, + u0, + v0, + u1, + v1, + color, + page: region.page, + }); + } } quads } } +fn synthetic_bold_offset(size_px: f32) -> f32 { + (size_px * 0.0125).clamp(0.35, 0.9) +} + impl Default for GlyphAtlas { fn default() -> Self { Self::new() @@ -707,6 +732,13 @@ mod tests { assert!(!atlas.is_page_dirty(0)); } + #[test] + fn synthetic_bold_offset_scales_with_size() { + assert_eq!(synthetic_bold_offset(8.0), 0.35); + assert!(synthetic_bold_offset(48.0) > synthetic_bold_offset(16.0)); + assert_eq!(synthetic_bold_offset(100.0), 0.9); + } + #[test] fn clear_dirty_nonexistent_page() { let mut atlas = GlyphAtlas::new(); diff --git a/crates/text/src/font/registry.rs b/crates/text/src/font/registry.rs index 4bdd22c..71eb3c8 100644 --- a/crates/text/src/font/registry.rs +++ b/crates/text/src/font/registry.rs @@ -38,6 +38,23 @@ pub struct WebFontEntry { pub data: Vec, } +/// A local font face registered as an alias via `@font-face src: local(...)`. +#[derive(Debug, Clone)] +pub struct LocalFontEntry { + /// The declared font-family name from `@font-face`. + pub family: String, + /// The local face name from `local(...)`. + pub local_name: String, + /// Numeric font weight (100-900). + pub weight: u16, + /// Whether this face is italic. + pub italic: bool, + /// File path to the resolved system font. + pub path: PathBuf, + /// Byte offset within the file (0 for standalone .ttf/.otf, nonzero for .ttc fonts). + pub offset: u32, +} + /// Loading state for a web font. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WebFontState { @@ -55,6 +72,8 @@ pub struct FontRegistry { families: HashMap>, /// Web fonts registered via `@font-face`, indexed by lowercase family name. web_fonts: HashMap>, + /// Local font aliases registered via `@font-face src: local(...)`. + local_fonts: HashMap>, } impl Default for FontRegistry { @@ -83,6 +102,7 @@ impl FontRegistry { FontRegistry { families, web_fonts: HashMap::new(), + local_fonts: HashMap::new(), } } @@ -113,10 +133,41 @@ impl FontRegistry { WebFontState::Loaded } + /// Register a local system font face under a declared web font family. + /// + /// `local_name` matches either a family name such as `Arial`, a full face + /// name such as `Arial Bold`, or a family plus subfamily pair discovered in + /// the system registry. The font is loaded later from its original file so + /// TTC faces keep their collection offset. + pub fn register_local_font( + &mut self, + family: &str, + local_name: &str, + weight: u16, + italic: bool, + ) -> WebFontState { + let bold = weight >= 600; + let Some(source) = self.find_local_entry(local_name, bold, italic).cloned() else { + return WebFontState::Failed; + }; + + let key = family.to_ascii_lowercase(); + let entry = LocalFontEntry { + family: family.to_owned(), + local_name: local_name.to_owned(), + weight, + italic, + path: source.path, + offset: source.offset, + }; + self.local_fonts.entry(key).or_default().push(entry); + WebFontState::Loaded + } + /// Check if a web font is registered for the given family name. pub fn has_web_font(&self, family: &str) -> bool { let key = family.to_ascii_lowercase(); - self.web_fonts.contains_key(&key) + self.web_fonts.contains_key(&key) || self.local_fonts.contains_key(&key) } /// Find a font by family name. Returns the first match (prefers Regular). @@ -140,6 +191,14 @@ impl FontRegistry { } } + if let Some(local_entries) = self.local_fonts.get(&key) { + let entry = local_entries + .iter() + .find(|e| e.weight == 400 && !e.italic) + .or_else(|| local_entries.first())?; + return load_font_at_offset(&entry.path, entry.offset).ok(); + } + let entries = self.families.get(&key)?; // Prefer the Regular face. @@ -173,6 +232,16 @@ impl FontRegistry { } } + if let Some(local_entries) = self.local_fonts.get(&key) { + let target_weight: u16 = if bold { 700 } else { 400 }; + let entry = local_entries + .iter() + .find(|e| e.weight == target_weight && e.italic == italic) + .or_else(|| local_entries.iter().find(|e| e.weight == target_weight)) + .or_else(|| local_entries.first())?; + return load_font_at_offset(&entry.path, entry.offset).ok(); + } + let entries = self.families.get(&key)?; // Exact style match. @@ -236,6 +305,45 @@ impl FontRegistry { self.find_font(family).or_else(|| self.find_fallback()) } + fn find_local_entry(&self, local_name: &str, bold: bool, italic: bool) -> Option<&FontEntry> { + let key = local_name.to_ascii_lowercase(); + + if let Some(entries) = self.families.get(&key) { + return entries + .iter() + .find(|e| e.bold == bold && e.italic == italic) + .or_else(|| entries.iter().find(|e| e.bold == bold)) + .or_else(|| entries.first()); + } + + let mut first_full_match = None; + let mut first_loose_match = None; + for entries in self.families.values() { + for entry in entries { + let full_name = format!("{} {}", entry.family, entry.subfamily); + if full_name.eq_ignore_ascii_case(local_name) { + if entry.bold == bold && entry.italic == italic { + return Some(entry); + } + if first_full_match.is_none() { + first_full_match = Some(entry); + } + } else if entry.family.eq_ignore_ascii_case(local_name) + || entry.subfamily.eq_ignore_ascii_case(local_name) + { + if entry.bold == bold && entry.italic == italic { + return Some(entry); + } + if first_loose_match.is_none() { + first_loose_match = Some(entry); + } + } + } + } + + first_full_match.or(first_loose_match) + } + /// Find the first available web font, or fall back to system fonts. /// /// If any web fonts have been registered via `@font-face`, returns the @@ -249,6 +357,17 @@ impl FontRegistry { } } } + for entries in self.local_fonts.values() { + if let Some(entry) = entries + .iter() + .find(|e| e.weight == 400 && !e.italic) + .or_else(|| entries.first()) + { + if let Ok(font) = load_font_at_offset(&entry.path, entry.offset) { + return Some(font); + } + } + } // Fall back to system fonts. self.find_fallback() } @@ -736,4 +855,40 @@ mod tests { let font = reg.find_font("BrandNewFamily"); assert!(font.is_some(), "should find web font by family name"); } + + #[test] + fn register_local_font_alias() { + if !has_system_fonts() { + return; + } + + let mut reg = FontRegistry::new(); + let state = reg.register_local_font("LocalAlias", "Arial", 400, false); + if state == WebFontState::Failed { + return; + } + + assert!(reg.has_web_font("LocalAlias")); + assert!( + reg.find_font("LocalAlias").is_some(), + "local alias should resolve through registry" + ); + } + + #[test] + fn register_local_font_alias_full_face_name() { + let path = Path::new("/System/Library/Fonts/Supplemental/Arial Bold.ttf"); + if !path.exists() { + return; + } + + let mut reg = FontRegistry::new(); + let state = reg.register_local_font("LocalBoldAlias", "Arial Bold", 700, false); + assert_eq!(state, WebFontState::Loaded); + assert!( + reg.find_font_with_style("LocalBoldAlias", true, false) + .is_some(), + "full local face names should resolve" + ); + } } -- 2.51.2