LibWeb/CSS: Parse @font-face descriptors as style values

CSSFontFaceRule now stores its values as a CSSFontFaceDescriptors, with
a ParsedFontFace produced on request. This is exposed via the `style`
attribute, so we pass a lot of tests that try to read values from
that.

We have one test regression, which we passed by mistake before: The test
wanted to ensure we don't allow `@font-face` nested inside other rules.
We passed it just because we discarded any `@font-face` without a
`font-family`. What we're supposed to do is 1) keep at-rules with
missing required descriptors and just not use them, and 2) reject
certain ones when nested.

We may want to cache the ParsedFontFace in the future, but I didn't here
because 1) it's called rarely, and 2) that would mean knowing to
invalidate it when the CSSFontFaceDescriptors changes, which isn't
obvious to me right now.
This commit is contained in:
Sam Atkins 2025-04-03 12:05:49 +01:00
parent 3c9685ff1a
commit f87b454fa9
Notes: github-actions[bot] 2025-04-04 09:41:41 +00:00
12 changed files with 118 additions and 378 deletions

View file

@ -699,296 +699,22 @@ template Vector<ParsedFontFace::Source> Parser::parse_font_face_src(TokenStream<
GC::Ptr<CSSFontFaceRule> Parser::convert_to_font_face_rule(AtRule const& rule)
{
// https://drafts.csswg.org/css-fonts/#font-face-rule
Optional<FlyString> font_family;
Optional<FlyString> font_named_instance;
Vector<ParsedFontFace::Source> src;
Vector<Gfx::UnicodeRange> unicode_range;
Optional<int> weight;
Optional<int> slope;
Optional<int> width;
Optional<Percentage> ascent_override;
Optional<Percentage> descent_override;
Optional<Percentage> line_gap_override;
FontDisplay font_display = FontDisplay::Auto;
Optional<FlyString> language_override;
Optional<OrderedHashMap<FlyString, i64>> font_feature_settings;
Optional<OrderedHashMap<FlyString, double>> font_variation_settings;
// "normal" is returned as nullptr
auto parse_as_percentage_or_normal = [&](Vector<ComponentValue> const& values) -> ErrorOr<Optional<Percentage>> {
// normal | <percentage [0,∞]>
TokenStream tokens { values };
if (auto percentage_value = parse_percentage_value(tokens)) {
tokens.discard_whitespace();
if (tokens.has_next_token())
return Error::from_string_literal("Unexpected trailing tokens");
if (percentage_value->is_percentage() && percentage_value->as_percentage().percentage().value() >= 0)
return percentage_value->as_percentage().percentage();
// TODO: Once we implement calc-simplification in the parser, we should no longer see math values here,
// unless they're impossible to resolve and thus invalid.
if (percentage_value->is_calculated()) {
if (auto result = percentage_value->as_calculated().resolve_percentage({}); result.has_value())
return result.value();
}
return Error::from_string_literal("Invalid percentage");
}
tokens.discard_whitespace();
if (!tokens.consume_a_token().is_ident("normal"sv))
return Error::from_string_literal("Expected `normal | <percentage [0,∞]>`");
tokens.discard_whitespace();
if (tokens.has_next_token())
return Error::from_string_literal("Unexpected trailing tokens");
return OptionalNone {};
};
Vector<Descriptor> descriptors;
HashTable<DescriptorID> seen_descriptor_ids;
rule.for_each_as_declaration_list([&](auto& declaration) {
if (declaration.name.equals_ignoring_ascii_case("ascent-override"sv)) {
auto value = parse_as_percentage_or_normal(declaration.value);
if (value.is_error()) {
dbgln_if(CSS_PARSER_DEBUG, "CSSParser: Failed to parse @font-face ascent-override: {}", value.error());
if (auto descriptor = convert_to_descriptor(AtRuleID::FontFace, declaration); descriptor.has_value()) {
if (seen_descriptor_ids.contains(descriptor->descriptor_id)) {
descriptors.remove_first_matching([&descriptor](Descriptor const& existing) {
return existing.descriptor_id == descriptor->descriptor_id;
});
} else {
ascent_override = value.release_value();
seen_descriptor_ids.set(descriptor->descriptor_id);
}
return;
descriptors.append(descriptor.release_value());
}
if (declaration.name.equals_ignoring_ascii_case("descent-override"sv)) {
auto value = parse_as_percentage_or_normal(declaration.value);
if (value.is_error()) {
dbgln_if(CSS_PARSER_DEBUG, "CSSParser: Failed to parse @font-face descent-override: {}", value.error());
} else {
descent_override = value.release_value();
}
return;
}
if (declaration.name.equals_ignoring_ascii_case("font-display"sv)) {
TokenStream token_stream { declaration.value };
if (auto keyword_value = parse_keyword_value(token_stream)) {
token_stream.discard_whitespace();
if (token_stream.has_next_token()) {
dbgln_if(CSS_PARSER_DEBUG, "CSSParser: Unexpected trailing tokens in font-display");
} else {
auto value = keyword_to_font_display(keyword_value->to_keyword());
if (value.has_value()) {
font_display = *value;
} else {
dbgln_if(CSS_PARSER_DEBUG, "CSSParser: `{}` is not a valid value for font-display", keyword_value->to_string(CSSStyleValue::SerializationMode::Normal));
}
}
}
return;
}
if (declaration.name.equals_ignoring_ascii_case("font-family"sv)) {
// FIXME: This is very similar to, but different from, the logic in parse_font_family_value().
// Ideally they could share code.
Vector<FlyString> font_family_parts;
bool had_syntax_error = false;
for (size_t i = 0; i < declaration.value.size(); ++i) {
auto const& part = declaration.value[i];
if (part.is(Token::Type::Whitespace))
continue;
if (part.is(Token::Type::String)) {
if (!font_family_parts.is_empty()) {
dbgln_if(CSS_PARSER_DEBUG, "CSSParser: @font-face font-family format invalid; discarding.");
had_syntax_error = true;
break;
}
font_family_parts.append(part.token().string());
continue;
}
if (part.is(Token::Type::Ident)) {
if (is_css_wide_keyword(part.token().ident())) {
dbgln_if(CSS_PARSER_DEBUG, "CSSParser: @font-face font-family format invalid; discarding.");
had_syntax_error = true;
break;
}
auto keyword = keyword_from_string(part.token().ident());
if (keyword.has_value() && keyword_to_generic_font_family(keyword.value()).has_value()) {
dbgln_if(CSS_PARSER_DEBUG, "CSSParser: @font-face font-family format invalid; discarding.");
had_syntax_error = true;
break;
}
font_family_parts.append(part.token().ident());
continue;
}
dbgln_if(CSS_PARSER_DEBUG, "CSSParser: @font-face font-family format invalid; discarding.");
had_syntax_error = true;
break;
}
if (had_syntax_error || font_family_parts.is_empty())
return;
font_family = String::join(' ', font_family_parts).release_value_but_fixme_should_propagate_errors();
return;
}
if (declaration.name.equals_ignoring_ascii_case("font-feature-settings"sv)) {
TokenStream token_stream { declaration.value };
if (auto value = parse_css_value(CSS::PropertyID::FontFeatureSettings, token_stream); !value.is_error()) {
if (value.value()->to_keyword() == Keyword::Normal) {
font_feature_settings.clear();
} else if (value.value()->is_value_list()) {
auto const& feature_tags = value.value()->as_value_list().values();
OrderedHashMap<FlyString, i64> settings;
settings.ensure_capacity(feature_tags.size());
for (auto const& feature_tag : feature_tags) {
if (!feature_tag->is_open_type_tagged()) {
dbgln_if(CSS_PARSER_DEBUG, "CSSParser: Value in font-feature-settings descriptor is not an OpenTypeTaggedStyleValue; skipping");
continue;
}
auto const& setting_value = feature_tag->as_open_type_tagged().value();
if (setting_value->is_integer()) {
settings.set(feature_tag->as_open_type_tagged().tag(), setting_value->as_integer().integer());
} else if (setting_value->is_calculated() && setting_value->as_calculated().resolves_to_number()) {
if (auto integer = setting_value->as_calculated().resolve_integer({}); integer.has_value()) {
settings.set(feature_tag->as_open_type_tagged().tag(), *integer);
} else {
dbgln_if(CSS_PARSER_DEBUG, "CSSParser: Calculated value in font-feature-settings descriptor cannot be resolved at parse time; skipping");
}
} else {
dbgln_if(CSS_PARSER_DEBUG, "CSSParser: Value in font-feature-settings descriptor is not an OpenTypeTaggedStyleValue holding a <integer>; skipping");
}
}
font_feature_settings = move(settings);
} else {
dbgln_if(CSS_PARSER_DEBUG, "CSSParser: Failed to parse font-feature-settings descriptor, not compatible with value returned from parsing font-feature-settings property: {}", value.value()->to_string(CSSStyleValue::SerializationMode::Normal));
}
}
return;
}
if (declaration.name.equals_ignoring_ascii_case("font-language-override"sv)) {
TokenStream token_stream { declaration.value };
if (auto maybe_value = parse_css_value(CSS::PropertyID::FontLanguageOverride, token_stream); !maybe_value.is_error()) {
auto& value = maybe_value.value();
if (value->is_string()) {
language_override = value->as_string().string_value();
} else {
language_override.clear();
}
}
return;
}
if (declaration.name.equals_ignoring_ascii_case("font-named-instance"sv)) {
// auto | <string>
TokenStream token_stream { declaration.value };
token_stream.discard_whitespace();
auto& token = token_stream.consume_a_token();
token_stream.discard_whitespace();
if (token_stream.has_next_token()) {
dbgln_if(CSS_PARSER_DEBUG, "CSSParser: Unexpected trailing tokens in font-named-instance");
return;
}
if (token.is_ident("auto"sv)) {
font_named_instance.clear();
} else if (token.is(Token::Type::String)) {
font_named_instance = token.token().string();
} else {
dbgln_if(CSS_PARSER_DEBUG, "CSSParser: Failed to parse font-named-instance from {}", token.to_debug_string());
}
return;
}
if (declaration.name.equals_ignoring_ascii_case("font-style"sv)) {
TokenStream token_stream { declaration.value };
if (auto value = parse_css_value(CSS::PropertyID::FontStyle, token_stream); !value.is_error()) {
slope = value.value()->to_font_slope();
}
return;
}
if (declaration.name.equals_ignoring_ascii_case("font-variation-settings"sv)) {
TokenStream token_stream { declaration.value };
if (auto value = parse_css_value(CSS::PropertyID::FontVariationSettings, token_stream); !value.is_error()) {
if (value.value()->to_keyword() == Keyword::Normal) {
font_variation_settings.clear();
} else if (value.value()->is_value_list()) {
auto const& variation_tags = value.value()->as_value_list().values();
OrderedHashMap<FlyString, double> settings;
settings.ensure_capacity(variation_tags.size());
for (auto const& variation_tag : variation_tags) {
if (!variation_tag->is_open_type_tagged()) {
dbgln_if(CSS_PARSER_DEBUG, "CSSParser: Value in font-variation-settings descriptor is not an OpenTypeTaggedStyleValue; skipping");
continue;
}
auto const& setting_value = variation_tag->as_open_type_tagged().value();
if (setting_value->is_number()) {
settings.set(variation_tag->as_open_type_tagged().tag(), setting_value->as_number().number());
} else if (setting_value->is_calculated() && setting_value->as_calculated().resolves_to_number()) {
if (auto number = setting_value->as_calculated().resolve_number({}); number.has_value()) {
settings.set(variation_tag->as_open_type_tagged().tag(), *number);
} else {
dbgln_if(CSS_PARSER_DEBUG, "CSSParser: Calculated value in font-variation-settings descriptor cannot be resolved at parse time; skipping");
}
} else {
dbgln_if(CSS_PARSER_DEBUG, "CSSParser: Value in font-variation-settings descriptor is not an OpenTypeTaggedStyleValue holding a <number>; skipping");
}
}
font_variation_settings = move(settings);
} else {
dbgln_if(CSS_PARSER_DEBUG, "CSSParser: Failed to parse font-variation-settings descriptor, not compatible with value returned from parsing font-variation-settings property: {}", value.value()->to_string(CSSStyleValue::SerializationMode::Normal));
}
}
return;
}
if (declaration.name.equals_ignoring_ascii_case("font-weight"sv)) {
TokenStream token_stream { declaration.value };
if (auto value = parse_css_value(CSS::PropertyID::FontWeight, token_stream); !value.is_error()) {
weight = value.value()->to_font_weight();
}
return;
}
if (declaration.name.equals_ignoring_ascii_case("font-width"sv)
|| declaration.name.equals_ignoring_ascii_case("font-stretch"sv)) {
TokenStream token_stream { declaration.value };
if (auto value = parse_css_value(CSS::PropertyID::FontWidth, token_stream); !value.is_error()) {
width = value.value()->to_font_width();
}
return;
}
if (declaration.name.equals_ignoring_ascii_case("line-gap-override"sv)) {
auto value = parse_as_percentage_or_normal(declaration.value);
if (value.is_error()) {
dbgln_if(CSS_PARSER_DEBUG, "CSSParser: Failed to parse @font-face line-gap-override: {}", value.error());
} else {
line_gap_override = value.release_value();
}
return;
}
if (declaration.name.equals_ignoring_ascii_case("src"sv)) {
TokenStream token_stream { declaration.value };
Vector<ParsedFontFace::Source> supported_sources = parse_font_face_src(token_stream);
if (!supported_sources.is_empty())
src = move(supported_sources);
return;
}
if (declaration.name.equals_ignoring_ascii_case("unicode-range"sv)) {
TokenStream token_stream { declaration.value };
auto unicode_ranges = parse_unicode_ranges(token_stream);
if (unicode_ranges.is_empty())
return;
unicode_range = move(unicode_ranges);
return;
}
dbgln_if(CSS_PARSER_DEBUG, "CSSParser: Unrecognized descriptor '{}' in @font-face; discarding.", declaration.name);
});
if (!font_family.has_value()) {
dbgln_if(CSS_PARSER_DEBUG, "CSSParser: Failed to parse @font-face: no font-family!");
return {};
}
if (unicode_range.is_empty()) {
unicode_range.empend(0x0u, 0x10FFFFu);
}
return CSSFontFaceRule::create(realm(), ParsedFontFace { font_family.release_value(), move(weight), move(slope), move(width), move(src), move(unicode_range), move(ascent_override), move(descent_override), move(line_gap_override), font_display, move(font_named_instance), move(language_override), move(font_feature_settings), move(font_variation_settings) });
return CSSFontFaceRule::create(realm(), CSSFontFaceDescriptors::create(realm(), move(descriptors)));
}
}