LibWeb/CSS: Parse @page selectors

Ideally we'd be able to share the code between page selectors and style
ones, but given how simple page selectors are, some code duplication is
the simpler option.
This commit is contained in:
Sam Atkins 2025-05-13 13:05:08 +01:00
commit 1aa5631610
Notes: github-actions[bot] 2025-05-15 08:54:44 +00:00
6 changed files with 163 additions and 27 deletions

View file

@ -15,12 +15,56 @@ namespace Web::CSS {
GC_DEFINE_ALLOCATOR(CSSPageRule); GC_DEFINE_ALLOCATOR(CSSPageRule);
GC::Ref<CSSPageRule> CSSPageRule::create(JS::Realm& realm, SelectorList&& selectors, GC::Ref<CSSPageDescriptors> style, CSSRuleList& rules) Optional<PagePseudoClass> page_pseudo_class_from_string(StringView input)
{
if (input.equals_ignoring_ascii_case("blank"sv))
return PagePseudoClass::Blank;
if (input.equals_ignoring_ascii_case("first"sv))
return PagePseudoClass::First;
if (input.equals_ignoring_ascii_case("left"sv))
return PagePseudoClass::Left;
if (input.equals_ignoring_ascii_case("right"sv))
return PagePseudoClass::Right;
return {};
}
StringView to_string(PagePseudoClass pseudo_class)
{
switch (pseudo_class) {
case PagePseudoClass::Blank:
return "blank"sv;
case PagePseudoClass::First:
return "first"sv;
case PagePseudoClass::Left:
return "left"sv;
case PagePseudoClass::Right:
return "right"sv;
}
VERIFY_NOT_REACHED();
}
PageSelector::PageSelector(Optional<FlyString> name, Vector<PagePseudoClass> pseudo_classes)
: m_name(move(name))
, m_pseudo_classes(move(pseudo_classes))
{
}
String PageSelector::serialize() const
{
StringBuilder builder;
if (m_name.has_value())
builder.append(m_name.value());
for (auto pseudo_class : m_pseudo_classes)
builder.appendff(":{}", to_string(pseudo_class));
return builder.to_string_without_validation();
}
GC::Ref<CSSPageRule> CSSPageRule::create(JS::Realm& realm, PageSelectorList&& selectors, GC::Ref<CSSPageDescriptors> style, CSSRuleList& rules)
{ {
return realm.create<CSSPageRule>(realm, move(selectors), style, rules); return realm.create<CSSPageRule>(realm, move(selectors), style, rules);
} }
CSSPageRule::CSSPageRule(JS::Realm& realm, SelectorList&& selectors, GC::Ref<CSSPageDescriptors> style, CSSRuleList& rules) CSSPageRule::CSSPageRule(JS::Realm& realm, PageSelectorList&& selectors, GC::Ref<CSSPageDescriptors> style, CSSRuleList& rules)
: CSSGroupingRule(realm, rules, Type::Page) : CSSGroupingRule(realm, rules, Type::Page)
, m_selectors(move(selectors)) , m_selectors(move(selectors))
, m_style(style) , m_style(style)
@ -38,7 +82,10 @@ void CSSPageRule::initialize(JS::Realm& realm)
String CSSPageRule::selector_text() const String CSSPageRule::selector_text() const
{ {
// The selectorText attribute, on getting, must return the result of serializing the associated selector list. // The selectorText attribute, on getting, must return the result of serializing the associated selector list.
return serialize_a_group_of_selectors(m_selectors);
// https://www.w3.org/TR/cssom/#serialize-a-group-of-selectors
// To serialize a group of selectors serialize each selector in the group of selectors and then serialize a comma-separated list of these serializations.
return MUST(String::join(", "sv, m_selectors));
} }
// https://drafts.csswg.org/cssom/#dom-csspagerule-selectortext // https://drafts.csswg.org/cssom/#dom-csspagerule-selectortext

View file

@ -8,17 +8,39 @@
#include <LibWeb/CSS/CSSGroupingRule.h> #include <LibWeb/CSS/CSSGroupingRule.h>
#include <LibWeb/CSS/CSSPageDescriptors.h> #include <LibWeb/CSS/CSSPageDescriptors.h>
#include <LibWeb/CSS/Selector.h>
namespace Web::CSS { namespace Web::CSS {
enum class PagePseudoClass : u8 {
Left,
Right,
First,
Blank,
};
Optional<PagePseudoClass> page_pseudo_class_from_string(StringView);
StringView to_string(PagePseudoClass);
class PageSelector {
public:
PageSelector(Optional<FlyString> name, Vector<PagePseudoClass>);
Optional<FlyString> name() const { return m_name; }
Vector<PagePseudoClass> const& pseudo_classes() const { return m_pseudo_classes; }
String serialize() const;
private:
Optional<FlyString> m_name;
Vector<PagePseudoClass> m_pseudo_classes;
};
using PageSelectorList = Vector<PageSelector>;
// https://drafts.csswg.org/css-page-3/#at-ruledef-page // https://drafts.csswg.org/css-page-3/#at-ruledef-page
class CSSPageRule final : public CSSGroupingRule { class CSSPageRule final : public CSSGroupingRule {
WEB_PLATFORM_OBJECT(CSSPageRule, CSSGroupingRule); WEB_PLATFORM_OBJECT(CSSPageRule, CSSGroupingRule);
GC_DECLARE_ALLOCATOR(CSSPageRule); GC_DECLARE_ALLOCATOR(CSSPageRule);
public: public:
[[nodiscard]] static GC::Ref<CSSPageRule> create(JS::Realm&, SelectorList&&, GC::Ref<CSSPageDescriptors>, CSSRuleList&); [[nodiscard]] static GC::Ref<CSSPageRule> create(JS::Realm&, PageSelectorList&&, GC::Ref<CSSPageDescriptors>, CSSRuleList&);
virtual ~CSSPageRule() override = default; virtual ~CSSPageRule() override = default;
@ -29,14 +51,26 @@ public:
GC::Ref<CSSPageDescriptors const> descriptors() const { return m_style; } GC::Ref<CSSPageDescriptors const> descriptors() const { return m_style; }
private: private:
CSSPageRule(JS::Realm&, SelectorList&&, GC::Ref<CSSPageDescriptors>, CSSRuleList&); CSSPageRule(JS::Realm&, PageSelectorList&&, GC::Ref<CSSPageDescriptors>, CSSRuleList&);
virtual void initialize(JS::Realm&) override; virtual void initialize(JS::Realm&) override;
virtual String serialized() const override; virtual String serialized() const override;
virtual void visit_edges(Visitor&) override; virtual void visit_edges(Visitor&) override;
SelectorList m_selectors; PageSelectorList m_selectors;
GC::Ref<CSSPageDescriptors> m_style; GC::Ref<CSSPageDescriptors> m_style;
}; };
} }
namespace AK {
template<>
struct Formatter<Web::CSS::PageSelector> : Formatter<StringView> {
ErrorOr<void> format(FormatBuilder& builder, Web::CSS::PageSelector const& selector)
{
return Formatter<StringView>::format(builder, selector.serialize());
}
};
}

View file

@ -600,19 +600,75 @@ GC::Ptr<CSSFontFaceRule> Parser::convert_to_font_face_rule(AtRule const& rule)
return CSSFontFaceRule::create(realm(), CSSFontFaceDescriptors::create(realm(), move(descriptors))); return CSSFontFaceRule::create(realm(), CSSFontFaceDescriptors::create(realm(), move(descriptors)));
} }
static Optional<PageSelectorList> parse_page_selector_list(Vector<ComponentValue> const& component_values)
{
// https://drafts.csswg.org/css-page-3/#syntax-page-selector
// <page-selector-list> = <page-selector>#
// <page-selector> = [ <ident-token>? <pseudo-page>* ]!
// <pseudo-page> = : [ left | right | first | blank ]
PageSelectorList selector_list;
TokenStream tokens { component_values };
tokens.discard_whitespace();
while (tokens.has_next_token()) {
// First optional ident
Optional<FlyString> maybe_ident;
if (tokens.next_token().is(Token::Type::Ident))
maybe_ident = tokens.consume_a_token().token().ident();
// Then an optional series of pseudo-classes
Vector<PagePseudoClass> pseudo_classes;
while (tokens.next_token().is(Token::Type::Colon)) {
tokens.discard_a_token(); // :
if (!tokens.next_token().is(Token::Type::Ident)) {
dbgln_if(CSS_PARSER_DEBUG, "Invalid @page selector: pseudo-class is not an ident: `{}`", tokens.next_token().to_debug_string());
return {};
}
auto pseudo_class_name = tokens.consume_a_token().token().ident();
if (auto pseudo_class = page_pseudo_class_from_string(pseudo_class_name); pseudo_class.has_value()) {
pseudo_classes.append(*pseudo_class);
} else {
dbgln_if(CSS_PARSER_DEBUG, "Invalid @page selector: unrecognized pseudo-class `:{}`", pseudo_class_name);
return {};
}
}
if (!maybe_ident.has_value() && pseudo_classes.is_empty()) {
// Nothing parsed
dbgln_if(CSS_PARSER_DEBUG, "Invalid @page selector: is empty");
return {};
}
selector_list.empend(move(maybe_ident), move(pseudo_classes));
tokens.discard_whitespace();
if (tokens.next_token().is(Token::Type::Comma)) {
tokens.discard_a_token(); // ,
tokens.discard_whitespace();
if (!tokens.has_next_token()) {
dbgln_if(CSS_PARSER_DEBUG, "Invalid @page selector: trailing comma");
return {};
}
} else if (tokens.has_next_token()) {
dbgln_if(CSS_PARSER_DEBUG, "Invalid @page selector: trailing token `{}`", tokens.next_token().to_debug_string());
return {};
}
}
return selector_list;
}
GC::Ptr<CSSPageRule> Parser::convert_to_page_rule(AtRule const& rule) GC::Ptr<CSSPageRule> Parser::convert_to_page_rule(AtRule const& rule)
{ {
// https://drafts.csswg.org/css-page-3/#syntax-page-selector // https://drafts.csswg.org/css-page-3/#syntax-page-selector
// @page = @page <page-selector-list>? { <declaration-rule-list> } // @page = @page <page-selector-list>? { <declaration-rule-list> }
// <page-selector-list> = <page-selector># auto page_selectors = parse_page_selector_list(rule.prelude);
// <page-selector> = [ <ident-token>? <pseudo-page>* ]! if (!page_selectors.has_value())
// <pseudo-page> = : [ left | right | first | blank ]
SelectorList page_selectors;
// FIXME: Parse page selectors
if (rule.prelude.find_first_index_if([](ComponentValue const& it) { return !it.is(Token::Type::Whitespace); }).has_value()) {
dbgln("@page prelude wasn't empty!");
return nullptr; return nullptr;
}
GC::RootVector<GC::Ref<CSSRule>> child_rules { realm().heap() }; GC::RootVector<GC::Ref<CSSRule>> child_rules { realm().heap() };
Vector<Descriptor> descriptors; Vector<Descriptor> descriptors;
@ -636,7 +692,7 @@ GC::Ptr<CSSPageRule> Parser::convert_to_page_rule(AtRule const& rule)
}); });
auto rule_list = CSSRuleList::create(realm(), child_rules); auto rule_list = CSSRuleList::create(realm(), child_rules);
return CSSPageRule::create(realm(), move(page_selectors), CSSPageDescriptors::create(realm(), move(descriptors)), rule_list); return CSSPageRule::create(realm(), page_selectors.release_value(), CSSPageDescriptors::create(realm(), move(descriptors)), rule_list);
} }
} }

View file

@ -2,9 +2,9 @@ Harness status: OK
Found 4 tests Found 4 tests
1 Pass 3 Pass
3 Fail 1 Fail
Fail There should be 3 @page rules. Pass There should be 3 @page rules.
Pass Rule #0 Pass Rule #0
Fail Rule #1 Pass Rule #1
Fail Rule #2 Fail Rule #2

View file

@ -2,5 +2,5 @@ Harness status: OK
Found 1 tests Found 1 tests
1 Fail 1 Pass
Fail nested-rules-001 Pass nested-rules-001

View file

@ -2,13 +2,12 @@ Harness status: OK
Found 8 tests Found 8 tests
5 Pass 8 Pass
3 Fail
Pass page-rules-001 Pass page-rules-001
Pass @page , { } should be an invalid rule Pass @page , { } should be an invalid rule
Pass @page { } should be a valid rule Pass @page { } should be a valid rule
Fail @page a { } should be a valid rule Pass @page a { } should be a valid rule
Fail @page page1 { } should be a valid rule Pass @page page1 { } should be a valid rule
Fail @page name1, name2 { } should be a valid rule Pass @page name1, name2 { } should be a valid rule
Pass @page a, { } should be an invalid rule Pass @page a, { } should be an invalid rule
Pass @page ,a { } should be an invalid rule Pass @page ,a { } should be an invalid rule