From 340ef361d8f23c68bb5124b276eafdade675f647 Mon Sep 17 00:00:00 2001 From: Tim Ledbetter Date: Sun, 14 Jun 2026 06:42:25 +0100 Subject: [PATCH] LibWeb: Support `anchor()` in `calc()` trees Previously, `anchor()` was only resolved when it appeared bare in an inset property. We now allow it to appear anywhere inside a `calc()` tree. --- .../LibWeb/CSS/CalculationResolutionContext.h | 11 ++ Libraries/LibWeb/CSS/Parser/ValueParsing.cpp | 4 + .../CSS/StyleValues/AnchorStyleValue.cpp | 38 ++++- .../LibWeb/CSS/StyleValues/AnchorStyleValue.h | 7 +- Libraries/LibWeb/Layout/FormattingContext.cpp | 156 ++++++++++++++---- .../anchor-in-css-min-max-function-ref.html | 26 +++ .../anchor-in-css-min-max-function.html | 63 +++++++ .../anchor-function-pseudo-element-basic.txt | 7 + .../anchor-parse-valid.txt | 13 +- .../anchor-function-pseudo-element-basic.html | 55 ++++++ 10 files changed, 338 insertions(+), 42 deletions(-) create mode 100644 Tests/LibWeb/Ref/expected/wpt-import/css/css-anchor-position/reference/anchor-in-css-min-max-function-ref.html create mode 100644 Tests/LibWeb/Ref/input/wpt-import/css/css-anchor-position/anchor-in-css-min-max-function.html create mode 100644 Tests/LibWeb/Text/expected/wpt-import/css/css-anchor-position/anchor-function-pseudo-element-basic.txt create mode 100644 Tests/LibWeb/Text/input/wpt-import/css/css-anchor-position/anchor-function-pseudo-element-basic.html diff --git a/Libraries/LibWeb/CSS/CalculationResolutionContext.h b/Libraries/LibWeb/CSS/CalculationResolutionContext.h index 1da5768d4e..73f116629f 100644 --- a/Libraries/LibWeb/CSS/CalculationResolutionContext.h +++ b/Libraries/LibWeb/CSS/CalculationResolutionContext.h @@ -6,14 +6,23 @@ #pragma once +#include #include #include #include #include #include +#include +#include namespace Web::CSS { +class AnchorResolver { +public: + virtual ~AnchorResolver() = default; + virtual Optional resolve(AnchorStyleValue const&) const = 0; +}; + struct CalculationResolutionContext { using PercentageBasis = Variant; @@ -21,6 +30,8 @@ struct CalculationResolutionContext { Optional length_resolution_context {}; Optional abstract_element {}; + AnchorResolver const* anchor_resolver { nullptr }; + static CalculationResolutionContext from_computation_context(ComputationContext const& computation_context, PercentageBasis percentage_basis = {}) { return { diff --git a/Libraries/LibWeb/CSS/Parser/ValueParsing.cpp b/Libraries/LibWeb/CSS/Parser/ValueParsing.cpp index 6f078157ca..bb77d93a25 100644 --- a/Libraries/LibWeb/CSS/Parser/ValueParsing.cpp +++ b/Libraries/LibWeb/CSS/Parser/ValueParsing.cpp @@ -5148,6 +5148,10 @@ RefPtr Parser::convert_to_calculation_node(CalcParsing::N if (auto tree_counting_function = parse_tree_counting_function(tree_counting_function_tokens, TreeCountingFunctionStyleValue::ComputedType::Number)) return NonMathFunctionCalculationNode::create(tree_counting_function.release_nonnull(), NumericType {}); + auto anchor_function_tokens = TokenStream::of_single_token(component_value); + if (auto anchor_function = parse_anchor(anchor_function_tokens)) + return NonMathFunctionCalculationNode::create(anchor_function->as_anchor(), NumericType { NumericType::BaseType::Length, 1 }); + // NOTE: If we get here, then we have a ComponentValue that didn't get replaced with something else, // so the calc() is invalid. ErrorReporter::the().report(InvalidValueError { diff --git a/Libraries/LibWeb/CSS/StyleValues/AnchorStyleValue.cpp b/Libraries/LibWeb/CSS/StyleValues/AnchorStyleValue.cpp index 2330c4c64e..d088d3cd2e 100644 --- a/Libraries/LibWeb/CSS/StyleValues/AnchorStyleValue.cpp +++ b/Libraries/LibWeb/CSS/StyleValues/AnchorStyleValue.cpp @@ -4,7 +4,9 @@ * SPDX-License-Identifier: BSD-2-Clause */ +#include #include +#include namespace Web::CSS { @@ -19,7 +21,7 @@ ValueComparingNonnullRefPtr AnchorStyleValue::create( AnchorStyleValue::AnchorStyleValue(Optional const& anchor_name, ValueComparingNonnullRefPtr const& anchor_side, ValueComparingRefPtr const& fallback_value) - : StyleValueWithDefaultOperators(Type::Anchor) + : AbstractNonMathCalcFunctionStyleValue(Type::Anchor) , m_properties { .anchor_name = anchor_name, .anchor_side = anchor_side, .fallback_value = fallback_value } { } @@ -43,4 +45,38 @@ void AnchorStyleValue::serialize(StringBuilder& builder, SerializationMode seria builder.append(')'); } +// https://drafts.csswg.org/css-anchor-position-1/#anchor-pos +RefPtr AnchorStyleValue::resolve_to_calculation_node(CalculationContext const& calculation_context, CalculationResolutionContext const& calculation_resolution_context) const +{ + if (!calculation_resolution_context.anchor_resolver) + return nullptr; + + // An anchor() function representing a resolvable anchor function resolves at computed value time (using style & + // layout interleaving) to the that would align the edge of the positioned boxes' inset-modified containing + // block corresponding to the property the function appears in with the specified edge of the target anchor + // element’s anchor box. + if (auto side_px = calculation_resolution_context.anchor_resolver->resolve(*this); side_px.has_value()) + return NumericCalculationNode::create(Length::make_px(side_px.release_value()), calculation_context); + + // If any of these conditions are false, the anchor() function computes to its specified fallback value. If no + // fallback value is specified, it makes the declaration referencing it invalid at computed-value time. + auto const& fallback_value = m_properties.fallback_value; + if (!fallback_value) + return nullptr; + + // NB: The fallback value can itself be an anchor(), which is resolved when the substituted tree is simplified. + NonnullRefPtr fallback_node = fallback_value->is_anchor() + ? static_cast>(NonMathFunctionCalculationNode::create(fallback_value->as_anchor(), NumericType { NumericType::BaseType::Length, 1 })) + : CalculationNode::from_style_value(*fallback_value, calculation_context); + return simplify_a_calculation_tree(fallback_node, calculation_context, calculation_resolution_context); +} + +bool AnchorStyleValue::equals(StyleValue const& other) const +{ + if (type() != other.type()) + return false; + + return m_properties == other.as_anchor().m_properties; +} + } diff --git a/Libraries/LibWeb/CSS/StyleValues/AnchorStyleValue.h b/Libraries/LibWeb/CSS/StyleValues/AnchorStyleValue.h index 3481ed0bba..c90846928b 100644 --- a/Libraries/LibWeb/CSS/StyleValues/AnchorStyleValue.h +++ b/Libraries/LibWeb/CSS/StyleValues/AnchorStyleValue.h @@ -8,12 +8,12 @@ #include #include -#include +#include namespace Web::CSS { // https://drafts.csswg.org/css-anchor-position-1/#funcdef-anchor -class AnchorStyleValue final : public StyleValueWithDefaultOperators { +class AnchorStyleValue final : public AbstractNonMathCalcFunctionStyleValue { public: static ValueComparingNonnullRefPtr create(Optional const& anchor_name, ValueComparingNonnullRefPtr const& anchor_side, @@ -21,8 +21,9 @@ public: virtual ~AnchorStyleValue() override = default; virtual void serialize(StringBuilder&, SerializationMode) const override; + virtual RefPtr resolve_to_calculation_node(CalculationContext const&, CalculationResolutionContext const&) const override; - bool properties_equal(AnchorStyleValue const& other) const { return m_properties == other.m_properties; } + virtual bool equals(StyleValue const& other) const override; virtual bool is_computationally_independent() const override { return true; } diff --git a/Libraries/LibWeb/Layout/FormattingContext.cpp b/Libraries/LibWeb/Layout/FormattingContext.cpp index ba0fdb0320..c2fa672d67 100644 --- a/Libraries/LibWeb/Layout/FormattingContext.cpp +++ b/Libraries/LibWeb/Layout/FormattingContext.cpp @@ -5,7 +5,9 @@ */ #include +#include #include +#include #include #include #include @@ -1549,6 +1551,58 @@ AbsposContainingBlockInfo FormattingContext::resolve_abspos_containing_block_inf return { rect, horizontal_axis_mode, vertical_axis_mode, {}, {} }; } +static bool calculation_tree_contains_anchor(CSS::CalculationNode const& root) +{ + if (root.type() == CSS::CalculationNode::Type::NonMathFunction && as(root).function()->is_anchor()) + return true; + + for (auto const& child : root.children()) { + if (calculation_tree_contains_anchor(child)) + return true; + } + return false; +} + +namespace { + +template +class AnchorInsetResolver final : public CSS::AnchorResolver { +public: + AnchorInsetResolver(ResolveAnchorSide const& resolve_anchor_side, bool box_is_absolutely_positioned, bool is_from_end, bool is_horizontal_axis, CSSPixels containing_block_extent) + : m_resolve_anchor_side(resolve_anchor_side) + , m_box_is_absolutely_positioned(box_is_absolutely_positioned) + , m_is_from_end(is_from_end) + , m_is_horizontal_axis(is_horizontal_axis) + , m_containing_block_extent(containing_block_extent) + { + } + + virtual Optional resolve(CSS::AnchorStyleValue const& anchor) const override + { + if (!m_box_is_absolutely_positioned) + return {}; + + auto side_px = m_resolve_anchor_side(anchor, m_is_from_end, m_is_horizontal_axis); + if (!side_px.has_value()) + return {}; + + // For inset properties measuring from the end edge (right, bottom), the resolved length is the distance from + // the anchor side to the corresponding edge of the containing block's padding box. + if (m_is_from_end) + return m_containing_block_extent - side_px.value(); + return side_px.value(); + } + +private: + ResolveAnchorSide const& m_resolve_anchor_side; + bool m_box_is_absolutely_positioned { false }; + bool m_is_from_end { false }; + bool m_is_horizontal_axis { false }; + CSSPixels m_containing_block_extent { 0 }; +}; + +} + // https://drafts.csswg.org/css-anchor-position-1/#anchor-pos void FormattingContext::resolve_anchor_insets(Box& box) const { @@ -1563,18 +1617,40 @@ void FormattingContext::resolve_anchor_insets(Box& box) const // and we only resolve anchor() values in inset properties. // FIXME: Support anchor-scope, position-try-fallbacks, anchor-size(), and other anchor positioning features. - auto const* element = as_if(box.dom_node()); + // NB: Generated boxes for pseudo-elements are anonymous, so their anchor insets live in the generator element's + // computed properties for the relevant pseudo-element rather than on a DOM node of their own. + DOM::Element const* element = nullptr; + Optional pseudo_element; + if (box.is_generated_for_pseudo_element()) { + element = box.pseudo_element_generator(); + pseudo_element = box.generated_for_pseudo_element(); + } else { + element = as_if(box.dom_node()); + } if (!element) return; - auto computed = element->computed_properties(); + auto computed = element->computed_properties(pseudo_element); if (!computed) return; auto const& top = computed->property(CSS::PropertyID::Top); auto const& right = computed->property(CSS::PropertyID::Right); auto const& bottom = computed->property(CSS::PropertyID::Bottom); auto const& left = computed->property(CSS::PropertyID::Left); - if (!top.is_anchor() && !right.is_anchor() && !bottom.is_anchor() && !left.is_anchor()) + + auto style_value_contains_anchor = [](CSS::StyleValue const& value) { + if (value.is_anchor()) + return true; + if (value.is_calculated()) + return calculation_tree_contains_anchor(value.as_calculated().calculation()); + return false; + }; + + bool top_contains_anchor = style_value_contains_anchor(top); + bool right_contains_anchor = style_value_contains_anchor(right); + bool bottom_contains_anchor = style_value_contains_anchor(bottom); + bool left_contains_anchor = style_value_contains_anchor(left); + if (!top_contains_anchor && !right_contains_anchor && !bottom_contains_anchor && !left_contains_anchor) return; auto containing_block = box.containing_block(); @@ -1711,42 +1787,45 @@ void FormattingContext::resolve_anchor_insets(Box& box) const return {}; }; - auto resolve_anchor_for_inset = [&](CSS::AnchorStyleValue const& anchor, bool is_from_end, bool is_horizontal_axis) - -> CSS::LengthPercentageOrAuto { - auto maybe_side_px = resolve_anchor_side(anchor, is_from_end, is_horizontal_axis); + auto resolve_inset = [&](bool contains_anchor, CSS::StyleValue const& value, CSS::LengthPercentageOrAuto const& existing_value, CSS::PropertyID property_id, bool is_from_end, bool is_horizontal_axis) -> CSS::LengthPercentageOrAuto { + if (!contains_anchor) + return existing_value; - // If any of these conditions are false, the anchor() function computes to its specified fallback value. If no - // fallback value is specified, it makes the declaration referencing it invalid at computed-value time. - // NB: The fallback value can itself be an anchor(), so we walk the chain. - auto const* current = &anchor; - while (!maybe_side_px.has_value()) { - auto const& fallback = current->fallback_value(); - if (!fallback) + auto containing_block_extent = is_horizontal_axis + ? containing_block_state.padding_box_width() + : containing_block_state.padding_box_height(); + + AnchorInsetResolver anchor_resolver { resolve_anchor_side, box.is_absolutely_positioned(), is_from_end, is_horizontal_axis, containing_block_extent }; + + CSS::CalculationResolutionContext resolution_context { + .percentage_basis = CSS::Length::make_px(containing_block_extent), + .length_resolution_context = CSS::Length::ResolutionContext::for_layout_node(box), + .anchor_resolver = &anchor_resolver, + }; + + auto to_inset = [](Optional resolved_length) -> CSS::LengthPercentageOrAuto { + if (!resolved_length.has_value()) return CSS::LengthPercentageOrAuto::make_auto(); - if (!fallback->is_anchor()) - return CSS::LengthPercentageOrAuto::from_style_value(*fallback); - current = &fallback->as_anchor(); - maybe_side_px = resolve_anchor_side(*current, is_from_end, is_horizontal_axis); + return { CSS::LengthPercentage { resolved_length.release_value() } }; + }; + + // A bare anchor() inset is wrapped in a calculation so it resolves through the same path as calc(anchor()). + if (value.is_anchor()) { + auto calculation_context = CSS::CalculationContext::for_property(CSS::PropertyNameAndID::from_id(property_id)); + auto calculation_node = CSS::NonMathFunctionCalculationNode::create(value.as_anchor(), CSS::NumericType { CSS::NumericType::BaseType::Length, 1 }); + auto calculated_value = CSS::CalculatedStyleValue::create(calculation_node, CSS::NumericType { CSS::NumericType::BaseType::Length, 1 }, calculation_context); + return to_inset(calculated_value->resolve_length(resolution_context)); } - // For inset properties measuring from the end edge (right, bottom), the resolved length is the distance from - // the anchor side to the corresponding edge of the containing block's padding box. - auto side_px = maybe_side_px.release_value(); - if (is_from_end) { - auto containing_block_extent = is_horizontal_axis - ? containing_block_state.padding_box_width() - : containing_block_state.padding_box_height(); - return { CSS::LengthPercentage { CSS::Length::make_px(containing_block_extent - side_px) } }; - } - return { CSS::LengthPercentage { CSS::Length::make_px(side_px) } }; + return to_inset(value.as_calculated().resolve_length(resolution_context)); }; auto const& existing_inset = box.computed_values().inset(); box.mutable_computed_values().set_inset({ - top.is_anchor() ? resolve_anchor_for_inset(top.as_anchor(), false, false) : existing_inset.top(), - right.is_anchor() ? resolve_anchor_for_inset(right.as_anchor(), true, true) : existing_inset.right(), - bottom.is_anchor() ? resolve_anchor_for_inset(bottom.as_anchor(), true, false) : existing_inset.bottom(), - left.is_anchor() ? resolve_anchor_for_inset(left.as_anchor(), false, true) : existing_inset.left(), + resolve_inset(top_contains_anchor, top, existing_inset.top(), CSS::PropertyID::Top, false, false), + resolve_inset(right_contains_anchor, right, existing_inset.right(), CSS::PropertyID::Right, true, true), + resolve_inset(bottom_contains_anchor, bottom, existing_inset.bottom(), CSS::PropertyID::Bottom, true, false), + resolve_inset(left_contains_anchor, left, existing_inset.left(), CSS::PropertyID::Left, false, true), }); } @@ -1989,6 +2068,21 @@ void FormattingContext::compute_height_for_absolutely_positioned_replaced_elemen // https://www.w3.org/TR/css-position-3/#relpos-insets void FormattingContext::compute_inset(NodeWithStyleAndBoxModelMetrics const& box, CSSPixelSize containing_block_size) { + // anchor() functions are unresolvable in the insets of non-absolutely-positioned boxes. Substitute them with their + // fallback value (or auto) here so the resulting calc() does not reach length resolution unresolved and crash. This + // also covers sticky boxes, whose insets are read later from these computed values. + // NB: The box is logically mutable during layout (resolve_anchor_insets rewrites its computed insets), it is only + // passed as const& through the compute_inset() call chain. + if (auto const* anchored_box = as_if(box)) { + auto inset_contains_anchor = [](CSS::LengthPercentageOrAuto const& value) { + return value.is_calculated() && calculation_tree_contains_anchor(value.calculated()->calculation()); + }; + auto const& inset = anchored_box->computed_values().inset(); + if (inset_contains_anchor(inset.top()) || inset_contains_anchor(inset.right()) + || inset_contains_anchor(inset.bottom()) || inset_contains_anchor(inset.left())) + resolve_anchor_insets(const_cast(*anchored_box)); + } + if (box.computed_values().position() != CSS::Positioning::Relative) return; diff --git a/Tests/LibWeb/Ref/expected/wpt-import/css/css-anchor-position/reference/anchor-in-css-min-max-function-ref.html b/Tests/LibWeb/Ref/expected/wpt-import/css/css-anchor-position/reference/anchor-in-css-min-max-function-ref.html new file mode 100644 index 0000000000..781a8ae6a2 --- /dev/null +++ b/Tests/LibWeb/Ref/expected/wpt-import/css/css-anchor-position/reference/anchor-in-css-min-max-function-ref.html @@ -0,0 +1,26 @@ + + + + +

The test passes if there are three green boxes with no red.

+ +
+
+
+
+
\ No newline at end of file diff --git a/Tests/LibWeb/Ref/input/wpt-import/css/css-anchor-position/anchor-in-css-min-max-function.html b/Tests/LibWeb/Ref/input/wpt-import/css/css-anchor-position/anchor-in-css-min-max-function.html new file mode 100644 index 0000000000..04c11f0a0d --- /dev/null +++ b/Tests/LibWeb/Ref/input/wpt-import/css/css-anchor-position/anchor-in-css-min-max-function.html @@ -0,0 +1,63 @@ + + + + + + + +

The test passes if there are three green boxes with no red.

+ +
+ +
+
+
+
+ + +
\ No newline at end of file diff --git a/Tests/LibWeb/Text/expected/wpt-import/css/css-anchor-position/anchor-function-pseudo-element-basic.txt b/Tests/LibWeb/Text/expected/wpt-import/css/css-anchor-position/anchor-function-pseudo-element-basic.txt new file mode 100644 index 0000000000..3ec4f752ff --- /dev/null +++ b/Tests/LibWeb/Text/expected/wpt-import/css/css-anchor-position/anchor-function-pseudo-element-basic.txt @@ -0,0 +1,7 @@ +Harness status: OK + +Found 2 tests + +2 Pass +Pass Initial anchored position +Pass Anchored position after moving \ No newline at end of file diff --git a/Tests/LibWeb/Text/expected/wpt-import/css/css-anchor-position/anchor-parse-valid.txt b/Tests/LibWeb/Text/expected/wpt-import/css/css-anchor-position/anchor-parse-valid.txt index 6c08d38649..b11947bafe 100644 --- a/Tests/LibWeb/Text/expected/wpt-import/css/css-anchor-position/anchor-parse-valid.txt +++ b/Tests/LibWeb/Text/expected/wpt-import/css/css-anchor-position/anchor-parse-valid.txt @@ -2,8 +2,7 @@ Harness status: OK Found 2358 tests -2353 Pass -5 Fail +2358 Pass Pass e.style['left'] = "anchor(inside)" should set the property value Pass e.style['left'] = "anchor(inside, 1px)" should set the property value Pass e.style['left'] = "anchor(inside, 50%)" should set the property value @@ -2356,9 +2355,9 @@ Pass e.style['inset-inline-end'] = "anchor(--foo min(50%, 100%), anchor(--bar le Pass e.style['inset-inline-end'] = "anchor(min(50%, 100%) --foo, anchor(--bar left))" should set the property value Pass e.style['inset-inline-end'] = "anchor(--foo min(50%, 100%), anchor(--bar left, anchor(--baz right)))" should set the property value Pass e.style['inset-inline-end'] = "anchor(min(50%, 100%) --foo, anchor(--bar left, anchor(--baz right)))" should set the property value -Fail e.style['top'] = "calc((anchor(--foo top) + anchor(--bar bottom)) / 2)" should set the property value -Fail e.style['top'] = "calc(0.5 * (anchor(--foo top) + anchor(--bar bottom)))" should set the property value -Fail e.style['top'] = "anchor(--foo top, calc(0.5 * anchor(--bar bottom)))" should set the property value -Fail e.style['top'] = "min(100px, 10%, anchor(--foo top), anchor(--bar bottom))" should set the property value +Pass e.style['top'] = "calc((anchor(--foo top) + anchor(--bar bottom)) / 2)" should set the property value +Pass e.style['top'] = "calc(0.5 * (anchor(--foo top) + anchor(--bar bottom)))" should set the property value +Pass e.style['top'] = "anchor(--foo top, calc(0.5 * anchor(--bar bottom)))" should set the property value +Pass e.style['top'] = "min(100px, 10%, anchor(--foo top), anchor(--bar bottom))" should set the property value Pass e.style['top'] = "anchor(--foo left, 0)" should set the property value -Fail e.style['top'] = "calc(anchor(--foo left, 0))" should set the property value \ No newline at end of file +Pass e.style['top'] = "calc(anchor(--foo left, 0))" should set the property value \ No newline at end of file diff --git a/Tests/LibWeb/Text/input/wpt-import/css/css-anchor-position/anchor-function-pseudo-element-basic.html b/Tests/LibWeb/Text/input/wpt-import/css/css-anchor-position/anchor-function-pseudo-element-basic.html new file mode 100644 index 0000000000..bc86427e79 --- /dev/null +++ b/Tests/LibWeb/Text/input/wpt-import/css/css-anchor-position/anchor-function-pseudo-element-basic.html @@ -0,0 +1,55 @@ + +Positioning pseudo-elements using anchor functions + + + + +
+
+