");
+ var element = document.QuerySelector("span");
+ var computed = element.ComputeCurrentStyle();
+
+ Assert.AreEqual(expected, computed.GetPropertyValue(name));
+ Assert.AreEqual(first, computed.GetPropertyValue("--alias"));
+ Assert.AreEqual("var(--alias) var(--b)", element.GetStyle().GetPropertyValue(name));
+ }
+
+ [TestCase("translate", "1px")]
+ [TestCase("rotate", "45deg")]
+ [TestCase("scale", "2")]
+ public void MissingAndCyclicVariablesStillUseFallbacksOrInitialValues(String name, String fallback)
+ {
+ using var document = ParseDocument("");
+ var element = document.QuerySelector("div");
+ var computed = element.ComputeCurrentStyle();
+
+ Assert.AreEqual(fallback, computed.GetPropertyValue(name));
+ Assert.AreEqual("rgba(255, 0, 0, 1)", computed.GetPropertyValue("color"));
+ Assert.IsInstanceOf(computed.GetProperty("--a").RawValue);
+ Assert.IsInstanceOf(computed.GetProperty("--b").RawValue);
+
+ foreach (var value in new[] { "var(--a)", "var(--missing)", "var(--missing,none trailing)" })
+ {
+ element.GetStyle().SetProperty(name, value);
+ Assert.AreEqual("none", element.ComputeCurrentStyle().GetPropertyValue(name), value);
+ }
+ }
+
+ [TestCase("translate", "1px")]
+ [TestCase("rotate", "45deg")]
+ [TestCase("scale", "2")]
+ public void InheritanceAndSubstitutedCssWideKeywordsRetainTheirBehavior(String name, String parentValue)
+ {
+ using var document = ParseDocument("
");
+ var element = document.QuerySelector("span");
+ element.SetAttribute("style", name + ":inherit");
+ Assert.AreEqual(parentValue, element.ComputeCurrentStyle().GetPropertyValue(name));
+
+ foreach (var keyword in new[] { "inherit", "initial", "unset" })
+ {
+ element.SetAttribute("style", "--keyword:var(--missing," + keyword + ");" + name + ":var(--keyword)");
+ Assert.AreEqual(keyword == "inherit" ? parentValue : "none",
+ element.ComputeCurrentStyle().GetPropertyValue(name), keyword);
+ }
+ }
+
+ [Test]
+ public void InvalidConcreteValuesStillDefaultInsteadOfExposingEarlierDeclarations()
+ {
+ using var document = ParseDocument("
");
+ var computed = document.QuerySelector("span").ComputeCurrentStyle();
+
+ Assert.AreEqual("auto", computed.GetPropertyValue("width"));
+ Assert.AreEqual("hidden", computed.GetPropertyValue("visibility"));
+ }
+ }
+}
diff --git a/src/AngleSharp.Css.Tests/Values/AnyValueComputation.cs b/src/AngleSharp.Css.Tests/Values/AnyValueComputation.cs
new file mode 100644
index 0000000..d41b6ee
--- /dev/null
+++ b/src/AngleSharp.Css.Tests/Values/AnyValueComputation.cs
@@ -0,0 +1,127 @@
+#nullable disable
+namespace AngleSharp.Css.Tests.Values
+{
+ using AngleSharp.Css.Converters;
+ using AngleSharp.Css.Dom;
+ using AngleSharp.Css.Values;
+ using AngleSharp.Text;
+ using NUnit.Framework;
+ using System;
+ using static ValueConverters;
+
+ [TestFixture]
+ public class AnyValueComputationTests
+ {
+ [TestCase(false)]
+ [TestCase(true)]
+ public void AnyResultIsNotRecomputedThroughNestedConverters(Boolean isResolved)
+ {
+ var converter = new SingleUseConverter(Or(None, Or(Auto, Any)));
+ var context = new TestComputeContext { Converter = converter };
+ ICssValue value = new CssAnyValue("opaque tokens", isResolved);
+
+ Assert.AreEqual("opaque tokens", value.Compute(context).CssText);
+ Assert.AreEqual(1, converter.Calls);
+ }
+
+ [TestCase("none", "none")]
+ [TestCase("2em", "32px")]
+ [TestCase("calc(1px + 2px)", "3px")]
+ [TestCase(" /*before*/ 2em /*after*/ ", "32px")]
+ [TestCase("invalid", null)]
+ [TestCase("2px trailing", null)]
+ [TestCase("none trailing", null)]
+ public void ConcreteCompositeResultsStillComputeAndValidate(String text, String expected)
+ {
+ var context = new TestComputeContext { Converter = Or(None, LengthConverter) };
+
+ foreach (var isResolved in new[] { false, true })
+ {
+ ICssValue value = new CssAnyValue(text, isResolved);
+ Assert.AreEqual(expected, value.Compute(context)?.CssText);
+ }
+ }
+
+ [TestCase("none", "none")]
+ [TestCase("2em", "32px")]
+ [TestCase("calc(1px + 2px)", "3px")]
+ public void ConcreteBranchesBeforeAnyStillCompute(String text, String expected)
+ {
+ var context = new TestComputeContext { Converter = Or(None, LengthConverter, Any) };
+ ICssValue value = new CssAnyValue(text);
+
+ Assert.AreEqual(expected, value.Compute(context).CssText);
+ }
+
+ [TestCase(false)]
+ [TestCase(true)]
+ public void AnyResultStillRequiresCompleteInputConsumption(Boolean isResolved)
+ {
+ var context = new TestComputeContext
+ {
+ Converter = new ClassValueConverter(source =>
+ {
+ source.Next();
+ return new CssAnyValue("accepted", isResolved);
+ }),
+ };
+ ICssValue value = new CssAnyValue("xy", isResolved);
+
+ Assert.IsNull(value.Compute(context));
+ }
+
+ [TestCase(false, false)]
+ [TestCase(false, true)]
+ [TestCase(true, false)]
+ [TestCase(true, true)]
+ public void DirectAnyAndMissingConvertersRetainResolutionSemantics(Boolean isResolved, Boolean hasConverter)
+ {
+ var context = new TestComputeContext { Converter = hasConverter ? Any : null };
+ ICssValue value = new CssAnyValue("opaque tokens", isResolved);
+
+ Assert.AreSame(isResolved ? value : null, value.Compute(context));
+ }
+
+ [Test]
+ public void ConverterExceptionsAreNotSuppressed()
+ {
+ var context = new TestComputeContext
+ {
+ Converter = new ClassValueConverter(_ => throw new InvalidOperationException("Test exception")),
+ };
+ ICssValue value = new CssAnyValue("opaque tokens");
+
+ Assert.Throws(() => value.Compute(context));
+ }
+
+ private sealed class SingleUseConverter : IValueConverter
+ {
+ private readonly IValueConverter _converter;
+
+ public SingleUseConverter(IValueConverter converter)
+ {
+ _converter = converter;
+ }
+
+ public Int32 Calls { get; private set; }
+
+ public ICssValue Convert(StringSource source)
+ {
+ if (++Calls > 1)
+ {
+ throw new InvalidOperationException("The converter was re-entered for its own any result.");
+ }
+
+ return _converter.Convert(source);
+ }
+ }
+
+ private sealed class TestComputeContext : ICssComputeContext
+ {
+ public IRenderDevice Device { get; } = new DefaultRenderDevice { FontSize = 16 };
+ public IBrowsingContext Context => null;
+ public IValueConverter Converter { get; set; }
+ public ICssValue Resolve(String name) => null;
+ }
+ }
+}
diff --git a/src/AngleSharp.Css/Values/Raws/CssAnyValue.cs b/src/AngleSharp.Css/Values/Raws/CssAnyValue.cs
index 3341808..e6fd1de 100644
--- a/src/AngleSharp.Css/Values/Raws/CssAnyValue.cs
+++ b/src/AngleSharp.Css/Values/Raws/CssAnyValue.cs
@@ -61,7 +61,15 @@ ICssValue ICssValue.Compute(ICssComputeContext context)
source.SkipSpacesAndComments();
var value = converter.Convert(source);
source.SkipSpacesAndComments();
- return source.IsDone ? value?.Compute(context) : null;
+
+ if (!source.IsDone)
+ {
+ return null;
+ }
+
+ // A composite converter may accept opaque tokens through an Any
+ // arm. Recomputing that result would re-enter the same converter.
+ return value is CssAnyValue ? value : value?.Compute(context);
}
return IsResolved ? this : null;
From f48e8ba232b6d6c48fde165a8556301c9e3efdba Mon Sep 17 00:00:00 2001
From: Florian Rappl
Date: Mon, 7 Sep 2026 08:13:24 +0200
Subject: [PATCH 2/9] Updated version
---
CHANGELOG.md | 6 ++++++
src/AngleSharp.Css.Docs/package.json | 2 +-
src/Directory.Build.props | 2 +-
3 files changed, 8 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e009b69..6d98eb1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,9 @@
+# 1.1.1
+
+Released on Wednesday, September 9 2026
+
+- Fixed unresolved converter re-entry (#243) @sebastienros
+
# 1.1.0
Released on Saturday, September 5 2026
diff --git a/src/AngleSharp.Css.Docs/package.json b/src/AngleSharp.Css.Docs/package.json
index 9e40277..32de862 100644
--- a/src/AngleSharp.Css.Docs/package.json
+++ b/src/AngleSharp.Css.Docs/package.json
@@ -1,6 +1,6 @@
{
"name": "@anglesharp/css",
- "version": "1.1.0",
+ "version": "1.1.1",
"preview": true,
"description": "The doclet for the AngleSharp.Css documentation.",
"keywords": [
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index bace0b8..60b7046 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -2,7 +2,7 @@
Extends the CSSOM from the core AngleSharp library.AngleSharp.Css
- 1.1.0
+ 1.1.1enablelatesttrue
From 8c7ec8b90b8e2d3fa57eb140badbee46ecbad628 Mon Sep 17 00:00:00 2001
From: Florian Rappl
Date: Tue, 8 Sep 2026 21:30:30 +0200
Subject: [PATCH 3/9] Fixed transform function
---
CHANGELOG.md | 1 +
.../Values/TransformFunctions.cs | 111 ++++++++++++++++++
.../Values/Functions/CssMatrixValue.cs | 21 ++--
.../Values/Functions/CssRotateValue.cs | 12 +-
.../Values/Functions/CssTranslateValue.cs | 6 +-
5 files changed, 135 insertions(+), 16 deletions(-)
create mode 100644 src/AngleSharp.Css.Tests/Values/TransformFunctions.cs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6d98eb1..028de0c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,7 @@
Released on Wednesday, September 9 2026
- Fixed unresolved converter re-entry (#243) @sebastienros
+- Fixed issue with computation of `transform` functions
# 1.1.0
diff --git a/src/AngleSharp.Css.Tests/Values/TransformFunctions.cs b/src/AngleSharp.Css.Tests/Values/TransformFunctions.cs
new file mode 100644
index 0000000..21a81ae
--- /dev/null
+++ b/src/AngleSharp.Css.Tests/Values/TransformFunctions.cs
@@ -0,0 +1,111 @@
+#nullable disable
+namespace AngleSharp.Css.Tests.Values
+{
+ using AngleSharp.Css.Parser;
+ using AngleSharp.Css.Tests.Mocks;
+ using AngleSharp.Dom;
+ using AngleSharp.Text;
+ using NUnit.Framework;
+ using static CssConstructionFunctions;
+
+ ///
+ /// Regression tests for three confirmed bugs in the CSS `transform` function value pipeline
+ /// (`ICssTransformFunctionValue`/`TransformMatrix`), found while integrating this library's
+ /// transform support into a downstream renderer. All three currently fail (that is the point -
+ /// they pin down the exact defect for a fix); see each test's own comments for the root cause
+ /// traced in the corresponding source file.
+ ///
+ [TestFixture]
+ public class TransformFunctionsTests
+ {
+ [Test]
+ public void TranslateWithTwoArgumentsDoesNotThrowWhenComputed()
+ {
+ // Root cause: CssTranslateValue.Compute() (Values/Functions/CssTranslateValue.cs)
+ // unconditionally calls _x.Compute(context), _y.Compute(context), _z.Compute(context)
+ // with no null check on any of the three. For the 2-argument `translate(x, y)` form,
+ // the constructor is only ever given a non-null z when parsing translate3d - here _z is
+ // null, so _z.Compute(context) throws NullReferenceException. ComputeMatrix (a few
+ // lines below Compute in the same file) does not have this bug, because it reads each
+ // component through the null-tolerant AsPx(...) extension method instead of calling an
+ // interface method directly on a value that may be null.
+ //
+ // This is not a synthetic edge case: it reproduces via the most ordinary possible
+ // route - building a render tree for a document with a plain inline
+ // `style="transform: translate(10px, 5px)"` declaration. RenderTreeBuilder computes an
+ // element's entire style declaration eagerly while constructing the tree, so the crash
+ // happens even if nothing downstream ever reads the `transform` property specifically.
+ //
+ // Written as "does not throw" (the correct, expected behavior) rather than "throws
+ // NullReferenceException" (today's actual, buggy behavior) so this test fails now and
+ // starts passing automatically once the bug is fixed, instead of needing to be flipped.
+ var document = "".ToHtmlDocument(Configuration.Default.WithRenderDevice().WithCss());
+ var window = document.DefaultView;
+
+ Assert.DoesNotThrow(() => window.Render(new PlainRenderDevice()));
+ }
+
+ [Test]
+ public void TranslateXWithOneArgumentDoesNotThrowWhenComputed()
+ {
+ // Same root cause as TranslateWithTwoArgumentsDoesNotThrowWhenComputed, via
+ // translateX() instead of the 2-argument translate() - here both _y and _z are null,
+ // so Compute() throws on the first of the two (_y.Compute(context)).
+ var document = "".ToHtmlDocument(Configuration.Default.WithRenderDevice().WithCss());
+ var window = document.DefaultView;
+
+ Assert.DoesNotThrow(() => window.Render(new PlainRenderDevice()));
+ }
+
+ [Test]
+ public void RotateComputeMatrixReturnsNaNForThePlain2DForm()
+ {
+ // Root cause: CssRotateValue.ComputeMatrix() (Values/Functions/CssRotateValue.cs) has
+ // two distinct bugs that compound here:
+ //
+ // 1. A copy-paste typo - the `y` and `z` locals are both assigned `_x.AsDouble()`
+ // instead of `_y.AsDouble()`/`_z.AsDouble()` respectively. This alone would corrupt
+ // rotate3d(x, y, z, angle) results, but does not explain this test's NaN, since for
+ // plain 2D rotate() _x/_y/_z are all null anyway.
+ //
+ // 2. The real cause of the NaN: for the plain 2D `rotate(angle)` form, _x/_y/_z are all
+ // null (per this class's own Name property, which treats "all three null" as the
+ // signal for plain `rotate`, distinct from rotateX/Y/Z/rotate3d). Per the CSS
+ // Transforms spec, `rotate(angle)` is shorthand for `rotate3d(0, 0, 1, angle)` - the
+ // rotation axis defaults to the Z axis, not the zero vector. ComputeMatrix does not
+ // special-case this: it reads x/y/z as 0 (via AsDouble() on a null value) and
+ // proceeds to normalize (x, y, z) as if it were a real axis vector - normalizing a
+ // zero-length vector divides by Math.Sqrt(0) = 0, producing Infinity, and
+ // 0 * Infinity is NaN in IEEE 754, propagating through every matrix entry.
+ var source = new StringSource("rotate(45deg)");
+ var value = TransformParser.ParseTransform(source);
+ Assert.IsNotNull(value);
+
+ var matrix = value.ComputeMatrix(new PlainRenderDevice());
+
+ Assert.IsFalse(double.IsNaN(matrix.M11), "M11 should not be NaN");
+ Assert.IsFalse(double.IsNaN(matrix.M12), "M12 should not be NaN");
+ Assert.IsFalse(double.IsNaN(matrix.M21), "M21 should not be NaN");
+ Assert.IsFalse(double.IsNaN(matrix.M22), "M22 should not be NaN");
+ }
+
+ [Test]
+ public void PlainSixValueMatrixFunctionThrowsArgumentExceptionWhenComputed()
+ {
+ // Root cause: CssMatrixValue.ComputeMatrix() (Values/Functions/CssMatrixValue.cs) pads
+ // the ordinary 6-value 2D matrix(a, b, c, d, e, f) form up to a 4x4 matrix by appending
+ // 8 more values (values.Add(...) called 8 times) before constructing a TransformMatrix
+ // from the flat array - but TransformMatrix's array constructor requires exactly 16
+ // values (4x4) and 6 + 8 = 14, not 16, so it throws
+ // "You need to provide 16 (4x4) values." The correct 4x4 homogeneous embedding of a 2D
+ // matrix(a, b, c, d, e, f) is the row-major 16-value array
+ // [a, c, 0, e, b, d, 0, f, 0, 0, 1, 0, 0, 0, 0, 1] - i.e. 10 padding values are
+ // needed after the original 6, not 8.
+ var source = new StringSource("matrix(1, 0, 0, 1, 5, 9)");
+ var value = TransformParser.ParseTransform(source);
+ Assert.IsNotNull(value);
+
+ Assert.DoesNotThrow(() => value.ComputeMatrix(new PlainRenderDevice()));
+ }
+ }
+}
diff --git a/src/AngleSharp.Css/Values/Functions/CssMatrixValue.cs b/src/AngleSharp.Css/Values/Functions/CssMatrixValue.cs
index 0564624..1694811 100644
--- a/src/AngleSharp.Css/Values/Functions/CssMatrixValue.cs
+++ b/src/AngleSharp.Css/Values/Functions/CssMatrixValue.cs
@@ -96,21 +96,20 @@ public Boolean Equals(CssMatrixValue other)
/// The current transformation.
public TransformMatrix ComputeMatrix(IRenderDimensions dimensions)
{
- var values = _values.Select(v => v.AsDouble()).ToList();
+ var values = _values.Select(v => v.AsDouble()).ToArray();
- if (values.Count == 6)
+ if (values.Length == 6)
{
- values.Add(1.0);
- values.Add(0.0);
- values.Add(0.0);
- values.Add(0.0);
- values.Add(0.0);
- values.Add(0.0);
- values.Add(0.0);
- values.Add(1.0);
+ values = new[]
+ {
+ values[0], values[2], 0.0, values[4],
+ values[1], values[3], 0.0, values[5],
+ 0.0, 0.0, 1.0, 0.0,
+ 0.0, 0.0, 0.0, 1.0,
+ };
}
- return new TransformMatrix(values.ToArray());
+ return new TransformMatrix(values);
}
ICssValue ICssValue.Compute(ICssComputeContext context)
diff --git a/src/AngleSharp.Css/Values/Functions/CssRotateValue.cs b/src/AngleSharp.Css/Values/Functions/CssRotateValue.cs
index eea99e6..d7d16ad 100644
--- a/src/AngleSharp.Css/Values/Functions/CssRotateValue.cs
+++ b/src/AngleSharp.Css/Values/Functions/CssRotateValue.cs
@@ -149,8 +149,16 @@ public Boolean Equals(CssRotateValue other)
public TransformMatrix ComputeMatrix(IRenderDimensions renderDimensions)
{
var x = _x.AsDouble();
- var y = _x.AsDouble();
- var z = _x.AsDouble();
+ var y = _y.AsDouble();
+ var z = _z.AsDouble();
+
+ if (_x is null && _y is null && _z is null)
+ {
+ x = 0.0;
+ y = 0.0;
+ z = 1.0;
+ }
+
var norm = 1.0 / Math.Sqrt(x * x + y * y + z * z);
var alpha = _angle.AsRad();
var sina = Math.Sin(alpha);
diff --git a/src/AngleSharp.Css/Values/Functions/CssTranslateValue.cs b/src/AngleSharp.Css/Values/Functions/CssTranslateValue.cs
index d29fc86..56b74de 100644
--- a/src/AngleSharp.Css/Values/Functions/CssTranslateValue.cs
+++ b/src/AngleSharp.Css/Values/Functions/CssTranslateValue.cs
@@ -133,9 +133,9 @@ public TransformMatrix ComputeMatrix(IRenderDimensions renderDimensions)
ICssValue ICssValue.Compute(ICssComputeContext context)
{
- var x = _x.Compute(context);
- var y = _y.Compute(context);
- var z = _z.Compute(context);
+ var x = _x?.Compute(context);
+ var y = _y?.Compute(context);
+ var z = _z?.Compute(context);
return new CssTranslateValue(x, y, z);
}
From 534c18ec235e2e7b97710d56deb41767676ab9c2 Mon Sep 17 00:00:00 2001
From: Florian Rappl
Date: Wed, 9 Sep 2026 10:19:40 +0200
Subject: [PATCH 4/9] Improved matrix constructor order
---
.../Values/TransformFunctions.cs | 62 ++++++++++++++++---
.../Values/Functions/CssMatrixValue.cs | 6 +-
2 files changed, 55 insertions(+), 13 deletions(-)
diff --git a/src/AngleSharp.Css.Tests/Values/TransformFunctions.cs b/src/AngleSharp.Css.Tests/Values/TransformFunctions.cs
index 21a81ae..c65c5c8 100644
--- a/src/AngleSharp.Css.Tests/Values/TransformFunctions.cs
+++ b/src/AngleSharp.Css.Tests/Values/TransformFunctions.cs
@@ -90,22 +90,64 @@ public void RotateComputeMatrixReturnsNaNForThePlain2DForm()
}
[Test]
- public void PlainSixValueMatrixFunctionThrowsArgumentExceptionWhenComputed()
+ public void PlainSixValueMatrixFunctionDoesNotThrowWhenComputed()
{
- // Root cause: CssMatrixValue.ComputeMatrix() (Values/Functions/CssMatrixValue.cs) pads
- // the ordinary 6-value 2D matrix(a, b, c, d, e, f) form up to a 4x4 matrix by appending
- // 8 more values (values.Add(...) called 8 times) before constructing a TransformMatrix
- // from the flat array - but TransformMatrix's array constructor requires exactly 16
- // values (4x4) and 6 + 8 = 14, not 16, so it throws
- // "You need to provide 16 (4x4) values." The correct 4x4 homogeneous embedding of a 2D
- // matrix(a, b, c, d, e, f) is the row-major 16-value array
- // [a, c, 0, e, b, d, 0, f, 0, 0, 1, 0, 0, 0, 0, 1] - i.e. 10 padding values are
- // needed after the original 6, not 8.
+ // Original root cause (now fixed): CssMatrixValue.ComputeMatrix() used to pad the
+ // ordinary 6-value 2D matrix(a, b, c, d, e, f) form up to a 4x4 matrix by appending
+ // only 8 more values before constructing a TransformMatrix from the flat array - but
+ // TransformMatrix's array constructor requires exactly 16 values (4x4) and 6 + 8 = 14,
+ // not 16, so it threw "You need to provide 16 (4x4) values." See
+ // PlainSixValueMatrixFunctionPreservesAllSixComponents below for a second, distinct bug
+ // the fix for this one introduced.
var source = new StringSource("matrix(1, 0, 0, 1, 5, 9)");
var value = TransformParser.ParseTransform(source);
Assert.IsNotNull(value);
Assert.DoesNotThrow(() => value.ComputeMatrix(new PlainRenderDevice()));
}
+
+ [Test]
+ public void PlainSixValueMatrixFunctionPreservesAllSixComponents()
+ {
+ // Root cause: TransformMatrix's array constructor (Values/TransformMatrix.cs) is
+ // column-major - `for (i = 0..4) for (j = 0..4, k++) _matrix[j, i] = values[k];` with
+ // `i` as the outer/column index and `j` as the inner/row index, so array indices
+ // 0-3 fill *column* 0 (not row 0), indices 4-7 fill column 1, and so on; Tx/Ty/Tz live
+ // at indices 12/13/14 (the start of column 3), not scattered through the middle of the
+ // array.
+ //
+ // CssMatrixValue.ComputeMatrix()'s current 6-to-16 padding
+ // (Values/Functions/CssMatrixValue.cs) builds
+ // [a, c, 0, e, b, d, 0, f, 0, 0, 1, 0, 0, 0, 0, 1] - grouping by CSS *row*
+ // (matching how a reader would naturally transcribe matrix(a,b,c,d,e,f) into the 2D
+ // homogeneous matrix [[a,c,0,e],[b,d,0,f],[0,0,1,0],[0,0,0,1]]), which would be correct
+ // for a *row-major* array constructor but is wrong for this one, which is column-major.
+ // Reading that array back through the constructor's actual column-major loop lands
+ // `e` and `f` in column 0/1's *last* row (an unused perspective cell, values[3] and
+ // values[7]) rather than in column 3 (Tx/Ty) - so the translation is silently dropped
+ // to 0 - and it also transposes b/c into the wrong of M12/M21.
+ //
+ // Confirmed with distinguishable, non-symmetric coefficients (a=2, b=3, c=4, d=5, e=6,
+ // f=7) specifically so a coincidental symmetric identity (e.g. a=d=1, b=c=0) cannot mask
+ // the corruption - which is exactly what happened with the identical-looking
+ // PlainSixValueMatrixFunctionDoesNotThrowWhenComputed test above: for matrix(1,0,0,1,5,9)
+ // this bug happens to zero everything down to the identity matrix, silently discarding
+ // the translation without a symptom that "does not throw" alone would ever catch.
+ //
+ // The correct column-major 16-value embedding of matrix(a, b, c, d, e, f) is
+ // [a, b, 0, 0, c, d, 0, 0, 0, 0, 1, 0, e, f, 0, 1].
+ var source = new StringSource("matrix(2, 3, 4, 5, 6, 7)");
+ var value = TransformParser.ParseTransform(source);
+ Assert.IsNotNull(value);
+
+ var matrix = value.ComputeMatrix(new PlainRenderDevice());
+
+ Assert.AreEqual(2.0, matrix.M11, "M11 should be a (2)");
+ Assert.AreEqual(4.0, matrix.M12, "M12 should be c (4)");
+ Assert.AreEqual(3.0, matrix.M21, "M21 should be b (3)");
+ Assert.AreEqual(5.0, matrix.M22, "M22 should be d (5)");
+ Assert.AreEqual(6.0, matrix.Tx, "Tx should be e (6)");
+ Assert.AreEqual(7.0, matrix.Ty, "Ty should be f (7)");
+ }
}
}
diff --git a/src/AngleSharp.Css/Values/Functions/CssMatrixValue.cs b/src/AngleSharp.Css/Values/Functions/CssMatrixValue.cs
index 1694811..767cf4c 100644
--- a/src/AngleSharp.Css/Values/Functions/CssMatrixValue.cs
+++ b/src/AngleSharp.Css/Values/Functions/CssMatrixValue.cs
@@ -102,10 +102,10 @@ public TransformMatrix ComputeMatrix(IRenderDimensions dimensions)
{
values = new[]
{
- values[0], values[2], 0.0, values[4],
- values[1], values[3], 0.0, values[5],
+ values[0], values[1], 0.0, 0.0,
+ values[2], values[3], 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
- 0.0, 0.0, 0.0, 1.0,
+ values[4], values[5], 0.0, 1.0,
};
}
From 13c2c73f8ea97bdf91218df25989165e577775e9 Mon Sep 17 00:00:00 2001
From: Florian Rappl
Date: Wed, 9 Sep 2026 14:33:18 +0200
Subject: [PATCH 5/9] Allow forcing set hover and other pseudo classes
---
CHANGELOG.md | 1 +
docs/general/04-Core-Interfaces.md | 27 +++
docs/general/06-Provided-Services.md | 1 +
docs/tutorials/02-Examples.md | 28 +++-
.../Styling/HoverPseudoClass.cs | 57 +++++++
.../Styling/PseudoClassForcing.cs | 155 ++++++++++++++++++
.../CssConfigurationExtensions.cs | 11 ++
.../Internal/ForcingPseudoClassSelector.cs | 32 ++++
.../Extensions/ElementExtensions.cs | 79 +++++++++
.../ForcingPseudoClassSelectorFactory.cs | 40 +++++
src/AngleSharp.Css/PseudoClassStateStore.cs | 41 +++++
11 files changed, 471 insertions(+), 1 deletion(-)
create mode 100644 src/AngleSharp.Css.Tests/Styling/HoverPseudoClass.cs
create mode 100644 src/AngleSharp.Css.Tests/Styling/PseudoClassForcing.cs
create mode 100644 src/AngleSharp.Css/Dom/Internal/ForcingPseudoClassSelector.cs
create mode 100644 src/AngleSharp.Css/Factories/ForcingPseudoClassSelectorFactory.cs
create mode 100644 src/AngleSharp.Css/PseudoClassStateStore.cs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 028de0c..34ea629 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@ Released on Wednesday, September 9 2026
- Fixed unresolved converter re-entry (#243) @sebastienros
- Fixed issue with computation of `transform` functions
+- Added explicit pseudo class handling via `SetPseudoClass`
# 1.1.0
diff --git a/docs/general/04-Core-Interfaces.md b/docs/general/04-Core-Interfaces.md
index b569cca..aa0264c 100644
--- a/docs/general/04-Core-Interfaces.md
+++ b/docs/general/04-Core-Interfaces.md
@@ -77,6 +77,33 @@ var config = Configuration.Default
These are central for extensibility and custom property handling.
+## Pseudo-Class Forcing
+
+AngleSharp has no notion of pointer/keyboard interaction state, so pseudo-classes such as `:hover` and `:active` never match by default. `WithCss()` adds extension methods on `IElement` that let a caller force a pseudo-class to match (or not match) for a specific element, both in `Element.Matches(...)` and in computed style (`ComputeCurrentStyle()`/`ComputeDeclarations(...)`):
+
+- `SetPseudoClass(pseudoClass, value = true)`
+: Forces the given pseudo-class on (or off, with `value: false`).
+- `GetPseudoClass(pseudoClass)`
+: Returns the forced value, or `null` if nothing was forced for that element.
+- `RemovePseudoClass(pseudoClass)`
+: Clears a single forced pseudo-class, reverting to normal matching.
+- `ClearPseudoClasses()`
+: Clears every forced pseudo-class for the element.
+
+```cs
+using AngleSharp.Dom;
+
+var target = document.QuerySelector("#target");
+target.SetPseudoClass("hover");
+
+Console.WriteLine(target.Matches(":hover")); // True
+Console.WriteLine(target.ComputeCurrentStyle().GetPropertyValue("background-color"));
+
+target.RemovePseudoClass("hover");
+```
+
+This applies to any pseudo-class the selector engine recognizes (`:hover`, `:active`, `:visited`, ...), works per element only (forcing a child does not propagate to its ancestors), and is not the mechanism for `:focus`: that pseudo-class already has a real, settable state in AngleSharp core, so `SetPseudoClass("focus", ...)` delegates to `IHtmlElement.DoFocus()`/`DoBlur()` instead of a separate forced flag.
+
## Rule Of Thumb
- Use parser and CSSOM interfaces for analysis/transforms.
diff --git a/docs/general/06-Provided-Services.md b/docs/general/06-Provided-Services.md
index 73dc5a4..d1ba3e6 100644
--- a/docs/general/06-Provided-Services.md
+++ b/docs/general/06-Provided-Services.md
@@ -18,6 +18,7 @@ From the default configuration extension:
- `ICssParser` -> parser instance using optional `CssParserOptions`,
- `IStylingService` -> `CssStylingService` for CSS MIME handling,
- CSS observer service (`Factory.Observer`) for style mutation integration.
+- `IPseudoClassSelectorFactory` -> wrapped so any recognized pseudo-class can be forced per element via `SetPseudoClass(...)` (see [Core Interfaces](04-Core-Interfaces.md)).
## Quick Service Retrieval
diff --git a/docs/tutorials/02-Examples.md b/docs/tutorials/02-Examples.md
index 5edffa7..69a0fca 100644
--- a/docs/tutorials/02-Examples.md
+++ b/docs/tutorials/02-Examples.md
@@ -197,7 +197,33 @@ Console.WriteLine(serialized);
This preserves comments, but not their exact original positions in every case. Depending on where a comment was placed, it may be moved when the stylesheet is serialized again.
-## 11. Where To Go Next
+## 11. Preview A `:hover` State Without Real Interaction
+
+Useful for headless rendering/screenshot tools that need to show interactive states.
+
+```cs
+using AngleSharp;
+using AngleSharp.Dom;
+
+var html = @"
+
+";
+
+var context = BrowsingContext.New(Configuration.Default.WithCss());
+var document = await context.OpenAsync(req => req.Content(html));
+
+var button = document.QuerySelector("#btn");
+button.SetPseudoClass("hover");
+
+Console.WriteLine(button.ComputeCurrentStyle().GetPropertyValue("background-color")); // rgba(255, 0, 0, 1)
+
+button.RemovePseudoClass("hover");
+```
+
+## 12. Where To Go Next
- Read [API Documentation](01-API.md) for deeper CSSOM details.
- Read [Render Tree Examples](03-Render-Tree.md) for style-aware tree traversal and resource download workflows.
diff --git a/src/AngleSharp.Css.Tests/Styling/HoverPseudoClass.cs b/src/AngleSharp.Css.Tests/Styling/HoverPseudoClass.cs
new file mode 100644
index 0000000..53a327b
--- /dev/null
+++ b/src/AngleSharp.Css.Tests/Styling/HoverPseudoClass.cs
@@ -0,0 +1,57 @@
+#nullable disable
+namespace AngleSharp.Css.Tests.Styling
+{
+ using AngleSharp.Dom;
+ using NUnit.Framework;
+ using static CssConstructionFunctions;
+
+ ///
+ /// AngleSharp has no notion of pointer/interaction state, so `:hover` is unconditionally
+ /// non-matching unless explicitly forced via
+ /// (see PseudoClassForcing.cs for the general mechanism this relies on). These tests pin down
+ /// the specific `:hover` case that first surfaced the gap while building CSS `transition`
+ /// support in a downstream renderer (AngleSharp.Renderer).
+ ///
+ [TestFixture]
+ public class HoverPseudoClassTests
+ {
+ [Test]
+ public void HoverDoesNotMatchByDefault()
+ {
+ var document = ParseDocument("");
+ var target = document.GetElementById("target");
+
+ Assert.IsFalse(target.Matches(":hover"));
+ Assert.IsTrue(target.Matches(":not(:hover)"));
+ }
+
+ [Test]
+ public void ForcingHoverMakesItMatchInTheSelectorEngine()
+ {
+ var document = ParseDocument("");
+ var target = document.GetElementById("target");
+
+ target.SetPseudoClass("hover");
+
+ Assert.IsTrue(target.Matches(":hover"));
+ Assert.IsFalse(target.Matches(":not(:hover)"));
+ }
+
+ [Test]
+ public void ForcingHoverLetsTheMoreSpecificHoverRuleWinTheCascade()
+ {
+ // #target:hover is more specific than #target and declared after it, so once :hover
+ // is forced to match, the cascade should resolve to red instead of blue.
+ var document = ParseDocument(@"");
+ var target = document.GetElementById("target");
+
+ target.SetPseudoClass("hover");
+ var backgroundColor = target.ComputeCurrentStyle().GetPropertyValue("background-color");
+
+ Assert.AreEqual("rgba(255, 0, 0, 1)", backgroundColor);
+ }
+ }
+}
diff --git a/src/AngleSharp.Css.Tests/Styling/PseudoClassForcing.cs b/src/AngleSharp.Css.Tests/Styling/PseudoClassForcing.cs
new file mode 100644
index 0000000..0aa2d58
--- /dev/null
+++ b/src/AngleSharp.Css.Tests/Styling/PseudoClassForcing.cs
@@ -0,0 +1,155 @@
+#nullable disable
+namespace AngleSharp.Css.Tests.Styling
+{
+ using AngleSharp.Dom;
+ using AngleSharp.Html.Dom;
+ using NUnit.Framework;
+ using static CssConstructionFunctions;
+
+ ///
+ /// Tests the generic pseudo-class forcing API (SetPseudoClass/GetPseudoClass/RemovePseudoClass/
+ /// ClearPseudoClasses), which lets a caller simulate interaction states such as `:hover` and
+ /// `:active` that AngleSharp otherwise never reports as matching - analogous to what browser
+ /// devtools expose via the Chrome DevTools Protocol's `CSS.forcePseudoState`.
+ ///
+ [TestFixture]
+ public class PseudoClassForcingTests
+ {
+ [Test]
+ public void GetPseudoClassReturnsNullWhenNothingHasBeenForced()
+ {
+ var document = ParseDocument("");
+ var target = document.GetElementById("target");
+
+ Assert.IsNull(target.GetPseudoClass("hover"));
+ }
+
+ [Test]
+ public void SetPseudoClassForcesTheGivenStateAndGetPseudoClassReportsIt()
+ {
+ var document = ParseDocument("");
+ var target = document.GetElementById("target");
+
+ target.SetPseudoClass("hover");
+
+ Assert.AreEqual(true, target.GetPseudoClass("hover"));
+ Assert.IsTrue(target.Matches(":hover"));
+ }
+
+ [Test]
+ public void SetPseudoClassAcceptsALeadingColon()
+ {
+ var document = ParseDocument("");
+ var target = document.GetElementById("target");
+
+ target.SetPseudoClass(":active");
+
+ Assert.IsTrue(target.Matches(":active"));
+ }
+
+ [Test]
+ public void RemovePseudoClassRevertsToTheNaturalState()
+ {
+ var document = ParseDocument("");
+ var target = document.GetElementById("target");
+
+ target.SetPseudoClass("hover");
+ target.RemovePseudoClass("hover");
+
+ Assert.IsNull(target.GetPseudoClass("hover"));
+ Assert.IsFalse(target.Matches(":hover"));
+ }
+
+ [Test]
+ public void ClearPseudoClassesRemovesEveryForcedStateForTheElement()
+ {
+ var document = ParseDocument("");
+ var target = document.GetElementById("target");
+
+ target.SetPseudoClass("hover");
+ target.SetPseudoClass("active");
+ target.ClearPseudoClasses();
+
+ Assert.IsFalse(target.Matches(":hover"));
+ Assert.IsFalse(target.Matches(":active"));
+ }
+
+ [Test]
+ public void ForcingIsExplicitAndDoesNotPropagateToAncestors()
+ {
+ var document = ParseDocument("
");
+ var child = document.GetElementById("child");
+ var parent = document.GetElementById("parent");
+
+ child.SetPseudoClass("hover");
+
+ Assert.IsTrue(child.Matches(":hover"));
+ Assert.IsFalse(parent.Matches(":hover"), "forcing a pseudo-class on an element must not implicitly affect its ancestors.");
+ }
+
+ [Test]
+ public void ForcingCanBeExplicitlySetToFalseToOverrideTheNaturalState()
+ {
+ var document = ParseDocument("");
+ var target = document.GetElementById("target");
+
+ target.SetPseudoClass("active", false);
+
+ Assert.AreEqual(false, target.GetPseudoClass("active"));
+ Assert.IsFalse(target.Matches(":active"));
+ }
+
+ [Test]
+ public void ForcingGeneralizesToOtherHardcodedPseudoClassesLikeVisited()
+ {
+ var document = ParseDocument("");
+ var target = document.GetElementById("target");
+
+ target.SetPseudoClass("visited");
+
+ Assert.IsTrue(target.Matches(":visited"));
+ }
+
+ [Test]
+ public void SettingFocusDelegatesToTheRealFocusStateInsteadOfAForcedOverride()
+ {
+ // Only elements whose DoFocus() implementation actually sets focus (e.g. anchors with
+ // an href) can be focused today - this is a real, separate AngleSharp core limitation.
+ var document = ParseDocument("");
+ var target = document.GetElementById("target") as IHtmlElement;
+
+ target.SetPseudoClass("focus");
+
+ Assert.IsTrue(target.Matches(":focus"));
+ Assert.AreEqual(true, target.GetPseudoClass("focus"));
+ Assert.AreSame(target, document.ActiveElement);
+ }
+
+ [Test]
+ public void SettingFocusOnAnElementMakesFocusWithinMatchOnItsAncestorsNaturally()
+ {
+ var document = ParseDocument("
");
+ var target = document.GetElementById("target") as IHtmlElement;
+ var parent = document.GetElementById("parent");
+
+ target.SetPseudoClass("focus");
+
+ Assert.IsTrue(parent.Matches(":focus-within"), "focus-within is derived from real focus state, so it works without any forcing.");
+ }
+
+ [Test]
+ public void RemovingFocusAttemptsToBlurTheElement()
+ {
+ // AngleSharp core does not currently implement DoBlur() for any element (it is a
+ // no-op everywhere), so this only calls into that hook - it cannot itself force focus
+ // to be cleared. Documented here since it is a separate, existing core limitation.
+ var document = ParseDocument("");
+ var target = document.GetElementById("target") as IHtmlElement;
+
+ target.SetPseudoClass("focus");
+ target.RemovePseudoClass("focus");
+
+ Assert.IsTrue(target.Matches(":focus"), "DoBlur() is a no-op in AngleSharp core today, so focus is not actually cleared.");
+ }
+ }
+}
diff --git a/src/AngleSharp.Css/CssConfigurationExtensions.cs b/src/AngleSharp.Css/CssConfigurationExtensions.cs
index 09985b4..da30661 100644
--- a/src/AngleSharp.Css/CssConfigurationExtensions.cs
+++ b/src/AngleSharp.Css/CssConfigurationExtensions.cs
@@ -4,6 +4,7 @@ namespace AngleSharp
using AngleSharp.Css;
using AngleSharp.Css.Parser;
using System;
+ using System.Linq;
///
/// Extensions for the configuration.
@@ -52,6 +53,16 @@ public static IConfiguration WithCss(this IConfiguration configuration, CssParse
configuration = configuration.With(context => new CssParser(options, context));
}
+ // Wraps whatever pseudo-class selector factory is registered so that pseudo-classes
+ // it recognizes (e.g. :hover, :active) can be forced per element via
+ // ElementExtensions.SetPseudoClass, regardless of which factory instance is in use.
+ var pseudoClassFactory = configuration.Services.OfType().FirstOrDefault();
+
+ if (pseudoClassFactory is not null && pseudoClassFactory is not ForcingPseudoClassSelectorFactory)
+ {
+ configuration = configuration.WithOnly(new ForcingPseudoClassSelectorFactory(pseudoClassFactory));
+ }
+
return configuration
.WithOnly(Factory.Observer)
.WithOnly(service);
diff --git a/src/AngleSharp.Css/Dom/Internal/ForcingPseudoClassSelector.cs b/src/AngleSharp.Css/Dom/Internal/ForcingPseudoClassSelector.cs
new file mode 100644
index 0000000..972342d
--- /dev/null
+++ b/src/AngleSharp.Css/Dom/Internal/ForcingPseudoClassSelector.cs
@@ -0,0 +1,32 @@
+#nullable enable
+namespace AngleSharp.Css.Dom
+{
+ using AngleSharp.Dom;
+ using System;
+
+ ///
+ /// Wraps a pseudo-class selector so that a caller-forced state (set via
+ /// ) takes precedence over the
+ /// wrapped selector's normal matching logic.
+ ///
+ sealed class ForcingPseudoClassSelector : ISelector
+ {
+ private readonly String _name;
+ private readonly ISelector _inner;
+
+ public ForcingPseudoClassSelector(String name, ISelector inner)
+ {
+ _name = name;
+ _inner = inner;
+ }
+
+ public Priority Specificity => _inner.Specificity;
+
+ public String Text => _inner.Text;
+
+ public void Accept(ISelectorVisitor visitor) => _inner.Accept(visitor);
+
+ public Boolean Match(IElement element, IElement? scope) =>
+ PseudoClassStateStore.TryGet(element, _name, out var forced) ? forced : _inner.Match(element, scope);
+ }
+}
diff --git a/src/AngleSharp.Css/Extensions/ElementExtensions.cs b/src/AngleSharp.Css/Extensions/ElementExtensions.cs
index bf78a5c..46e6a49 100644
--- a/src/AngleSharp.Css/Extensions/ElementExtensions.cs
+++ b/src/AngleSharp.Css/Extensions/ElementExtensions.cs
@@ -32,6 +32,85 @@ public static class ElementExtensions
return factory?.Create(element, pseudoElement);
}
+ ///
+ /// Forces the given pseudo-class to be considered active (or inactive) for this element
+ /// during selector matching and style computation, e.g. to preview a `:hover` or
+ /// `:active` state without real pointer/keyboard interaction.
+ ///
+ /// The element to force the pseudo-class on.
+ /// The pseudo-class name, with or without the leading colon.
+ /// True to force it as matching, false to force it as not matching.
+ ///
+ /// The focus pseudo-class is backed by real focus state instead of a forced
+ /// override; setting it calls / .
+ /// Forcing is explicit and does not propagate to ancestors or descendants.
+ ///
+ public static void SetPseudoClass(this IElement element, String pseudoClass, Boolean value = true)
+ {
+ pseudoClass = pseudoClass.TrimStart(':');
+
+ if (pseudoClass.Equals(PseudoClassNames.Focus, StringComparison.OrdinalIgnoreCase))
+ {
+ if (element is IHtmlElement html)
+ {
+ if (value)
+ {
+ html.DoFocus();
+ }
+ else
+ {
+ html.DoBlur();
+ }
+ }
+
+ return;
+ }
+
+ PseudoClassStateStore.Set(element, pseudoClass, value);
+ }
+
+ ///
+ /// Gets the forced state of the given pseudo-class for this element, or null if it has
+ /// not been forced (in which case normal matching rules apply).
+ ///
+ /// The element to inspect.
+ /// The pseudo-class name, with or without the leading colon.
+ public static Boolean? GetPseudoClass(this IElement element, String pseudoClass)
+ {
+ pseudoClass = pseudoClass.TrimStart(':');
+
+ if (pseudoClass.Equals(PseudoClassNames.Focus, StringComparison.OrdinalIgnoreCase))
+ {
+ return element.IsFocused;
+ }
+
+ return PseudoClassStateStore.TryGet(element, pseudoClass, out var value) ? value : null;
+ }
+
+ ///
+ /// Removes a previously forced pseudo-class state, reverting to normal matching rules.
+ ///
+ /// The element to reset.
+ /// The pseudo-class name, with or without the leading colon.
+ public static void RemovePseudoClass(this IElement element, String pseudoClass)
+ {
+ pseudoClass = pseudoClass.TrimStart(':');
+
+ if (pseudoClass.Equals(PseudoClassNames.Focus, StringComparison.OrdinalIgnoreCase))
+ {
+ (element as IHtmlElement)?.DoBlur();
+ return;
+ }
+
+ PseudoClassStateStore.Remove(element, pseudoClass);
+ }
+
+ ///
+ /// Removes all forced pseudo-class states for this element.
+ ///
+ /// The element to reset.
+ public static void ClearPseudoClasses(this IElement element) => PseudoClassStateStore.Clear(element);
+
///
/// Gets the innerText of an element.
///
diff --git a/src/AngleSharp.Css/Factories/ForcingPseudoClassSelectorFactory.cs b/src/AngleSharp.Css/Factories/ForcingPseudoClassSelectorFactory.cs
new file mode 100644
index 0000000..edd665e
--- /dev/null
+++ b/src/AngleSharp.Css/Factories/ForcingPseudoClassSelectorFactory.cs
@@ -0,0 +1,40 @@
+#nullable enable
+namespace AngleSharp.Css
+{
+ using AngleSharp.Css.Dom;
+ using AngleSharp.Dom;
+ using System;
+
+ ///
+ /// Decorates another pseudo-class selector factory so that any pseudo-class it recognizes can
+ /// be forced on/off per element via .
+ ///
+ ///
+ /// The focus pseudo-class is intentionally left untouched: AngleSharp already tracks
+ /// real focus state (, settable through
+ /// /
+ /// ), so that remains the single source
+ /// of truth instead of introducing a second, potentially conflicting one.
+ ///
+ sealed class ForcingPseudoClassSelectorFactory : IPseudoClassSelectorFactory
+ {
+ private readonly IPseudoClassSelectorFactory _inner;
+
+ public ForcingPseudoClassSelectorFactory(IPseudoClassSelectorFactory inner)
+ {
+ _inner = inner;
+ }
+
+ public ISelector? Create(String name)
+ {
+ var selector = _inner.Create(name);
+
+ if (selector is null || name.Equals(PseudoClassNames.Focus, StringComparison.OrdinalIgnoreCase))
+ {
+ return selector;
+ }
+
+ return new ForcingPseudoClassSelector(name, selector);
+ }
+ }
+}
diff --git a/src/AngleSharp.Css/PseudoClassStateStore.cs b/src/AngleSharp.Css/PseudoClassStateStore.cs
new file mode 100644
index 0000000..1dd69ab
--- /dev/null
+++ b/src/AngleSharp.Css/PseudoClassStateStore.cs
@@ -0,0 +1,41 @@
+#nullable enable
+namespace AngleSharp.Css
+{
+ using AngleSharp.Dom;
+ using System;
+ using System.Collections.Generic;
+ using System.Runtime.CompilerServices;
+
+ ///
+ /// Tracks pseudo-class states forced onto individual elements, keyed by element identity so
+ /// that no changes to itself are required.
+ ///
+ static class PseudoClassStateStore
+ {
+ private static readonly ConditionalWeakTable> _states = new();
+
+ public static void Set(IElement element, String pseudoClass, Boolean value) =>
+ _states.GetValue(element, _ => new Dictionary(StringComparer.OrdinalIgnoreCase))[pseudoClass] = value;
+
+ public static Boolean TryGet(IElement element, String pseudoClass, out Boolean value)
+ {
+ if (_states.TryGetValue(element, out var state) && state.TryGetValue(pseudoClass, out value))
+ {
+ return true;
+ }
+
+ value = default;
+ return false;
+ }
+
+ public static void Remove(IElement element, String pseudoClass)
+ {
+ if (_states.TryGetValue(element, out var state))
+ {
+ state.Remove(pseudoClass);
+ }
+ }
+
+ public static void Clear(IElement element) => _states.Remove(element);
+ }
+}
From 36aef14503f5f8a8ad5b8961d9c2fba6d34bf333 Mon Sep 17 00:00:00 2001
From: Florian Rappl
Date: Wed, 9 Sep 2026 19:20:14 +0200
Subject: [PATCH 6/9] Enhancements and fixes
---
CHANGELOG.md | 4 ++
.../Extensions/Elements.cs | 6 +-
.../Styling/AnimationComputedStyleTests.cs | 59 +++++++++++++++++
.../BorderRadiusPercentageResolutionTests.cs | 63 +++++++++++++++++++
.../Styling/FilterPropertyTests.cs | 48 ++++++++++++++
.../Styling/ListStyleComputedValueTests.cs | 49 +++++++++++++++
.../Styling/OverflowComputedStyleTests.cs | 48 ++++++++++++++
src/AngleSharp.Css/Constants/InitialValues.cs | 2 +-
.../CssDefaultStyleSheetProvider.cs | 2 +-
.../Declarations/OverflowDeclaration.cs | 46 +++++++++++++-
.../Declarations/OverflowXDeclaration.cs | 5 ++
.../Declarations/OverflowYDeclaration.cs | 5 ++
.../Dom/Internal/CssProperty.cs | 4 +-
.../Factories/DefaultDeclarationFactory.cs | 9 ++-
.../Values/Composites/CssBorderRadiusValue.cs | 48 +++++++++++++-
.../Values/CssComputeContext.cs | 5 +-
.../Values/ILocalComputeContext.cs | 8 +++
.../Values/Multiples/CssRadiusValue.cs | 44 ++++++++++++-
.../Values/Primitives/CssTimeValue.cs | 2 +-
19 files changed, 442 insertions(+), 15 deletions(-)
create mode 100644 src/AngleSharp.Css.Tests/Styling/AnimationComputedStyleTests.cs
create mode 100644 src/AngleSharp.Css.Tests/Styling/BorderRadiusPercentageResolutionTests.cs
create mode 100644 src/AngleSharp.Css.Tests/Styling/FilterPropertyTests.cs
create mode 100644 src/AngleSharp.Css.Tests/Styling/ListStyleComputedValueTests.cs
create mode 100644 src/AngleSharp.Css.Tests/Styling/OverflowComputedStyleTests.cs
create mode 100644 src/AngleSharp.Css/Values/ILocalComputeContext.cs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 34ea629..b3565eb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,10 @@ Released on Wednesday, September 9 2026
- Fixed unresolved converter re-entry (#243) @sebastienros
- Fixed issue with computation of `transform` functions
+- Fixed computed `animation` longhands retaining unresolved `initial` values
+- Fixed percentage `border-radius` components resolving against the wrong axis
+- Fixed default list styling for unordered lists
+- Added shorthand decomposition and `clip` support for `overflow`
- Added explicit pseudo class handling via `SetPseudoClass`
# 1.1.0
diff --git a/src/AngleSharp.Css.Tests/Extensions/Elements.cs b/src/AngleSharp.Css.Tests/Extensions/Elements.cs
index f34bc68..3c563f9 100644
--- a/src/AngleSharp.Css.Tests/Extensions/Elements.cs
+++ b/src/AngleSharp.Css.Tests/Extensions/Elements.cs
@@ -39,7 +39,11 @@ public async Task DownloadResources()
};
var config = Configuration.Default
.WithDefaultLoader(loaderOptions)
- .WithRenderDevice()
+ .WithRenderDevice(new DefaultRenderDevice
+ {
+ ViewPortWidth = 800,
+ ViewPortHeight = 600,
+ })
.WithCss();
var document = "".ToHtmlDocument(config);
var tree = document.DefaultView!.Render();
diff --git a/src/AngleSharp.Css.Tests/Styling/AnimationComputedStyleTests.cs b/src/AngleSharp.Css.Tests/Styling/AnimationComputedStyleTests.cs
new file mode 100644
index 0000000..b07987e
--- /dev/null
+++ b/src/AngleSharp.Css.Tests/Styling/AnimationComputedStyleTests.cs
@@ -0,0 +1,59 @@
+#nullable disable
+namespace AngleSharp.Css.Tests.Styling
+{
+ using AngleSharp.Dom;
+ using NUnit.Framework;
+ using static CssConstructionFunctions;
+
+ ///
+ /// Per the CSS cascade, a computed style is supposed to fully resolve every longhand to a
+ /// concrete value - the CSS-wide keyword `initial` is itself resolved away during computation,
+ /// never present verbatim in the computed declaration. Confirmed empirically while building
+ /// CSS `animation` support in a downstream renderer (AngleSharp.Renderer): any `animation`
+ /// longhand the `animation` shorthand does not explicitly set reports the literal string
+ /// `"initial"` instead of that property's own real initial value (`normal` for
+ /// `animation-direction`, `none` for `animation-fill-mode`, `0s` for `animation-delay`,
+ /// `running` for `animation-play-state`) - unlike `transition`'s equivalent longhands, which
+ /// report an empty string instead in the same situation (see BasicStyling.cs's sibling
+ /// investigation for `transition`, not affected by this).
+ ///
+ [TestFixture]
+ public class AnimationComputedStyleTests
+ {
+ [Test]
+ public void UnsetAnimationDirectionResolvesToNormal()
+ {
+ var document = ParseDocument("");
+ var target = document.GetElementById("target");
+
+ Assert.AreEqual("normal", target.ComputeCurrentStyle().GetPropertyValue("animation-direction"));
+ }
+
+ [Test]
+ public void UnsetAnimationFillModeResolvesToNone()
+ {
+ var document = ParseDocument("");
+ var target = document.GetElementById("target");
+
+ Assert.AreEqual("none", target.ComputeCurrentStyle().GetPropertyValue("animation-fill-mode"));
+ }
+
+ [Test]
+ public void UnsetAnimationDelayResolvesToZeroSeconds()
+ {
+ var document = ParseDocument("");
+ var target = document.GetElementById("target");
+
+ Assert.AreEqual("0s", target.ComputeCurrentStyle().GetPropertyValue("animation-delay"));
+ }
+
+ [Test]
+ public void UnsetAnimationPlayStateResolvesToRunning()
+ {
+ var document = ParseDocument("");
+ var target = document.GetElementById("target");
+
+ Assert.AreEqual("running", target.ComputeCurrentStyle().GetPropertyValue("animation-play-state"));
+ }
+ }
+}
diff --git a/src/AngleSharp.Css.Tests/Styling/BorderRadiusPercentageResolutionTests.cs b/src/AngleSharp.Css.Tests/Styling/BorderRadiusPercentageResolutionTests.cs
new file mode 100644
index 0000000..cdeceba
--- /dev/null
+++ b/src/AngleSharp.Css.Tests/Styling/BorderRadiusPercentageResolutionTests.cs
@@ -0,0 +1,63 @@
+#nullable disable
+namespace AngleSharp.Css.Tests.Styling
+{
+ using AngleSharp.Dom;
+ using AngleSharp.Html.Parser;
+ using NUnit.Framework;
+
+ ///
+ /// Per https://www.w3.org/TR/css-backgrounds-3/#corner-overlap, a percentage `border-radius`
+ /// resolves its horizontal component against the border box's own *width* and its vertical
+ /// component against the border box's own *height* - independently. Confirmed empirically
+ /// while building `border-radius` support in a downstream renderer (AngleSharp.Renderer) that
+ /// this does not happen: both components resolve against the containing block's/element's
+ /// *width* alone, so on a box whose width and height differ, the vertical radius comes out
+ /// wrong (tracking the wrong axis's dimension entirely, not merely imprecise).
+ ///
+ [TestFixture]
+ public class BorderRadiusPercentageResolutionTests
+ {
+ private static IDocument ParseWithRenderDevice(string html, int viewPortWidth = 1000)
+ {
+ var config = Configuration.Default
+ .WithCss()
+ .WithRenderDevice(new DefaultRenderDevice { ViewPortWidth = viewPortWidth });
+ var browsingContext = BrowsingContext.New(config);
+ var htmlParser = browsingContext.GetService();
+ return htmlParser.ParseDocument(html);
+ }
+
+ [Test]
+ public void VerticalPercentageComponentResolvesAgainstTheElementsOwnHeight()
+ {
+ // 200x100 box, `border-radius: 10% / 30%` - the horizontal component (10%) should
+ // resolve against the 200px width (20px); the vertical component (30%) should resolve
+ // against the 100px height (30px), not against the 200px width (which would give the
+ // wrong value, 60px).
+ var document = ParseWithRenderDevice("");
+ var target = document.GetElementById("target");
+ var style = target.ComputeCurrentStyle();
+
+ var horizontal = style.GetPropertyValue("border-top-left-radius");
+ Assert.IsTrue(horizontal.Contains("30px"), $"expected the vertical 30% component to resolve to 30px (30% of the 100px height); got '{horizontal}'");
+ }
+
+ [Test]
+ public void VerticalPercentageComponentTracksHeightAcrossDifferentElementHeights()
+ {
+ // The same vertical percentage against two different heights (but the same width)
+ // must resolve to two different pixel values if it is genuinely tracking height - if
+ // it were (incorrectly) tracking width instead, both would resolve identically despite
+ // the different heights.
+ var shortDocument = ParseWithRenderDevice("");
+ var shortTarget = shortDocument.GetElementById("target");
+ var shortRadius = shortTarget.ComputeCurrentStyle().GetPropertyValue("border-top-left-radius");
+
+ var tallDocument = ParseWithRenderDevice("");
+ var tallTarget = tallDocument.GetElementById("target");
+ var tallRadius = tallTarget.ComputeCurrentStyle().GetPropertyValue("border-top-left-radius");
+
+ Assert.AreNotEqual(shortRadius, tallRadius, "a 30% vertical radius against a 100px-tall box and a 200px-tall box must resolve to different pixel values.");
+ }
+ }
+}
diff --git a/src/AngleSharp.Css.Tests/Styling/FilterPropertyTests.cs b/src/AngleSharp.Css.Tests/Styling/FilterPropertyTests.cs
new file mode 100644
index 0000000..15c1be3
--- /dev/null
+++ b/src/AngleSharp.Css.Tests/Styling/FilterPropertyTests.cs
@@ -0,0 +1,48 @@
+#nullable disable
+namespace AngleSharp.Css.Tests.Styling
+{
+ using AngleSharp.Dom;
+ using NUnit.Framework;
+ using static CssConstructionFunctions;
+
+ ///
+ /// Documents a confirmed, missing capability rather than a wrong-value bug (unlike the other
+ /// gaps documented alongside this file - AnimationComputedStyleTests, ListStyleComputedValueTests,
+ /// OverflowComputedStyleTests, BorderRadiusPercentageResolutionTests - which are each a small,
+ /// pinpointable fix): CSS `filter` has no structured parsing support at all. Unlike `transform`
+ /// (`AngleSharp.Css.Parser.TransformParser`/`ICssTransformFunctionValue`) and unlike
+ /// `background-image`'s gradient functions (`AngleSharp.Css.Parser.GradientParser`/
+ /// `ICssGradientFunctionValue` - both fully public and already relied on directly by a
+ /// downstream renderer, AngleSharp.Renderer, instead of reimplementing that parsing locally),
+ /// reflecting over this assembly finds no `FilterParser`/`ICssFilterFunctionValue` equivalent
+ /// for `filter` - only the internal, unrelated `BackdropFilterDeclaration` for the different
+ /// `backdrop-filter` property. A downstream renderer that wants to support `filter` (`blur()`,
+ /// `grayscale()`, `drop-shadow()`, ...) currently has nothing to delegate to and must hand-parse
+ /// the raw inline `style=""` text itself - the same situation `transform` and CSS gradients
+ /// used to be in before `TransformParser`/`GradientParser` existed.
+ ///
+ [TestFixture]
+ public class FilterPropertyTests
+ {
+ [Test]
+ public void FilterComputedValueIsAlwaysEmptyRegardlessOfWhatWasAuthored()
+ {
+ var document = ParseDocument("");
+ var target = document.GetElementById("target");
+
+ Assert.AreEqual(string.Empty, target.ComputeCurrentStyle().GetPropertyValue("filter"));
+ }
+
+ [Test]
+ public void RawFilterTextIsStillReadableFromTheInlineStyleAttributeItself()
+ {
+ // Confirms the gap is specifically in AngleSharp.Css's own computed-style/cascade
+ // pipeline, not in the HTML/attribute layer - the text is right there, just never
+ // parsed into a structured value or even echoed back through computed style.
+ var document = ParseDocument("");
+ var target = document.GetElementById("target");
+
+ Assert.AreEqual("filter: grayscale(0.9) blur(2px);", target.GetAttribute("style"));
+ }
+ }
+}
diff --git a/src/AngleSharp.Css.Tests/Styling/ListStyleComputedValueTests.cs b/src/AngleSharp.Css.Tests/Styling/ListStyleComputedValueTests.cs
new file mode 100644
index 0000000..d084646
--- /dev/null
+++ b/src/AngleSharp.Css.Tests/Styling/ListStyleComputedValueTests.cs
@@ -0,0 +1,49 @@
+#nullable disable
+namespace AngleSharp.Css.Tests.Styling
+{
+ using AngleSharp.Dom;
+ using NUnit.Framework;
+ using static CssConstructionFunctions;
+
+ ///
+ /// A computed style should always resolve to a property's own initial value when nothing in
+ /// the cascade sets it explicitly - confirmed this does not happen for `list-style-type`/
+ /// `list-style-position` on a `<ul>` while building `display: list-item` support in a
+ /// downstream renderer (AngleSharp.Renderer): both come back as an empty string instead of
+ /// their real initial values (`disc`, `outside`). `<ol>` is included as a control case -
+ /// its UA-stylesheet rule explicitly sets `list-style-type: decimal`, and that value *does*
+ /// show up correctly, confirming this is specifically a missing initial-value fallback (for a
+ /// property nothing in the cascade ever set), not a blanket "these properties are never
+ /// computed" issue.
+ ///
+ [TestFixture]
+ public class ListStyleComputedValueTests
+ {
+ [Test]
+ public void UnsetListStyleTypeOnUnorderedListResolvesToDisc()
+ {
+ var document = ParseDocument("