LibWeb: Resolve list item marker using registered counter styles

This commit is contained in:
Callum Law 2026-02-09 21:44:19 +13:00 committed by Sam Atkins
parent ca54cc6c79
commit 8d4084261a
Notes: github-actions[bot] 2026-02-23 11:22:22 +00:00
19 changed files with 294 additions and 111 deletions

View file

@ -1343,7 +1343,7 @@ TextTransform ComputedProperties::text_transform() const
return keyword_to_text_transform(value.to_keyword()).release_value();
}
ListStyleType ComputedProperties::list_style_type() const
ListStyleType ComputedProperties::list_style_type(HashMap<FlyString, CounterStyle> const& registered_counter_styles) const
{
auto const& value = property(PropertyID::ListStyleType);
@ -1353,11 +1353,7 @@ ListStyleType ComputedProperties::list_style_type() const
if (value.is_string())
return value.as_string().string_value().to_string();
if (auto keyword = value.as_counter_style().to_counter_style_name_keyword(); keyword.has_value())
return keyword.release_value();
// FIXME: Support user defined counter styles.
return Empty {};
return value.as_counter_style().resolve_counter_style(registered_counter_styles);
}
ListStylePosition ComputedProperties::list_style_position() const

View file

@ -134,7 +134,7 @@ public:
Vector<ShadowData> text_shadow(Layout::Node const&) const;
TextIndentData text_indent() const;
TextWrapMode text_wrap_mode() const;
ListStyleType list_style_type() const;
ListStyleType list_style_type(HashMap<FlyString, CounterStyle> const&) const;
ListStylePosition list_style_position() const;
FlexDirection flex_direction() const;
FlexWrap flex_wrap() const;

View file

@ -15,6 +15,7 @@
#include <LibWeb/CSS/CalculatedOr.h>
#include <LibWeb/CSS/Clip.h>
#include <LibWeb/CSS/ColumnCount.h>
#include <LibWeb/CSS/CounterStyle.h>
#include <LibWeb/CSS/CountersSet.h>
#include <LibWeb/CSS/Display.h>
#include <LibWeb/CSS/Enums.h>
@ -144,7 +145,7 @@ private:
using CursorData = Variant<NonnullRefPtr<CursorStyleValue const>, CursorPredefined>;
using ListStyleType = Variant<Empty, CounterStyleNameKeyword, String>;
using ListStyleType = Variant<Empty, Optional<CounterStyle const&>, String>;
class InitialValues {
public:
@ -185,7 +186,7 @@ public:
static Filter filter() { return Filter::make_none(); }
static Color background_color() { return Color::Transparent; }
static BackgroundBox background_color_clip() { return BackgroundBox::BorderBox; }
static ListStyleType list_style_type() { return CounterStyleNameKeyword::Disc; }
static ListStyleType list_style_type() { return Optional<CounterStyle const&> { CounterStyle::disc() }; }
static ListStylePosition list_style_position() { return ListStylePosition::Outside; }
static Visibility visibility() { return Visibility::Visible; }
static FlexDirection flex_direction() { return FlexDirection::Row; }

View file

@ -24,6 +24,20 @@ CounterStyle CounterStyle::decimal()
CounterStylePad { .minimum_length = 0, .symbol = ""_fly_string });
}
// https://drafts.csswg.org/css-counter-styles-3/#disc
CounterStyle CounterStyle::disc()
{
return CounterStyle::create(
"disc"_fly_string,
GenericCounterStyleAlgorithm { CounterStyleSystem::Cyclic, { ""_fly_string } },
CounterStyleNegativeSign { .prefix = ""_fly_string, .suffix = " "_fly_string },
""_fly_string,
" "_fly_string,
{ { NumericLimits<i64>::min(), NumericLimits<i64>::max() } },
"decimal"_fly_string,
CounterStylePad { .minimum_length = 0, .symbol = ""_fly_string });
}
CounterStyle CounterStyle::from_counter_style_definition(CounterStyleDefinition const& definition, HashMap<FlyString, CounterStyle> const& registered_counter_styles)
{
return definition.algorithm().visit(

View file

@ -16,6 +16,7 @@ namespace Web::CSS {
class CounterStyle {
public:
static CounterStyle decimal();
static CounterStyle disc();
static CounterStyle from_counter_style_definition(CounterStyleDefinition const&, HashMap<FlyString, CounterStyle> const&);
static CounterStyle create(FlyString name, CounterStyleAlgorithm algorithm, CounterStyleNegativeSign negative_sign, FlyString prefix, FlyString suffix, Vector<CounterStyleRangeEntry> range, Optional<FlyString> fallback, CounterStylePad pad)

View file

@ -5,6 +5,7 @@
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <LibWeb/DOM/Document.h>
#include <LibWeb/DOM/Element.h>
#include <LibWeb/Layout/ListItemMarkerBox.h>
#include <LibWeb/Painting/MarkerPaintable.h>
@ -23,51 +24,53 @@ ListItemMarkerBox::ListItemMarkerBox(DOM::Document& document, CSS::ListStyleType
ListItemMarkerBox::~ListItemMarkerBox() = default;
bool ListItemMarkerBox::counter_style_is_rendered_with_custom_image(Optional<CSS::CounterStyle const&> const& counter_style)
{
// https://drafts.csswg.org/css-counter-styles-3/#simple-symbolic
// When used in list-style-type, a UA may instead render these styles using a UA-generated image or a UA-chosen font
// instead of rendering the specified character in the elements own font. If using an image, it must look similar
// to the character, and must be sized to attractively fill a 1em by 1em square.
if (!counter_style.has_value())
return false;
auto const& counter_style_name = counter_style->name();
return first_is_one_of(counter_style_name, "square"_fly_string, "circle"_fly_string, "disc"_fly_string, "disclosure-closed"_fly_string, "disclosure-open"_fly_string);
}
Optional<String> ListItemMarkerBox::text() const
{
// https://drafts.csswg.org/css-lists-3/#text-markers
auto index = m_list_item_element->ordinal_value();
return m_list_style_type.visit(
[](Empty const&) -> Optional<String> {
// none
// The element has no marker string.
return {};
},
[index](CSS::CounterStyleNameKeyword keyword) -> Optional<String> {
String text;
switch (keyword) {
case CSS::CounterStyleNameKeyword::Square:
case CSS::CounterStyleNameKeyword::Circle:
case CSS::CounterStyleNameKeyword::Disc:
case CSS::CounterStyleNameKeyword::DisclosureClosed:
case CSS::CounterStyleNameKeyword::DisclosureOpen:
[&](Optional<CSS::CounterStyle const&> const& counter_style) -> Optional<String> {
// <counter-style>
// Specifies the elements marker string as the value of the list-item counter represented using the
// specified <counter-style>. Specifically, the marker string is the result of generating a counter
// representation of the list-item counter value using the specified <counter-style>, prefixed by the prefix
// of the <counter-style>, and followed by the suffix of the <counter-style>. If the specified
// <counter-style> does not exist, decimal is assumed.
if (counter_style_is_rendered_with_custom_image(counter_style))
return {};
case CSS::CounterStyleNameKeyword::Decimal:
text = String::number(index);
break;
case CSS::CounterStyleNameKeyword::DecimalLeadingZero:
// This is weird, but in accordance to spec.
text = index < 10 ? MUST(String::formatted("0{}", index)) : String::number(index);
break;
case CSS::CounterStyleNameKeyword::LowerAlpha:
case CSS::CounterStyleNameKeyword::LowerLatin:
text = String::bijective_base_from(index - 1, String::Case::Lower);
break;
case CSS::CounterStyleNameKeyword::UpperAlpha:
case CSS::CounterStyleNameKeyword::UpperLatin:
text = String::bijective_base_from(index - 1, String::Case::Upper);
break;
case CSS::CounterStyleNameKeyword::LowerGreek:
text = String::greek_letter_from(index);
break;
case CSS::CounterStyleNameKeyword::LowerRoman:
text = String::roman_number_from(index, String::Case::Lower);
break;
case CSS::CounterStyleNameKeyword::UpperRoman:
text = String::roman_number_from(index, String::Case::Upper);
break;
}
return MUST(String::formatted("{}. ", text));
// NB: Fallback to decimal if the counter style does not exist is handled within generate_a_counter_representation()
auto counter_representation = CSS::generate_a_counter_representation(counter_style, m_list_item_element->document().registered_counter_styles(), index);
if (!counter_style.has_value())
return MUST(String::formatted("{}. ", counter_representation));
return MUST(String::formatted("{}{}{}", counter_style->prefix(), counter_representation, counter_style->suffix()));
},
[](String const& string) -> Optional<String> {
// <string>
// The elements marker string is the specified <string>.
return string;
});
}
@ -93,14 +96,17 @@ CSSPixels ListItemMarkerBox::relative_size() const
static constexpr float marker_image_size_factor = 0.35f;
static constexpr float disclosure_marker_image_size_factor = 0.5f;
// Scale the marker box relative to the used font's pixel size.
switch (m_list_style_type.get<CSS::CounterStyleNameKeyword>()) {
case CSS::CounterStyleNameKeyword::DisclosureClosed:
case CSS::CounterStyleNameKeyword::DisclosureOpen:
return CSSPixels::nearest_value_for(ceilf(font_size * disclosure_marker_image_size_factor));
default:
auto counter_style = m_list_style_type.get<Optional<CSS::CounterStyle const&>>();
VERIFY(counter_style.has_value());
if (counter_style->name() == "square"_fly_string || counter_style->name() == "circle"_fly_string || counter_style->name() == "disc"_fly_string)
return CSSPixels::nearest_value_for(ceilf(font_size * marker_image_size_factor));
}
if (counter_style->name() == "disclosure-closed"_fly_string || counter_style->name() == "disclosure-open"_fly_string)
return CSSPixels::nearest_value_for(ceilf(font_size * disclosure_marker_image_size_factor));
VERIFY_NOT_REACHED();
}
void ListItemMarkerBox::visit_edges(Cell::Visitor& visitor)

View file

@ -17,6 +17,8 @@ class ListItemMarkerBox final : public Box {
GC_DECLARE_ALLOCATOR(ListItemMarkerBox);
public:
static bool counter_style_is_rendered_with_custom_image(Optional<CSS::CounterStyle const&> const& counter_style);
explicit ListItemMarkerBox(DOM::Document&, CSS::ListStyleType, CSS::ListStylePosition, GC::Ref<DOM::Element>, GC::Ref<CSS::ComputedProperties>);
virtual ~ListItemMarkerBox() override;

View file

@ -704,7 +704,7 @@ void NodeWithStyle::apply_style(CSS::ComputedProperties const& computed_style)
computed_values.set_text_decoration_style(computed_style.text_decoration_style());
computed_values.set_text_transform(computed_style.text_transform());
computed_values.set_list_style_type(computed_style.list_style_type());
computed_values.set_list_style_type(computed_style.list_style_type(m_dom_node->document().registered_counter_styles()));
computed_values.set_list_style_position(computed_style.list_style_position());
auto const& list_style_image = computed_style.property(CSS::PropertyID::ListStyleImage);
if (list_style_image.is_abstract_image()) {

View file

@ -5,6 +5,7 @@
*/
#include <LibGC/Heap.h>
#include <LibWeb/CSS/CounterStyle.h>
#include <LibWeb/Layout/ListItemMarkerBox.h>
#include <LibWeb/Painting/DisplayListRecorder.h>
#include <LibWeb/Painting/MarkerPaintable.h>
@ -49,10 +50,9 @@ void MarkerPaintable::paint(DisplayListRecordingContext& context, PaintPhase pha
return;
}
float left = device_rect.x().value();
float right = left + device_rect.width().value();
float top = device_rect.y().value();
float bottom = top + device_rect.height().value();
auto const& list_style_type = layout_box().list_style_type();
VERIFY(!list_style_type.has<Empty>());
auto color = computed_values().color();
@ -60,63 +60,67 @@ void MarkerPaintable::paint(DisplayListRecordingContext& context, PaintPhase pha
// FIXME: This should use proper text layout logic!
// This does not line up with the text in the <li> element which looks very sad :(
context.display_list_recorder().draw_text(device_rect.to_type<int>(), Utf16String::from_utf8(*text), layout_box().font(context), Gfx::TextAlignment::Center, color);
} else if (auto const* counter_style = layout_box().list_style_type().get_pointer<CSS::CounterStyleNameKeyword>()) {
switch (*counter_style) {
case CSS::CounterStyleNameKeyword::Square:
context.display_list_recorder().fill_rect(device_rect.to_type<int>(), color);
break;
case CSS::CounterStyleNameKeyword::Circle:
context.display_list_recorder().draw_ellipse(device_rect.to_type<int>(), color, 1);
break;
case CSS::CounterStyleNameKeyword::Disc:
context.display_list_recorder().fill_ellipse(device_rect.to_type<int>(), color);
break;
case CSS::CounterStyleNameKeyword::DisclosureClosed: {
// https://drafts.csswg.org/css-counter-styles-3/#disclosure-closed
// For the disclosure-open and disclosure-closed counter styles, the marker must be an image or character
// suitable for indicating the open and closed states of a disclosure widget, such as HTMLs details element.
// FIXME: If the image is directional, it must respond to the writing mode of the element, similar to the
// bidi-sensitive images feature of the Images 4 module.
// Draw an equilateral triangle pointing right.
auto path = Gfx::Path();
path.move_to({ left, top });
path.line_to({ left + sin_60_deg * (right - left), (top + bottom) / 2 });
path.line_to({ left, bottom });
path.close();
context.display_list_recorder().fill_path({ .path = path, .paint_style_or_color = color, .winding_rule = Gfx::WindingRule::EvenOdd });
break;
}
case CSS::CounterStyleNameKeyword::DisclosureOpen: {
// https://drafts.csswg.org/css-counter-styles-3/#disclosure-open
// For the disclosure-open and disclosure-closed counter styles, the marker must be an image or character
// suitable for indicating the open and closed states of a disclosure widget, such as HTMLs details element.
// FIXME: If the image is directional, it must respond to the writing mode of the element, similar to the
// bidi-sensitive images feature of the Images 4 module.
// Draw an equilateral triangle pointing down.
auto path = Gfx::Path();
path.move_to({ left, top });
path.line_to({ right, top });
path.line_to({ (left + right) / 2, top + sin_60_deg * (bottom - top) });
path.close();
context.display_list_recorder().fill_path({ .path = path, .paint_style_or_color = color, .winding_rule = Gfx::WindingRule::EvenOdd });
break;
}
case CSS::CounterStyleNameKeyword::Decimal:
case CSS::CounterStyleNameKeyword::DecimalLeadingZero:
case CSS::CounterStyleNameKeyword::LowerAlpha:
case CSS::CounterStyleNameKeyword::LowerGreek:
case CSS::CounterStyleNameKeyword::LowerLatin:
case CSS::CounterStyleNameKeyword::LowerRoman:
case CSS::CounterStyleNameKeyword::UpperAlpha:
case CSS::CounterStyleNameKeyword::UpperLatin:
case CSS::CounterStyleNameKeyword::UpperRoman:
// These are handled by text() already.
default:
VERIFY_NOT_REACHED();
}
return;
}
auto const& counter_style = list_style_type.get<Optional<CSS::CounterStyle const&>>();
VERIFY(Layout::ListItemMarkerBox::counter_style_is_rendered_with_custom_image(counter_style));
if (counter_style->name() == "square"_fly_string) {
context.display_list_recorder().fill_rect(device_rect.to_type<int>(), color);
return;
}
if (counter_style->name() == "circle"_fly_string) {
context.display_list_recorder().draw_ellipse(device_rect.to_type<int>(), color, 1);
return;
}
if (counter_style->name() == "disc"_fly_string) {
context.display_list_recorder().fill_ellipse(device_rect.to_type<int>(), color);
return;
}
float left = device_rect.x().value();
float right = left + device_rect.width().value();
float top = device_rect.y().value();
float bottom = top + device_rect.height().value();
if (counter_style->name() == "disclosure-closed"_fly_string) {
// https://drafts.csswg.org/css-counter-styles-3/#disclosure-closed
// For the disclosure-open and disclosure-closed counter styles, the marker must be an image or character
// suitable for indicating the open and closed states of a disclosure widget, such as HTMLs details element.
// FIXME: If the image is directional, it must respond to the writing mode of the element, similar to the
// bidi-sensitive images feature of the Images 4 module.
// Draw an equilateral triangle pointing right.
auto path = Gfx::Path();
path.move_to({ left, top });
path.line_to({ left + sin_60_deg * (right - left), (top + bottom) / 2 });
path.line_to({ left, bottom });
path.close();
context.display_list_recorder().fill_path({ .path = path, .paint_style_or_color = color, .winding_rule = Gfx::WindingRule::EvenOdd });
return;
}
if (counter_style->name() == "disclosure-open"_fly_string) {
// https://drafts.csswg.org/css-counter-styles-3/#disclosure-open
// For the disclosure-open and disclosure-closed counter styles, the marker must be an image or character
// suitable for indicating the open and closed states of a disclosure widget, such as HTMLs details element.
// FIXME: If the image is directional, it must respond to the writing mode of the element, similar to the
// bidi-sensitive images feature of the Images 4 module.
// Draw an equilateral triangle pointing down.
auto path = Gfx::Path();
path.move_to({ left, top });
path.line_to({ right, top });
path.line_to({ (left + right) / 2, top + sin_60_deg * (bottom - top) });
path.close();
context.display_list_recorder().fill_path({ .path = path, .paint_style_or_color = color, .winding_rule = Gfx::WindingRule::EvenOdd });
return;
}
VERIFY_NOT_REACHED();
}
}

View file

@ -0,0 +1,3 @@
<!DOCTYPE html>
<meta charset="UTF-8">
<ol><li>Should have "1." as bullet point.</ol>

View file

@ -0,0 +1,8 @@
<!DOCTYPE html>
<meta charset="UTF-8">
<title>CSS Reference: symbols function, invalid</title>
<link rel="author" title="Xidorn Quan" href="https://www.upsuper.org/">
<link rel="stylesheet" href="../../../../../input/wpt-import/css/css-counter-styles/counter-style-at-rule/support/test-common.css">
<ol start="-2" style="list-style-type: decimal">
<li>foo<li>bar<li>foo<li>bar
</ol>

View file

@ -0,0 +1,11 @@
<!DOCTYPE html>
<meta charset="UTF-8">
<title>CSS Reference: symbols function, invalid</title>
<link rel="author" title="Xidorn Quan" href="https://www.upsuper.org/">
<link rel="stylesheet" href="../../../../../input/wpt-import/css/css-counter-styles/counter-style-at-rule/support/test-common.css">
<ol start="-2" style="list-style-type: decimal">
<li>foo<li>bar<li>foo<li>bar
</ol>
<ol start="-2" style="list-style-type: decimal">
<li>foo<li>bar<li>foo<li>bar
</ol>

View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<meta charset="UTF-8">
<title>CSS Test: invalid counter-style symbols</title>
<link rel="author" title="Anthony Ramine" href="mailto:n.oxyde@gmail.com">
<link rel="help" href="https://drafts.csswg.org/css-counter-styles-3/#typedef-symbol">
<link rel="match" href="../../../../../expected/wpt-import/css/css-counter-styles/counter-style-at-rule/broken-symbols-ref.html">
<style type="text/css">
@counter-style a {
system: alphabetic;
symbols: ⓐ inherit;
}
</style>
<ol style="list-style-type: a"><li>Should have "1." as bullet point.</ol>

View file

@ -0,0 +1,21 @@
body {
/* to match ua.css, see bug 1020143 */
font-variant-numeric: tabular-nums;
}
ol, ul, section, p {
padding: 0; margin: 0;
line-height: 150%;
}
ol, ul {
list-style-position: inside;
}
li, p {
padding: 0;
}
p {
padding-right: .5em;
}
li::marker {
/* Blink workaround of https://bugzilla.mozilla.org/show_bug.cgi?id=1688769 */
white-space: pre;
}

View file

@ -0,0 +1,16 @@
<!DOCTYPE html>
<meta charset="UTF-8">
<title>CSS Test: system additive, invalid</title>
<link rel="author" title="Xidorn Quan" href="https://www.upsuper.org/">
<link rel="help" href="https://drafts.csswg.org/css-counter-styles-3/#additive-system">
<link rel="match" href="../../../../../expected/wpt-import/css/css-counter-styles/counter-style-at-rule/system-common-invalid-ref.html">
<link rel="stylesheet" href="support/test-common.css">
<style type="text/css">
@counter-style a {
system: additive;
suffix: ":";
}
</style>
<ol start="-2" style="list-style-type: a">
<li>foo<li>bar<li>foo<li>bar
</ol>

View file

@ -0,0 +1,24 @@
<!DOCTYPE html>
<meta charset="UTF-8">
<title>CSS Test: system alphabetic, invalid</title>
<link rel="author" title="Xidorn Quan" href="https://www.upsuper.org/">
<link rel="help" href="https://drafts.csswg.org/css-counter-styles-3/#alphabetic-system">
<link rel="match" href="../../../../../expected/wpt-import/css/css-counter-styles/counter-style-at-rule/system-common-invalid2-ref.html">
<link rel="stylesheet" href="support/test-common.css">
<style type="text/css">
@counter-style a {
system: alphabetic;
suffix: ":";
}
@counter-style b {
system: alphabetic;
symbols: A;
suffix: ":";
}
</style>
<ol start="-2" style="list-style-type: a">
<li>foo<li>bar<li>foo<li>bar
</ol>
<ol start="-2" style="list-style-type: b">
<li>foo<li>bar<li>foo<li>bar
</ol>

View file

@ -0,0 +1,23 @@
<!DOCTYPE html>
<meta charset="UTF-8">
<title>CSS Test: system fixed, invalid</title>
<link rel="author" title="Xidorn Quan" href="https://www.upsuper.org/">
<link rel="help" href="https://drafts.csswg.org/css-counter-styles-3/#fixed-system">
<link rel="match" href="../../../../../expected/wpt-import/css/css-counter-styles/counter-style-at-rule/system-common-invalid2-ref.html">
<link rel="stylesheet" href="support/test-common.css">
<style type="text/css">
@counter-style a {
system: fixed;
suffix: ":";
}
@counter-style b {
system: fixed invalid;
suffix: ":";
}
</style>
<ol start="-2" style="list-style-type: a">
<li>foo<li>bar<li>foo<li>bar
</ol>
<ol start="-2" style="list-style-type: b">
<li>foo<li>bar<li>foo<li>bar
</ol>

View file

@ -0,0 +1,24 @@
<!DOCTYPE html>
<meta charset="UTF-8">
<title>CSS Test: system numeric, invalid</title>
<link rel="author" title="Xidorn Quan" href="https://www.upsuper.org/">
<link rel="help" href="https://drafts.csswg.org/css-counter-styles-3/#numeric-system">
<link rel="match" href="../../../../../expected/wpt-import/css/css-counter-styles/counter-style-at-rule/system-common-invalid2-ref.html">
<link rel="stylesheet" href="support/test-common.css">
<style type="text/css">
@counter-style a {
system: numeric;
suffix: ":";
}
@counter-style b {
system: numeric;
symbols: A;
suffix: ":";
}
</style>
<ol start="-2" style="list-style-type: a">
<li>foo<li>bar<li>foo<li>bar
</ol>
<ol start="-2" style="list-style-type: b">
<li>foo<li>bar<li>foo<li>bar
</ol>

View file

@ -0,0 +1,16 @@
<!DOCTYPE html>
<meta charset="UTF-8">
<title>CSS Test: system symbolic, invalid</title>
<link rel="author" title="Xidorn Quan" href="https://www.upsuper.org/">
<link rel="help" href="https://drafts.csswg.org/css-counter-styles-3/#symbolic-system">
<link rel="match" href="../../../../../expected/wpt-import/css/css-counter-styles/counter-style-at-rule/system-common-invalid-ref.html">
<link rel="stylesheet" href="support/test-common.css">
<style type="text/css">
@counter-style a {
system: symbolic;
suffix: ":";
}
</style>
<ol start="-2" style="list-style-type: a">
<li>foo<li>bar<li>foo<li>bar
</ol>