diff --git a/CHANGELOG.md b/CHANGELOG.md index e009b69b..b7e7ac58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +# 1.1.1 + +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` +- Added support for parsing `filter` declarations + # 1.1.0 Released on Saturday, September 5 2026 diff --git a/docs/general/04-Core-Interfaces.md b/docs/general/04-Core-Interfaces.md index b569ccaa..aa0264c1 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 73dc5a4a..d1ba3e60 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 5edffa79..69a0fca6 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.Docs/package.json b/src/AngleSharp.Css.Docs/package.json index 9e402773..32de862e 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/AngleSharp.Css.Tests/Extensions/Elements.cs b/src/AngleSharp.Css.Tests/Extensions/Elements.cs index f34bc68d..3c563f93 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 00000000..b07987ec --- /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 00000000..cdeceba8 --- /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 00000000..ad3d7cb7 --- /dev/null +++ b/src/AngleSharp.Css.Tests/Styling/FilterPropertyTests.cs @@ -0,0 +1,54 @@ +#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; + + /// + /// 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 FilterComputedValuePreservesAuthoredFunctions() + { + var document = ParseDocument("
"); + var target = document.GetElementById("target"); + + 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] + 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/HoverPseudoClass.cs b/src/AngleSharp.Css.Tests/Styling/HoverPseudoClass.cs new file mode 100644 index 00000000..53a327be --- /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/IndividualTransformComputation.cs b/src/AngleSharp.Css.Tests/Styling/IndividualTransformComputation.cs new file mode 100644 index 00000000..0c4a6319 --- /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/Styling/ListStyleComputedValueTests.cs b/src/AngleSharp.Css.Tests/Styling/ListStyleComputedValueTests.cs new file mode 100644 index 00000000..d0846468 --- /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 00000000..2919b9c1 --- /dev/null +++ b/src/AngleSharp.Css.Tests/Styling/OverflowComputedStyleTests.cs @@ -0,0 +1,77 @@ +#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")); + } + + [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.Tests/Styling/PseudoClassForcing.cs b/src/AngleSharp.Css.Tests/Styling/PseudoClassForcing.cs new file mode 100644 index 00000000..0aa2d58e --- /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.Tests/Values/AnyValueComputation.cs b/src/AngleSharp.Css.Tests/Values/AnyValueComputation.cs new file mode 100644 index 00000000..d41b6ee1 --- /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.Tests/Values/TransformFunctions.cs b/src/AngleSharp.Css.Tests/Values/TransformFunctions.cs new file mode 100644 index 00000000..c65c5c8f --- /dev/null +++ b/src/AngleSharp.Css.Tests/Values/TransformFunctions.cs @@ -0,0 +1,153 @@ +#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 PlainSixValueMatrixFunctionDoesNotThrowWhenComputed() + { + // 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/Constants/InitialValues.cs b/src/AngleSharp.Css/Constants/InitialValues.cs index eb808595..a6d9b590 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); @@ -90,7 +91,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/CssConfigurationExtensions.cs b/src/AngleSharp.Css/CssConfigurationExtensions.cs index 09985b45..da30661d 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/CssDefaultStyleSheetProvider.cs b/src/AngleSharp.Css/CssDefaultStyleSheetProvider.cs index 1878a787..f52d5daf 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/FilterDeclaration.cs b/src/AngleSharp.Css/Declarations/FilterDeclaration.cs new file mode 100644 index 00000000..fdb3fb54 --- /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/Declarations/OverflowDeclaration.cs b/src/AngleSharp.Css/Declarations/OverflowDeclaration.cs index d4818969..5936070b 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 | PropertyFlags.PreserveShorthand; + + 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 ec680aab..2b47f7d1 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 214058d2..d7ce345e 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 3a1413a4..f9241baf 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/Dom/Internal/CssStyleDeclaration.cs b/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs index bc44cbb0..b264d3b9 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/Dom/Internal/ForcingPseudoClassSelector.cs b/src/AngleSharp.Css/Dom/Internal/ForcingPseudoClassSelector.cs new file mode 100644 index 00000000..972342d7 --- /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/CssOmExtensions.cs b/src/AngleSharp.Css/Extensions/CssOmExtensions.cs index c57b2564..5fe65851 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,32 @@ 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++) + { + 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)); + } + } + } + return computedStyle; } diff --git a/src/AngleSharp.Css/Extensions/ElementExtensions.cs b/src/AngleSharp.Css/Extensions/ElementExtensions.cs index bf78a5c5..46e6a498 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/DefaultDeclarationFactory.cs b/src/AngleSharp.Css/Factories/DefaultDeclarationFactory.cs index 7a168ad6..ceb185dd 100644 --- a/src/AngleSharp.Css/Factories/DefaultDeclarationFactory.cs +++ b/src/AngleSharp.Css/Factories/DefaultDeclarationFactory.cs @@ -253,26 +253,36 @@ 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, 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/Factories/ForcingPseudoClassSelectorFactory.cs b/src/AngleSharp.Css/Factories/ForcingPseudoClassSelectorFactory.cs new file mode 100644 index 00000000..edd665e0 --- /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/Parser/Micro/FilterParser.cs b/src/AngleSharp.Css/Parser/Micro/FilterParser.cs new file mode 100644 index 00000000..7a528d4b --- /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/PropertyFlags.cs b/src/AngleSharp.Css/PropertyFlags.cs index b99d6946..c35ce0fc 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 } } diff --git a/src/AngleSharp.Css/PseudoClassStateStore.cs b/src/AngleSharp.Css/PseudoClassStateStore.cs new file mode 100644 index 00000000..1dd69ab8 --- /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); + } +} diff --git a/src/AngleSharp.Css/ValueConverters.cs b/src/AngleSharp.Css/ValueConverters.cs index 8fb2a465..987d7a8f 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/CssBorderRadiusValue.cs b/src/AngleSharp.Css/Values/Composites/CssBorderRadiusValue.cs index 0947a390..794ff340 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/Composites/CssFilterValue.cs b/src/AngleSharp.Css/Values/Composites/CssFilterValue.cs new file mode 100644 index 00000000..a76b1cb6 --- /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/CssComputeContext.cs b/src/AngleSharp.Css/Values/CssComputeContext.cs index d2e7255f..3d0ea93f 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/Functions/CssFilterFunctionValue.cs b/src/AngleSharp.Css/Values/Functions/CssFilterFunctionValue.cs new file mode 100644 index 00000000..afb579e2 --- /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/Functions/CssMatrixValue.cs b/src/AngleSharp.Css/Values/Functions/CssMatrixValue.cs index 05646244..767cf4cf 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[1], 0.0, 0.0, + values[2], values[3], 0.0, 0.0, + 0.0, 0.0, 1.0, 0.0, + values[4], values[5], 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 eea99e68..d7d16ad0 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 d29fc867..56b74def 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); } diff --git a/src/AngleSharp.Css/Values/ICssFilterFunctionValue.cs b/src/AngleSharp.Css/Values/ICssFilterFunctionValue.cs new file mode 100644 index 00000000..0a60203b --- /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 + { + } +} diff --git a/src/AngleSharp.Css/Values/ILocalComputeContext.cs b/src/AngleSharp.Css/Values/ILocalComputeContext.cs new file mode 100644 index 00000000..acbcca46 --- /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 29fa8fa6..5fcf9aa6 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 ec90bb84..06070eac 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); diff --git a/src/AngleSharp.Css/Values/Raws/CssAnyValue.cs b/src/AngleSharp.Css/Values/Raws/CssAnyValue.cs index 33418082..e6fd1de9 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; diff --git a/src/Directory.Build.props b/src/Directory.Build.props index bace0b84..60b70462 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