From af16326eb1386bc3a9ab6b3af0083d453e6e4139 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= <1165805+sebastienros@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:14:45 -0700 Subject: [PATCH 1/9] Fix unresolved-any re-entry during style computation Return an accepted opaque value instead of recursively computing it through the same composite converter. Preserve complete-input validation, concrete computation, and direct Any resolution semantics. Cover individual transforms, nested composite converters, variable substitution, inheritance, invalid values, and custom-property cycles. Fixes #243 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Styling/IndividualTransformComputation.cs | 124 +++++++++++++++++ .../Values/AnyValueComputation.cs | 127 ++++++++++++++++++ src/AngleSharp.Css/Values/Raws/CssAnyValue.cs | 10 +- 3 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 src/AngleSharp.Css.Tests/Styling/IndividualTransformComputation.cs create mode 100644 src/AngleSharp.Css.Tests/Values/AnyValueComputation.cs diff --git a/src/AngleSharp.Css.Tests/Styling/IndividualTransformComputation.cs b/src/AngleSharp.Css.Tests/Styling/IndividualTransformComputation.cs new file mode 100644 index 0000000..0c4a631 --- /dev/null +++ b/src/AngleSharp.Css.Tests/Styling/IndividualTransformComputation.cs @@ -0,0 +1,124 @@ +#nullable disable +namespace AngleSharp.Css.Tests.Styling +{ + using AngleSharp.Css.Dom; + using AngleSharp.Css.RenderTree; + using AngleSharp.Css.Values; + using AngleSharp.Dom; + using NUnit.Framework; + using System; + using System.Threading.Tasks; + using static CssConstructionFunctions; + + [TestFixture] + public class IndividualTransformComputationTests + { + [Test] + public async Task OriginalReproductionDoesNotOverflow() + { + using var context = BrowsingContext.New(Configuration.Default.WithCss()); + using var document = await context.OpenAsync(request => request.Content( + "
")); + + Assert.AreEqual("1px", document.QuerySelector("div").ComputeCurrentStyle().GetPropertyValue("translate")); + } + + [TestCase("translate", "1px")] + [TestCase("translate", "1px 2px")] + [TestCase("translate", "1px 2px 3px")] + [TestCase("translate", "50% 25%")] + [TestCase("translate", "0")] + [TestCase("translate", "0 -50%")] + [TestCase("translate", "none")] + [TestCase("rotate", "1deg")] + [TestCase("rotate", "45deg")] + [TestCase("rotate", "x 45deg")] + [TestCase("rotate", "1 0 0 45deg")] + [TestCase("rotate", "none")] + [TestCase("scale", "1")] + [TestCase("scale", "1.5 2")] + [TestCase("scale", "1 1.5 2")] + [TestCase("scale", "none")] + public void IndividualTransformsComputeWithoutChangingSpecifiedStyles(String name, String value) + { + using var document = ParseDocument("
"); + var element = document.QuerySelector("div"); + var styles = document.DefaultView.GetStyleCollection(new DefaultRenderDevice()); + var specified = styles.GetDeclarations(element); + var source = specified.CssText; + var rendered = RenderTreeBuilder.GetInstance(document.DefaultView).RenderElement(element, styles.Device); + + Assert.AreEqual(value, element.ComputeCurrentStyle().GetPropertyValue(name)); + Assert.AreEqual(value, rendered.ComputedStyle.GetPropertyValue(name)); + Assert.AreEqual(value, rendered.SpecifiedStyle.GetPropertyValue(name)); + Assert.AreEqual(source, specified.CssText); + Assert.AreEqual(value, element.ComputeCurrentStyle().GetPropertyValue(name)); + } + + [TestCase("translate", "1px", "2px", "1px 2px")] + [TestCase("rotate", "x", "45deg", "x 45deg")] + [TestCase("scale", "1", "2", "1 2")] + public void SubstitutionPreservesAllComponentsAndInheritedAliases(String name, String first, String second, String expected) + { + using var document = ParseDocument("
" + + "
"); + 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.1 enable latest true 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("
  • Item
"); + var target = document.GetElementById("target"); + + Assert.AreEqual("disc", target.ComputeCurrentStyle().GetPropertyValue("list-style-type")); + } + + [Test] + public void UnsetListStylePositionResolvesToOutside() + { + var document = ParseDocument("
  • Item
"); + var target = document.GetElementById("target"); + + Assert.AreEqual("outside", target.ComputeCurrentStyle().GetPropertyValue("list-style-position")); + } + + [Test] + public void OrderedListsUaRuleAlreadyResolvesListStyleTypeCorrectly() + { + var document = ParseDocument("
  1. Item
"); + var target = document.GetElementById("target"); + + Assert.AreEqual("decimal", target.ComputeCurrentStyle().GetPropertyValue("list-style-type")); + } + } +} diff --git a/src/AngleSharp.Css.Tests/Styling/OverflowComputedStyleTests.cs b/src/AngleSharp.Css.Tests/Styling/OverflowComputedStyleTests.cs new file mode 100644 index 0000000..821b65c --- /dev/null +++ b/src/AngleSharp.Css.Tests/Styling/OverflowComputedStyleTests.cs @@ -0,0 +1,48 @@ +#nullable disable +namespace AngleSharp.Css.Tests.Styling +{ + using AngleSharp.Dom; + using NUnit.Framework; + using static CssConstructionFunctions; + + /// + /// Two `overflow` computed-style gaps confirmed while building overflow clipping support in a + /// downstream renderer (AngleSharp.Renderer): + /// (1) the `overflow` shorthand does not decompose into `overflow-x`/`overflow-y` in the + /// computed style the way other shorthand/longhand pairs do, so a value authored only via the + /// shorthand is never visible through either longhand accessor; + /// (2) `overflow: clip` (a real, shipped CSS Overflow Module value) is not recognized by the + /// parser at all - the whole declaration is silently dropped rather than being computed or + /// even reported as an unsupported/unresolved value. + /// + [TestFixture] + public class OverflowComputedStyleTests + { + [Test] + public void OverflowShorthandDecomposesIntoOverflowXLonghand() + { + var document = ParseDocument("
"); + var target = document.GetElementById("target"); + + Assert.AreEqual("hidden", target.ComputeCurrentStyle().GetPropertyValue("overflow-x")); + } + + [Test] + public void OverflowShorthandDecomposesIntoOverflowYLonghand() + { + var document = ParseDocument("
"); + var target = document.GetElementById("target"); + + Assert.AreEqual("hidden", target.ComputeCurrentStyle().GetPropertyValue("overflow-y")); + } + + [Test] + public void OverflowClipIsRecognizedAndComputed() + { + var document = ParseDocument("
"); + var target = document.GetElementById("target"); + + Assert.AreEqual("clip", target.ComputeCurrentStyle().GetPropertyValue("overflow")); + } + } +} diff --git a/src/AngleSharp.Css/Constants/InitialValues.cs b/src/AngleSharp.Css/Constants/InitialValues.cs index eb80859..aabc8ed 100644 --- a/src/AngleSharp.Css/Constants/InitialValues.cs +++ b/src/AngleSharp.Css/Constants/InitialValues.cs @@ -90,7 +90,7 @@ static class InitialValues public static readonly ICssValue AnimationNameDecl = new CssConstantValue(CssKeywords.None, null); public static readonly ICssValue AnimationDurationDecl = CssTimeValue.Zero; public static readonly ICssValue AnimationTimingFunctionDecl = CssCubicBezierValue.Ease; - public static readonly ICssValue AnimationDelayDecl = CssTimeValue.Zero; + public static readonly ICssValue AnimationDelayDecl = new CssTimeValue(0, CssTimeValue.Unit.S); public static readonly ICssValue AnimationIterationCountDecl = new CssLengthValue(1, CssLengthValue.Unit.None); public static readonly ICssValue AnimationDirectionDecl = new CssConstantValue(CssKeywords.Normal, AnimationDirection.Normal); public static readonly ICssValue AnimationFillModeDecl = new CssConstantValue(CssKeywords.None, AnimationFillStyle.None); diff --git a/src/AngleSharp.Css/CssDefaultStyleSheetProvider.cs b/src/AngleSharp.Css/CssDefaultStyleSheetProvider.cs index 1878a78..f52d5da 100644 --- a/src/AngleSharp.Css/CssDefaultStyleSheetProvider.cs +++ b/src/AngleSharp.Css/CssDefaultStyleSheetProvider.cs @@ -97,7 +97,7 @@ private static ICssStyleSheet Parse(String source) s, strike, del { text-decoration: line-through } hr { border: 1px inset } ol, ul, dir, -menu, dd { margin-left: 40px } +menu, dd { margin-left: 40px; list-style-type: disc; list-style-position: outside } ol { list-style-type: decimal } ol ul, ul ol, ul ul, ol ol { margin-top: 0; margin-bottom: 0 } diff --git a/src/AngleSharp.Css/Declarations/OverflowDeclaration.cs b/src/AngleSharp.Css/Declarations/OverflowDeclaration.cs index d481896..6fce42c 100644 --- a/src/AngleSharp.Css/Declarations/OverflowDeclaration.cs +++ b/src/AngleSharp.Css/Declarations/OverflowDeclaration.cs @@ -1,6 +1,10 @@ +#nullable disable namespace AngleSharp.Css.Declarations { + using AngleSharp.Css.Converters; using AngleSharp.Css.Dom; + using AngleSharp.Css.Values; + using AngleSharp.Text; using System; using static ValueConverters; @@ -8,10 +12,48 @@ static class OverflowDeclaration { public static String Name = PropertyNames.Overflow; - public static IValueConverter Converter = OverflowModeConverter; + public static String[] Longhands = new[] + { + PropertyNames.OverflowX, + PropertyNames.OverflowY, + }; + + public static IValueConverter Converter = new OverflowAggregator(); public static ICssValue InitialValue = InitialValues.OverflowDecl; - public static PropertyFlags Flags = PropertyFlags.None; + public static PropertyFlags Flags = PropertyFlags.Shorthand; + + sealed class OverflowAggregator : IValueAggregator, IValueConverter + { + private static readonly IValueConverter converter = OverflowExtendedModeConverter.Many(1, 2); + + public ICssValue Convert(StringSource source) => converter.Convert(source); + + public ICssValue Merge(ICssValue[] values) + { + var x = values[0]; + var y = values[1]; + + if (x != null && y != null) + { + return x.Equals(y) ? new CssTupleValue(new[] { x }) : new CssTupleValue(new[] { x, y }); + } + + return null; + } + + public ICssValue[] Split(ICssValue value) + { + if (value is CssTupleValue tuple) + { + var first = tuple.Items[0]; + var second = tuple.Items.Length > 1 ? tuple.Items[1] : first; + return new[] { first, second }; + } + + return null; + } + } } } diff --git a/src/AngleSharp.Css/Declarations/OverflowXDeclaration.cs b/src/AngleSharp.Css/Declarations/OverflowXDeclaration.cs index ec680aa..2b47f7d 100644 --- a/src/AngleSharp.Css/Declarations/OverflowXDeclaration.cs +++ b/src/AngleSharp.Css/Declarations/OverflowXDeclaration.cs @@ -8,6 +8,11 @@ static class OverflowXDeclaration { public static String Name = PropertyNames.OverflowX; + public static String[] Shorthands = new[] + { + PropertyNames.Overflow, + }; + public static IValueConverter Converter = OverflowExtendedModeConverter; public static ICssValue InitialValue = InitialValues.OverflowDecl; diff --git a/src/AngleSharp.Css/Declarations/OverflowYDeclaration.cs b/src/AngleSharp.Css/Declarations/OverflowYDeclaration.cs index 214058d..d7ce345 100644 --- a/src/AngleSharp.Css/Declarations/OverflowYDeclaration.cs +++ b/src/AngleSharp.Css/Declarations/OverflowYDeclaration.cs @@ -8,6 +8,11 @@ static class OverflowYDeclaration { public static String Name = PropertyNames.OverflowY; + public static String[] Shorthands = new[] + { + PropertyNames.Overflow, + }; + public static IValueConverter Converter = OverflowExtendedModeConverter; public static ICssValue InitialValue = InitialValues.OverflowDecl; diff --git a/src/AngleSharp.Css/Dom/Internal/CssProperty.cs b/src/AngleSharp.Css/Dom/Internal/CssProperty.cs index 3a1413a..f9241ba 100644 --- a/src/AngleSharp.Css/Dom/Internal/CssProperty.cs +++ b/src/AngleSharp.Css/Dom/Internal/CssProperty.cs @@ -128,7 +128,7 @@ public ICssProperty Compute(ICssComputeContext context) #region Compute Context - sealed class PropertyComputeContext : ICssComputeContext + sealed class PropertyComputeContext : ICssComputeContext, ILocalComputeContext { private readonly ICssComputeContext _parent; private readonly IValueConverter _converter; @@ -141,6 +141,8 @@ public PropertyComputeContext(ICssComputeContext parent, IValueConverter convert public IRenderDevice Device => _parent.Device; + public ICssProperties Properties => _parent is ILocalComputeContext local ? local.Properties : null; + public IBrowsingContext Context => _parent.Context; public IValueConverter Converter => _converter; diff --git a/src/AngleSharp.Css/Factories/DefaultDeclarationFactory.cs b/src/AngleSharp.Css/Factories/DefaultDeclarationFactory.cs index 7a168ad..ee1d770 100644 --- a/src/AngleSharp.Css/Factories/DefaultDeclarationFactory.cs +++ b/src/AngleSharp.Css/Factories/DefaultDeclarationFactory.cs @@ -258,21 +258,24 @@ public class DefaultDeclarationFactory : IDeclarationFactory name: OverflowDeclaration.Name, converter: OverflowDeclaration.Converter, initialValue: OverflowDeclaration.InitialValue, - flags: OverflowDeclaration.Flags) + flags: OverflowDeclaration.Flags, + longhands: OverflowDeclaration.Longhands) }, { OverflowXDeclaration.Name, new DeclarationInfo( name: OverflowXDeclaration.Name, converter: OverflowXDeclaration.Converter, initialValue: OverflowXDeclaration.InitialValue, - flags: OverflowXDeclaration.Flags) + flags: OverflowXDeclaration.Flags, + shorthands: OverflowXDeclaration.Shorthands) }, { OverflowYDeclaration.Name, new DeclarationInfo( name: OverflowYDeclaration.Name, converter: OverflowYDeclaration.Converter, initialValue: OverflowYDeclaration.InitialValue, - flags: OverflowYDeclaration.Flags) + flags: OverflowYDeclaration.Flags, + shorthands: OverflowYDeclaration.Shorthands) }, { PositionDeclaration.Name, new DeclarationInfo( diff --git a/src/AngleSharp.Css/Values/Composites/CssBorderRadiusValue.cs b/src/AngleSharp.Css/Values/Composites/CssBorderRadiusValue.cs index 0947a39..794ff34 100644 --- a/src/AngleSharp.Css/Values/Composites/CssBorderRadiusValue.cs +++ b/src/AngleSharp.Css/Values/Composites/CssBorderRadiusValue.cs @@ -89,11 +89,55 @@ public Boolean Equals(CssBorderRadiusValue other) ICssValue ICssValue.Compute(ICssComputeContext context) { - var h = ((ICssValue)_horizontal).Compute(context); - var v = ((ICssValue)_vertical).Compute(context); + var h = ComputePeriodic(_horizontal, context, RenderMode.Horizontal); + var v = ComputePeriodic(_vertical, context, RenderMode.Vertical); return new CssBorderRadiusValue((CssPeriodicValue)h, (CssPeriodicValue)v); } + private static CssPeriodicValue ComputePeriodic(CssPeriodicValue value, ICssComputeContext context, RenderMode mode) => + new CssPeriodicValue(new[] + { + ComputeLength(value.Top, context, mode), + ComputeLength(value.Right, context, mode), + ComputeLength(value.Bottom, context, mode), + ComputeLength(value.Left, context, mode), + }); + + private static ICssValue ComputeLength(ICssValue value, ICssComputeContext context, RenderMode mode) + { + if (value is CssLengthValue length && length.Type == CssLengthValue.Unit.Percent) + { + return new CssLengthValue(length.Value * 0.01 * GetDimension(context, mode), CssLengthValue.Unit.Px); + } + + if (value is CssPercentageValue percentage) + { + var dimension = GetDimension(context, mode); + return new CssLengthValue(percentage.Value * 0.01 * dimension, CssLengthValue.Unit.Px); + } + + return value.Compute(context); + } + + private static Double GetDimension(ICssComputeContext context, RenderMode mode) + { + var name = mode == RenderMode.Horizontal ? PropertyNames.Width : PropertyNames.Height; + var properties = (context as ILocalComputeContext)?.Properties; + var property = properties?.GetProperty(name); + + if (property?.RawValue is CssLengthValue length && length.Type == CssLengthValue.Unit.Px) + { + return length.Value; + } + + if (CssLengthValue.TryParse(properties?.GetPropertyValue(name), out var parsed)) + { + return parsed.ToPixel(context.Device); + } + + return mode == RenderMode.Horizontal ? context.Device.RenderWidth : context.Device.RenderHeight; + } + #endregion } } diff --git a/src/AngleSharp.Css/Values/CssComputeContext.cs b/src/AngleSharp.Css/Values/CssComputeContext.cs index d2e7255..3d0ea93 100644 --- a/src/AngleSharp.Css/Values/CssComputeContext.cs +++ b/src/AngleSharp.Css/Values/CssComputeContext.cs @@ -4,7 +4,7 @@ namespace AngleSharp.Css.Values using System; using System.Linq; - sealed class CssComputeContext : ICssComputeContext + sealed class CssComputeContext : ICssComputeContext, ILocalComputeContext { private readonly IRenderDevice _device; private readonly IBrowsingContext? _context; @@ -17,8 +17,11 @@ public CssComputeContext(IRenderDevice device, IBrowsingContext? context, ICssPr _context = context; _variables = new CssCustomPropertyResolver(properties, parent); _parent = parent; + Properties = properties; } + public ICssProperties Properties { get; } + public IRenderDevice Device => _device; public IBrowsingContext? Context => _context; diff --git a/src/AngleSharp.Css/Values/ILocalComputeContext.cs b/src/AngleSharp.Css/Values/ILocalComputeContext.cs new file mode 100644 index 0000000..acbcca4 --- /dev/null +++ b/src/AngleSharp.Css/Values/ILocalComputeContext.cs @@ -0,0 +1,8 @@ +namespace AngleSharp.Css.Values; + +using AngleSharp.Css.Dom; + +interface ILocalComputeContext +{ + ICssProperties Properties { get; } +} diff --git a/src/AngleSharp.Css/Values/Multiples/CssRadiusValue.cs b/src/AngleSharp.Css/Values/Multiples/CssRadiusValue.cs index 29fa8fa..5fcf9aa 100644 --- a/src/AngleSharp.Css/Values/Multiples/CssRadiusValue.cs +++ b/src/AngleSharp.Css/Values/Multiples/CssRadiusValue.cs @@ -6,7 +6,6 @@ namespace AngleSharp.Css.Values using System; using System.Collections; using System.Collections.Generic; - using System.Linq; /// /// Represents a periodic CSS value. @@ -127,10 +126,51 @@ IEnumerator IEnumerable.GetEnumerator() ICssValue ICssValue.Compute(ICssComputeContext context) { - var values = _values.Select(v => (T)v.Compute(context)).ToArray(); + var values = new T[_values.Length]; + + for (var i = 0; i < _values.Length; i++) + { + var mode = i == 1 ? RenderMode.Vertical : RenderMode.Horizontal; + values[i] = (T)ComputeValue(_values[i], context, mode); + } + return new CssRadiusValue(values); } + private static ICssValue ComputeValue(ICssValue value, ICssComputeContext context, RenderMode mode) + { + if (value is CssLengthValue length && length.Type == CssLengthValue.Unit.Percent) + { + return new CssLengthValue(length.Value * 0.01 * GetDimension(context, mode), CssLengthValue.Unit.Px); + } + + if (value is CssPercentageValue percentage) + { + return new CssLengthValue(percentage.Value * 0.01 * GetDimension(context, mode), CssLengthValue.Unit.Px); + } + + return value.Compute(context); + } + + private static Double GetDimension(ICssComputeContext context, RenderMode mode) + { + var name = mode == RenderMode.Horizontal ? PropertyNames.Width : PropertyNames.Height; + var properties = (context as ILocalComputeContext)?.Properties; + var property = properties?.GetProperty(name); + + if (property?.RawValue is CssLengthValue length && length.Type == CssLengthValue.Unit.Px) + { + return length.Value; + } + + if (CssLengthValue.TryParse(properties?.GetPropertyValue(name), out var parsed)) + { + return parsed.ToPixel(context.Device); + } + + return mode == RenderMode.Horizontal ? context.Device.RenderWidth : context.Device.RenderHeight; + } + Boolean IEquatable.Equals(ICssValue other) => other is CssRadiusValue value && Equals(value); #endregion diff --git a/src/AngleSharp.Css/Values/Primitives/CssTimeValue.cs b/src/AngleSharp.Css/Values/Primitives/CssTimeValue.cs index ec90bb8..06070ea 100644 --- a/src/AngleSharp.Css/Values/Primitives/CssTimeValue.cs +++ b/src/AngleSharp.Css/Values/Primitives/CssTimeValue.cs @@ -127,7 +127,7 @@ public String UnitString ICssValue ICssValue.Compute(ICssComputeContext context) { - if (_unit != Unit.Ms) + if (_unit != Unit.Ms && _value != 0.0) { var ms = ToMilliseconds(); return new CssTimeValue(ms, Unit.Ms); From f443118b80e382e1d652e2c52717859ebb9e58db Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Thu, 10 Sep 2026 08:46:54 +0200 Subject: [PATCH 7/9] Improved overflow and animation --- .../Declarations/OverflowDeclaration.cs | 2 +- .../Dom/Internal/CssStyleDeclaration.cs | 43 +++++++++++++++++++ .../Extensions/CssOmExtensions.cs | 28 +++++++++++- src/AngleSharp.Css/PropertyFlags.cs | 6 ++- 4 files changed, 75 insertions(+), 4 deletions(-) diff --git a/src/AngleSharp.Css/Declarations/OverflowDeclaration.cs b/src/AngleSharp.Css/Declarations/OverflowDeclaration.cs index 6fce42c..5936070 100644 --- a/src/AngleSharp.Css/Declarations/OverflowDeclaration.cs +++ b/src/AngleSharp.Css/Declarations/OverflowDeclaration.cs @@ -22,7 +22,7 @@ static class OverflowDeclaration public static ICssValue InitialValue = InitialValues.OverflowDecl; - public static PropertyFlags Flags = PropertyFlags.Shorthand; + public static PropertyFlags Flags = PropertyFlags.Shorthand | PropertyFlags.PreserveShorthand; sealed class OverflowAggregator : IValueAggregator, IValueConverter { diff --git a/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs b/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs index bc44cbb..b264d3b 100644 --- a/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs +++ b/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs @@ -91,6 +91,13 @@ public ICssProperty GetProperty(String name) } } + var related = GetPropertyFromShorthand(name); + + if (related is not null) + { + return related; + } + return GetPropertyShorthand(name); } @@ -508,6 +515,12 @@ private void SetLonghand(ICssProperty property) private void SetShorthand(ICssProperty shorthand) { + if ((_context.GetDeclarationInfo(shorthand.Name).Flags & PropertyFlags.PreserveShorthand) == PropertyFlags.PreserveShorthand) + { + SetLonghand(shorthand); + return; + } + var properties = _context.CreateLonghands(shorthand); if (properties is not null) @@ -519,6 +532,36 @@ private void SetShorthand(ICssProperty shorthand) } } + private ICssProperty GetPropertyFromShorthand(String name) + { + var info = _context.GetDeclarationInfo(name); + var factory = _context.GetFactory(); + + foreach (var shorthandName in info.Shorthands) + { + if (_declarationIndex.TryGetValue(shorthandName, out var index) && index < _declarations.Count) + { + var shorthand = _declarations[index]; + + var shorthandInfo = factory.Create(shorthandName); + var rawValue = shorthand.RawValue ?? shorthandInfo.Converter.Convert(new StringSource(shorthand.Value)); + + if (rawValue is not null) + { + var values = shorthandInfo.Expand(factory, rawValue); + var longhandIndex = Array.IndexOf(shorthandInfo.Longhands, name); + + if (values is not null && longhandIndex >= 0 && longhandIndex < values.Length) + { + return new CssProperty(name, info.Converter, info.Flags, values[longhandIndex], shorthand.IsImportant); + } + } + } + } + + return null; + } + private void RebuildIndex() { _declarationIndex.Clear(); diff --git a/src/AngleSharp.Css/Extensions/CssOmExtensions.cs b/src/AngleSharp.Css/Extensions/CssOmExtensions.cs index c57b256..a19a899 100644 --- a/src/AngleSharp.Css/Extensions/CssOmExtensions.cs +++ b/src/AngleSharp.Css/Extensions/CssOmExtensions.cs @@ -3,9 +3,9 @@ namespace AngleSharp.Css.Dom { using AngleSharp.Css.Converters; using AngleSharp.Css.Parser; + using AngleSharp.Text; using AngleSharp.Css.Values; using AngleSharp.Dom; - using AngleSharp.Text; using System; using System.Linq; @@ -96,8 +96,11 @@ public static ICssStyleDeclaration Compute(this ICssStyleDeclaration style, ICss var computed = property.Compute(context); var substitutedKeyword = property.RawValue is not ICssSpecialValue && computed.RawValue is ICssSpecialValue; + var info = context.Context.GetDeclarationInfo(property.Name); + var initialValue = computed.RawValue is CssInitialValue || + (info.Shorthands.Length > 0 && computed.Value.Isi(CssKeywords.Initial)); - if ((computed.RawValue is null || substitutedKeyword) && property.RawValue is not null && property is CssProperty cssProperty) + if ((computed.RawValue is null || substitutedKeyword || initialValue) && property.RawValue is not null && property is CssProperty cssProperty) { var inherit = computed.RawValue is CssInheritValue || (computed.RawValue is not CssInitialValue && property.CanBeInherited); @@ -111,6 +114,27 @@ public static ICssStyleDeclaration Compute(this ICssStyleDeclaration style, ICss computedStyle.AddProperty(computed); } + var factory = context.Context.GetFactory(); + var preservedShorthands = style + .Where(property => (factory.Create(property.Name).Flags & PropertyFlags.PreserveShorthand) != 0) + .ToArray(); + + foreach (var shorthand in preservedShorthands) + { + var info = factory.Create(shorthand.Name); + var rawValue = shorthand.RawValue ?? info.Converter.Convert(new StringSource(shorthand.Value)); + var values = rawValue is null ? null : info.Expand(factory, rawValue); + + if (values is not null) + { + for (var i = 0; i < info.Longhands.Length; i++) + { + var longhand = factory.Create(info.Longhands[i]); + computedStyle.AddProperty(new CssProperty(info.Longhands[i], longhand.Converter, longhand.Flags, values[i], shorthand.IsImportant)); + } + } + } + return computedStyle; } diff --git a/src/AngleSharp.Css/PropertyFlags.cs b/src/AngleSharp.Css/PropertyFlags.cs index b99d694..c35ce0f 100644 --- a/src/AngleSharp.Css/PropertyFlags.cs +++ b/src/AngleSharp.Css/PropertyFlags.cs @@ -37,6 +37,10 @@ public enum PropertyFlags : byte /// /// The property is not known. /// - Unknown = 0x20 + Unknown = 0x20, + /// + /// The authored shorthand is retained instead of being expanded immediately. + /// + PreserveShorthand = 0x40 } } From 92ee9b1c20a3d6009a151f9145c921198b44431c Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Thu, 10 Sep 2026 09:46:08 +0200 Subject: [PATCH 8/9] Added filter declaration --- .../Styling/FilterPropertyTests.cs | 38 +++++---- src/AngleSharp.Css/Constants/InitialValues.cs | 1 + .../Declarations/FilterDeclaration.cs | 17 ++++ .../Factories/DefaultDeclarationFactory.cs | 7 ++ .../Parser/Micro/FilterParser.cs | 78 +++++++++++++++++++ src/AngleSharp.Css/ValueConverters.cs | 7 ++ .../Values/Composites/CssFilterValue.cs | 36 +++++++++ .../Functions/CssFilterFunctionValue.cs | 46 +++++++++++ .../Values/ICssFilterFunctionValue.cs | 9 +++ 9 files changed, 223 insertions(+), 16 deletions(-) create mode 100644 src/AngleSharp.Css/Declarations/FilterDeclaration.cs create mode 100644 src/AngleSharp.Css/Parser/Micro/FilterParser.cs create mode 100644 src/AngleSharp.Css/Values/Composites/CssFilterValue.cs create mode 100644 src/AngleSharp.Css/Values/Functions/CssFilterFunctionValue.cs create mode 100644 src/AngleSharp.Css/Values/ICssFilterFunctionValue.cs diff --git a/src/AngleSharp.Css.Tests/Styling/FilterPropertyTests.cs b/src/AngleSharp.Css.Tests/Styling/FilterPropertyTests.cs index 15c1be3..ad3d7cb 100644 --- a/src/AngleSharp.Css.Tests/Styling/FilterPropertyTests.cs +++ b/src/AngleSharp.Css.Tests/Styling/FilterPropertyTests.cs @@ -1,36 +1,42 @@ #nullable disable namespace AngleSharp.Css.Tests.Styling { + using AngleSharp.Css.Dom; using AngleSharp.Dom; + using AngleSharp.Css.Values; + using AngleSharp.Html.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. + /// Verifies that filter function lists are parsed into structured values and preserved by + /// computed style, while the original inline style remains available through the DOM. /// [TestFixture] public class FilterPropertyTests { [Test] - public void FilterComputedValueIsAlwaysEmptyRegardlessOfWhatWasAuthored() + public void FilterComputedValuePreservesAuthoredFunctions() { var document = ParseDocument("
"); var target = document.GetElementById("target"); - Assert.AreEqual(string.Empty, target.ComputeCurrentStyle().GetPropertyValue("filter")); + Assert.AreEqual("grayscale(0.9) blur(2px)", target.ComputeCurrentStyle().GetPropertyValue("filter")); + } + + [Test] + public void FilterFunctionsExposeNamesAndArguments() + { + var document = ParseDocument("
"); + var target = document.GetElementById("target"); + var value = target.GetStyle().GetProperty("filter").RawValue as CssFilterValue; + + Assert.IsNotNull(value); + Assert.AreEqual(2, value.Functions.Length); + Assert.AreEqual("grayscale", value.Functions[0].Name); + Assert.AreEqual("0.9", value.Functions[0].Arguments[0].CssText); + Assert.AreEqual("blur", value.Functions[1].Name); + Assert.AreEqual("2px", value.Functions[1].Arguments[0].CssText); } [Test] diff --git a/src/AngleSharp.Css/Constants/InitialValues.cs b/src/AngleSharp.Css/Constants/InitialValues.cs index aabc8ed..a6d9b59 100644 --- a/src/AngleSharp.Css/Constants/InitialValues.cs +++ b/src/AngleSharp.Css/Constants/InitialValues.cs @@ -59,6 +59,7 @@ static class InitialValues public static readonly ICssValue ForcedColorAdjustDecl = new CssConstantValue(CssKeywords.Auto, null); public static readonly ICssValue PrintColorAdjustDecl = new CssConstantValue(CssKeywords.Auto, null); public static readonly ICssValue BackdropFilterDecl = new CssConstantValue(CssKeywords.None, null); + public static readonly ICssValue FilterDecl = new CssConstantValue(CssKeywords.None, null); public static readonly ICssValue MixBlendModeDecl = new CssConstantValue(CssKeywords.Normal, null); public static readonly ICssValue BackgroundBlendModeDecl = new CssConstantValue(CssKeywords.Normal, null); public static readonly ICssValue IsolationDecl = new CssConstantValue(CssKeywords.Auto, null); diff --git a/src/AngleSharp.Css/Declarations/FilterDeclaration.cs b/src/AngleSharp.Css/Declarations/FilterDeclaration.cs new file mode 100644 index 0000000..fdb3fb5 --- /dev/null +++ b/src/AngleSharp.Css/Declarations/FilterDeclaration.cs @@ -0,0 +1,17 @@ +namespace AngleSharp.Css.Declarations +{ + using AngleSharp.Css.Dom; + using System; + using static ValueConverters; + + static class FilterDeclaration + { + public static String Name = PropertyNames.Filter; + + public static IValueConverter Converter = FilterConverter; + + public static ICssValue InitialValue = InitialValues.FilterDecl; + + public static PropertyFlags Flags = PropertyFlags.None; + } +} \ No newline at end of file diff --git a/src/AngleSharp.Css/Factories/DefaultDeclarationFactory.cs b/src/AngleSharp.Css/Factories/DefaultDeclarationFactory.cs index ee1d770..ceb185d 100644 --- a/src/AngleSharp.Css/Factories/DefaultDeclarationFactory.cs +++ b/src/AngleSharp.Css/Factories/DefaultDeclarationFactory.cs @@ -253,6 +253,13 @@ public class DefaultDeclarationFactory : IDeclarationFactory initialValue: ForcedColorAdjustDeclaration.InitialValue, flags: ForcedColorAdjustDeclaration.Flags) }, + { + FilterDeclaration.Name, new DeclarationInfo( + name: FilterDeclaration.Name, + converter: FilterDeclaration.Converter, + initialValue: FilterDeclaration.InitialValue, + flags: FilterDeclaration.Flags) + }, { OverflowDeclaration.Name, new DeclarationInfo( name: OverflowDeclaration.Name, diff --git a/src/AngleSharp.Css/Parser/Micro/FilterParser.cs b/src/AngleSharp.Css/Parser/Micro/FilterParser.cs new file mode 100644 index 0000000..7a528d4 --- /dev/null +++ b/src/AngleSharp.Css/Parser/Micro/FilterParser.cs @@ -0,0 +1,78 @@ +#nullable disable +namespace AngleSharp.Css.Parser +{ + using AngleSharp.Css.Dom; + using AngleSharp.Css.Values; + using AngleSharp.Text; + using System; + using System.Collections.Generic; + + /// + /// Parses CSS filter function lists. + /// + public static class FilterParser + { + /// + /// Parses a space-separated list of CSS filter functions. + /// + /// The source to parse. + /// The parsed filter value, if valid. + public static ICssValue ParseFilter(StringSource source) + { + var start = source.Index; + var functions = new List(); + + while (!source.IsDone) + { + source.SkipSpacesAndComments(); + var functionStart = source.Index; + var name = source.ParseIdent(); + + if (name is null || source.Current != Symbols.RoundBracketOpen) + { + source.BackTo(start); + return null; + } + + var openIndex = source.Index; + var depth = 1; + var closeIndex = openIndex; + + for (var i = openIndex + 1; i < source.Content.Length; i++) + { + var current = source.Content[i]; + + if (current == Symbols.RoundBracketOpen) + { + depth++; + } + else if (current == Symbols.RoundBracketClose) + { + depth--; + + if (depth == 0) + { + closeIndex = i; + break; + } + } + } + + if (depth != 0) + { + source.BackTo(start); + return null; + } + + var text = source.Content.Substring(functionStart, closeIndex - functionStart + 1); + source.NextTo(closeIndex + 1); + source.SkipSpacesAndComments(); + var argumentStart = text.IndexOf('(') + 1; + var argumentText = text.Substring(argumentStart, text.Length - argumentStart - 1).Trim(); + functions.Add(new CssFilterFunctionValue(name, String.IsNullOrEmpty(argumentText) ? Array.Empty() : new ICssValue[] { new CssAnyValue(argumentText, true) }, text)); + } + + return functions.Count > 0 ? new CssFilterValue(functions.ToArray()) : null; + } + } +} diff --git a/src/AngleSharp.Css/ValueConverters.cs b/src/AngleSharp.Css/ValueConverters.cs index 8fb2a46..987d7a8 100644 --- a/src/AngleSharp.Css/ValueConverters.cs +++ b/src/AngleSharp.Css/ValueConverters.cs @@ -283,6 +283,13 @@ static class ValueConverters /// public static readonly IValueConverter BackdropFilterConverter = Assign(CssKeywords.None, CssKeywords.None); + /// + /// Represents a filter function list or the none keyword. + /// + public static readonly IValueConverter FilterConverter = Or( + Assign(CssKeywords.None, CssKeywords.None), + FromParser(FilterParser.ParseFilter)); + /// /// Represents a converter for blend mode values (mix-blend-mode, background-blend-mode). /// diff --git a/src/AngleSharp.Css/Values/Composites/CssFilterValue.cs b/src/AngleSharp.Css/Values/Composites/CssFilterValue.cs new file mode 100644 index 0000000..a76b1cb --- /dev/null +++ b/src/AngleSharp.Css/Values/Composites/CssFilterValue.cs @@ -0,0 +1,36 @@ +#nullable disable +namespace AngleSharp.Css.Values +{ + using AngleSharp.Css.Dom; + using System; + using System.Linq; + + /// + /// Represents a list of CSS filter functions. + /// + public sealed class CssFilterValue : ICssValue, IEquatable + { + /// + /// Creates a filter value from its functions. + /// + /// The filter functions. + public CssFilterValue(ICssFilterFunctionValue[] functions) + { + Functions = functions ?? Array.Empty(); + } + + /// Gets the filter functions. + public ICssFilterFunctionValue[] Functions { get; } + + /// Gets the serialized filter value. + public String CssText => String.Join(" ", Functions.Select(function => function.CssText)); + + /// Compares this filter value with another filter value. + public Boolean Equals(CssFilterValue other) => + other is not null && CssText.Equals(other.CssText, StringComparison.OrdinalIgnoreCase); + + ICssValue ICssValue.Compute(ICssComputeContext context) => this; + + Boolean IEquatable.Equals(ICssValue other) => other is CssFilterValue value && Equals(value); + } +} diff --git a/src/AngleSharp.Css/Values/Functions/CssFilterFunctionValue.cs b/src/AngleSharp.Css/Values/Functions/CssFilterFunctionValue.cs new file mode 100644 index 0000000..afb579e --- /dev/null +++ b/src/AngleSharp.Css/Values/Functions/CssFilterFunctionValue.cs @@ -0,0 +1,46 @@ +#nullable disable +namespace AngleSharp.Css.Values +{ + using AngleSharp.Css.Dom; + using System; + + /// + /// Represents a CSS filter function. + /// + public sealed class CssFilterFunctionValue : ICssFilterFunctionValue, IEquatable + { + private readonly String _name; + private readonly ICssValue[] _arguments; + private readonly String _cssText; + + /// + /// Creates a filter function value. + /// + /// The function name. + /// The function arguments. + /// The serialized function. + public CssFilterFunctionValue(String name, ICssValue[] arguments, String cssText) + { + _name = name; + _arguments = arguments ?? Array.Empty(); + _cssText = cssText; + } + + /// Gets the function name. + public String Name => _name; + + /// Gets the function arguments. + public ICssValue[] Arguments => _arguments; + + /// Gets the serialized function. + public String CssText => _cssText; + + /// Compares this function with another function. + public Boolean Equals(CssFilterFunctionValue other) => + other is not null && String.Equals(_cssText, other._cssText, StringComparison.OrdinalIgnoreCase); + + ICssValue ICssValue.Compute(ICssComputeContext context) => this; + + Boolean IEquatable.Equals(ICssValue other) => other is CssFilterFunctionValue value && Equals(value); + } +} diff --git a/src/AngleSharp.Css/Values/ICssFilterFunctionValue.cs b/src/AngleSharp.Css/Values/ICssFilterFunctionValue.cs new file mode 100644 index 0000000..0a60203 --- /dev/null +++ b/src/AngleSharp.Css/Values/ICssFilterFunctionValue.cs @@ -0,0 +1,9 @@ +namespace AngleSharp.Css.Values +{ + /// + /// Represents a CSS filter function such as blur() or grayscale(). + /// + public interface ICssFilterFunctionValue : ICssFunctionValue + { + } +} From b5011abad8b14859cae862ddbd3d4d192b6aa9c0 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Thu, 10 Sep 2026 09:46:33 +0200 Subject: [PATCH 9/9] Fixed issue with overflow shorthand order --- CHANGELOG.md | 1 + .../Styling/OverflowComputedStyleTests.cs | 29 +++++++++++++++++++ .../Extensions/CssOmExtensions.cs | 5 ++++ 3 files changed, 35 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3565eb..b7e7ac5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Released on Wednesday, September 9 2026 - Fixed default list styling for unordered lists - Added shorthand decomposition and `clip` support for `overflow` - Added explicit pseudo class handling via `SetPseudoClass` +- Added support for parsing `filter` declarations # 1.1.0 diff --git a/src/AngleSharp.Css.Tests/Styling/OverflowComputedStyleTests.cs b/src/AngleSharp.Css.Tests/Styling/OverflowComputedStyleTests.cs index 821b65c..2919b9c 100644 --- a/src/AngleSharp.Css.Tests/Styling/OverflowComputedStyleTests.cs +++ b/src/AngleSharp.Css.Tests/Styling/OverflowComputedStyleTests.cs @@ -44,5 +44,34 @@ public void OverflowClipIsRecognizedAndComputed() Assert.AreEqual("clip", target.ComputeCurrentStyle().GetPropertyValue("overflow")); } + + [Test] + public void ExplicitLonghandAuthoredAfterTheShorthandOverridesItsComponent() + { + // A newly confirmed gap, found once the two gaps above were fixed: `overflow-y` + // written *after* the `overflow` shorthand in the same declaration block should win + // for that axis (the ordinary "later declaration of the same effective property wins" + // cascade rule), but the shorthand's own component always wins instead, regardless of + // declaration order (confirmed with both orderings below) - the shorthand's expansion + // into longhands appears to be applied unconditionally rather than only when that + // longhand was not otherwise explicitly set. + var document = ParseDocument("
"); + var target = document.GetElementById("target"); + + Assert.AreEqual("visible", target.ComputeCurrentStyle().GetPropertyValue("overflow-y")); + } + + [Test] + public void ExplicitLonghandAuthoredBeforeTheShorthandStillWinsForThatAxis() + { + // The shorthand comes textually *after* the longhand here - if the shorthand's + // expansion is unconditionally overwriting rather than declaration-order-aware, this + // ordering fails identically to the reverse ordering above (confirmed: it does). + var document = ParseDocument("
"); + var target = document.GetElementById("target"); + + Assert.AreEqual("hidden", target.ComputeCurrentStyle().GetPropertyValue("overflow-x"), "the horizontal axis is untouched by the explicit overflow-y override and should still pick up hidden from the shorthand."); + Assert.AreEqual("visible", target.ComputeCurrentStyle().GetPropertyValue("overflow-y")); + } } } diff --git a/src/AngleSharp.Css/Extensions/CssOmExtensions.cs b/src/AngleSharp.Css/Extensions/CssOmExtensions.cs index a19a899..5fe6585 100644 --- a/src/AngleSharp.Css/Extensions/CssOmExtensions.cs +++ b/src/AngleSharp.Css/Extensions/CssOmExtensions.cs @@ -129,6 +129,11 @@ public static ICssStyleDeclaration Compute(this ICssStyleDeclaration style, ICss { for (var i = 0; i < info.Longhands.Length; i++) { + if (style.Any(property => property.Name.Is(info.Longhands[i]))) + { + continue; + } + var longhand = factory.Create(info.Longhands[i]); computedStyle.AddProperty(new CssProperty(info.Longhands[i], longhand.Converter, longhand.Flags, values[i], shorthand.IsImportant)); }