diff --git a/.isu/issues.json b/.isu/issues.json index 6921a4f..f469af0 100644 --- a/.isu/issues.json +++ b/.isu/issues.json @@ -1,5 +1,5 @@ { - "next_id": 402, + "next_id": 403, "issues": [ { "id": 1, @@ -4909,6 +4909,19 @@ "author": "piefev", "state": "open", "created_at": "2026-07-18T06:45:57Z" + }, + { + "id": 402, + "repo": "we", + "title": "Opera parity remains blocked after multi-word font-face family fix", + "body": "Parent: isu issue 281\n\nThis pass fixed CSS parsing for unquoted multi-ident @font-face family names and local(...) font source names. Opera's snapshot now registers and resolves Be Vietnam Pro and Space Mono instead of truncating them to Be and Space, and the hero subhead wraps like the Chromium reference.\n\nFocused repro:\n cargo run -p we-e2e -- --scenario crates/e2e/scenarios/real-web/opera.com.we --out-dir crates/e2e/artifacts\n\nCurrent result after the font parser fix:\n - desktop assertion L99: 64.53% match, 435707/1228500 pixels differ\n - mobile assertion L110: 68.03% match, 105235/329160 pixels differ\n\nFor comparison, current main before the fix was:\n - desktop assertion L99: 63.42% match, 449439/1228500 pixels differ\n - mobile assertion L110: 67.31% match, 107605/329160 pixels differ\n\nRelevant artifacts from the focused run:\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 - crates/e2e/artifacts/real-web/opera.com/dom.txt\n - crates/e2e/artifacts/real-web/opera.com/console.txt\n\nRemaining symptoms are broad Chromium parity differences in text rasterization/vertical placement and image composition. WebP is still unsupported/skipped by img_loader, and this Opera scenario is already using committed PNG fallbacks rather than WebP bytes.\n\nAcceptance: continue reducing deterministic Opera text/image/layout differences and remove the xfail from crates/e2e/scenarios/real-web/opera.com.we only when both Chromium screenshot assertions pass against opera.com.{desktop,mobile}.chromium.expected.png.", + "labels": [ + "real-web" + ], + "assigned": [], + "author": "piefev", + "state": "open", + "created_at": "2026-07-18T07:06:15Z" } ] } diff --git a/crates/css/src/parser.rs b/crates/css/src/parser.rs index 9a01db9..2442f4a 100644 --- a/crates/css/src/parser.rs +++ b/crates/css/src/parser.rs @@ -1255,14 +1255,7 @@ fn token_to_string(token: &Token) -> String { /// Extract the family name from a `font-family` descriptor value. fn extract_font_face_family(values: &[ComponentValue]) -> Option { - for v in values { - match v { - ComponentValue::String(s) => return Some(s.clone()), - ComponentValue::Ident(s) => return Some(s.clone()), - _ => {} - } - } - None + extract_css_family_name(values) } /// Parse the `src` descriptor into a list of font sources. @@ -1312,14 +1305,38 @@ fn parse_font_face_src(values: &[ComponentValue]) -> Vec { /// Extract a string or ident from function arguments. fn extract_string_from_args(args: &[ComponentValue]) -> Option { - for arg in args { - match arg { - ComponentValue::String(s) => return Some(s.clone()), - ComponentValue::Ident(s) => return Some(s.clone()), + extract_css_family_name(args) +} + +fn extract_css_family_name(values: &[ComponentValue]) -> Option { + let mut parts = Vec::new(); + let mut saw_ident = false; + + for value in values { + match value { + ComponentValue::String(s) => { + if !s.is_empty() { + return Some(s.clone()); + } + } + ComponentValue::Ident(s) => { + if !s.is_empty() { + parts.push(s.clone()); + saw_ident = true; + } + } + ComponentValue::Whitespace if saw_ident => {} + ComponentValue::Comma if saw_ident => break, + _ if saw_ident => break, _ => {} } } - None + + if parts.is_empty() { + None + } else { + Some(parts.join(" ")) + } } /// Skip whitespace tokens and try to parse a `format()` function. @@ -2430,6 +2447,22 @@ mod tests { assert_eq!(ff.family, "CustomFont"); } + #[test] + fn font_face_unquoted_multi_word_family() { + let ss = Parser::parse( + r#"@font-face { + font-family: Be Vietnam Pro; + src: url("be-vietnam-pro.woff"); + }"#, + ); + assert_eq!(ss.rules.len(), 1); + let ff = match &ss.rules[0] { + Rule::FontFace(ff) => ff, + _ => panic!("expected FontFace rule"), + }; + assert_eq!(ff.family, "Be Vietnam Pro"); + } + #[test] fn font_face_unquoted_url_source() { let ss = Parser::parse( @@ -2450,6 +2483,29 @@ mod tests { ); } + #[test] + fn font_face_unquoted_multi_word_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[0].source, + FontFaceSourceKind::Local("Arial Bold".to_string()) + ); + assert_eq!( + ff.sources[1].source, + FontFaceSourceKind::Url("fallback.ttf".to_string()) + ); + } + #[test] fn font_face_among_other_rules() { let ss = Parser::parse(