diff --git a/CHANGELOG.md b/CHANGELOG.md index 465f5936..e009b69b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +# 1.1.0 + +Released on Saturday, September 5 2026 + +- Improved evaluation of comma-separated media queries (#230) +- Fixed wrong media feature used for scripting (#233) +- Fixed wrong rientation and scan evaluation (#232) +- Fixed `not ` is always false (#231) +- Fixed `calc()` computations in AoT-compiled applications (#236) @sebastienros +- Fixed usage of `calc()` with unitless scaling (multiplication / division) +- Fixed case-sensitive matching of `and` / `or` in `@supports` and `from` / `to` in `@keyframes` (#240) @meziantou +- Fixed operator associativity and result units in `calc()` (#239) @meziantou +- Fixed unquoted and invalid `url()` handling (#238) @meziantou +- Fixed handling of invalid keyframe selectors +- Fixed stackoverflow due to cyclic CSS variables (#241) +- Added optional CSSOM compliant color seralization (#229) @lahma +- Added user-preference media features to the render device (#235) @lahma +- Added media query list evaluation using `IRenderDevice` (#228) @lahma + # 1.0.2 Released on Friday, August 21 2026. diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 6c9b8b2d..b9fef890 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -26,6 +26,7 @@ AngleSharp.Css contains code written by (in order of first pull request / commit * [MaceWindu](https://github.com/MaceWindu) * [Serhan Apaydın](https://github.com/monoblaine) * [scasteran](https://github.com/scasteran-jw) +* [Sébastien Ros](https://github.com/sebastienros) Without these awesome people AngleSharp.Css could not exist. Thanks to everyone for your contributions! :beers: diff --git a/README.md b/README.md index dcc5e464..ee29e537 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,37 @@ var config = Configuration.Default If no specific `IRenderDevice` (e.g., via creating an `DefaultRenderDevice` object) instance is created a default implementation will be set. +The render device also carries the *user preferences* of the Media Queries Level 5 (and Level 4) user-preference features. `DefaultRenderDevice` implements `IRenderDevicePreferences` for that, and any custom `IRenderDevice` can implement it as well. The dictionary is keyed by the media feature name and holds the keyword that the feature should answer with: + +```cs +var config = Configuration.Default + .WithCss() + .WithRenderDevice(new DefaultRenderDevice + { + Preferences = new Dictionary + { + { "prefers-color-scheme", "dark" }, + { "prefers-reduced-motion", "reduce" }, + }, + }); +``` + +With this device `@media (prefers-color-scheme: dark)` applies in the cascade and `window.MatchMedia("(prefers-color-scheme: dark)").IsMatched` is `true`. A key that is not set leaves its media feature unknown, i.e., a query using it never matches. The keys a browser would set are: + +| Key | Keywords | +| --- | --- | +| `prefers-color-scheme` | `light`, `dark` | +| `prefers-reduced-motion` | `no-preference`, `reduce` | +| `prefers-reduced-transparency` | `no-preference`, `reduce` | +| `prefers-contrast` | `no-preference`, `more`, `less`, `custom` | +| `prefers-reduced-data` | `no-preference`, `reduce` | +| `forced-colors` | `none`, `active` | +| `hover`, `any-hover` | `none`, `hover` | +| `pointer`, `any-pointer` | `none`, `coarse`, `fine` | +| `display-mode` | `fullscreen`, `standalone`, `minimal-ui`, `browser` | + +The value is compared to the queried keyword case insensitively, so a keyword that is newer than this library works as well. Used without a value, e.g., `@media (prefers-reduced-motion)`, the feature evaluates in a boolean context, where `no-preference` (and `none` for `forced-colors`, `hover`, `any-hover`, `pointer` and `any-pointer`) is `false`. Without a preference `hover` and `pointer` keep answering as they did before, i.e., as a device with no input mechanism. + Going a bit further it is possible to `Render` the current document. This render tree information can then be used to retrieve or other information, e.g., ```cs diff --git a/docs/general/02-Values.md b/docs/general/02-Values.md index c208cd27..9be72233 100644 --- a/docs/general/02-Values.md +++ b/docs/general/02-Values.md @@ -62,3 +62,32 @@ Console.WriteLine($"Computed font-size: {computedFontSize}"); - Shorthand values (e.g., `margin`, `background`) are decomposed internally to longhands. - Variables (`var(--x)`) may defer full resolution until cascade context is available. - Comparing raw source strings is often misleading; compare parsed or computed values instead. + +## Custom Properties At Computed-Value Time + +Custom properties are resolved only during style computation, for each element before +they are inherited. `GetDeclarations`, `ComputeExplicitStyle`, `ComputeCascadedStyle`, +and render-tree `SpecifiedStyle` retain the original variable expressions. Computed +results are separate declarations and do not rewrite stylesheet or inline values. + +During computation, an inherited +alias keeps the parent's resolved value; changing its dependencies on a child does not +resolve that alias again. A declaration explicitly matching both elements is resolved +locally on each element. + +Following [CSS Variables dependency-cycle rules](https://drafts.csswg.org/css-variables-1/#cycles), +every property in a cycle becomes guaranteed-invalid, including cycles through unused +fallbacks. A consuming `var(--name, fallback)` can recover from an invalid or missing +custom property. Without a usable fallback, the consuming declaration uses its inherited +or initial value, not an earlier declaration from the cascade. A valid custom-property +value that does not match the consumer's grammar does not trigger the `var()` fallback. + +Dependency analysis and fallback substitution are iterative, including deeply nested +fallbacks. The public parser still represents nested `var()` fallbacks as `CssVarValue` +objects, and direct `CssReferenceValue.Compute` calls honor the supplied `References` +array, including subsequent changes to its entries. + +Expanded values during style computation are limited to 1,048,576 UTF-16 code units (including token +separators) to bound exponential substitution; an expansion exceeding this limit is +invalid at computed-value time. Property-specific parsing, unit conversion, and layout +support still determine which resolved values can be used by a consuming property. diff --git a/docs/general/05-Extensibility.md b/docs/general/05-Extensibility.md index 5b17a698..bd0d64b5 100644 --- a/docs/general/05-Extensibility.md +++ b/docs/general/05-Extensibility.md @@ -20,6 +20,8 @@ AngleSharp.Css is designed to be composed through services in the AngleSharp con : Add pseudo-element behavior. - `IRenderDevice` : Provide device characteristics for style computation. +- `IRenderDevicePreferences` +: Provide the user preferences answering the user-preference media features. ## Override The Default Stylesheet @@ -56,6 +58,37 @@ var config = Configuration.Default .WithRenderDevice(renderDevice); ``` +## Provide The User Preferences + +Beside the dimensions a render device carries the user preferences, which answer the user-preference media features. `DefaultRenderDevice` implements `IRenderDevicePreferences` for that; a custom `IRenderDevice` can implement it as well and is picked up the same way. + +```cs +var renderDevice = new DefaultRenderDevice +{ + Preferences = new Dictionary + { + { "prefers-color-scheme", "dark" }, + { "prefers-reduced-motion", "reduce" }, + }, +}; +``` + +The dictionary is keyed by the media feature name and holds the keyword the feature answers with. A key that is not set leaves its media feature unknown, i.e., a query using it never matches. + +| Key | Keywords | +| --- | --- | +| `prefers-color-scheme` | `light`, `dark` | +| `prefers-reduced-motion` | `no-preference`, `reduce` | +| `prefers-reduced-transparency` | `no-preference`, `reduce` | +| `prefers-contrast` | `no-preference`, `more`, `less`, `custom` | +| `prefers-reduced-data` | `no-preference`, `reduce` | +| `forced-colors` | `none`, `active` | +| `hover`, `any-hover` | `none`, `hover` | +| `pointer`, `any-pointer` | `none`, `coarse`, `fine` | +| `display-mode` | `fullscreen`, `standalone`, `minimal-ui`, `browser` | + +The value is compared to the queried keyword case insensitively, so a keyword that is newer than this library works as well. Used without a value, e.g., `@media (prefers-reduced-motion)`, the feature evaluates in a boolean context, where `no-preference` (and `none` for `forced-colors`, `hover`, `any-hover`, `pointer` and `any-pointer`) is `false`. Without a preference `hover` and `pointer` keep answering as they did before, i.e., as a device with no input mechanism. + ## Composition Pattern Start from the default registrations and replace only what you need: diff --git a/docs/tutorials/04-Questions.md b/docs/tutorials/04-Questions.md index d104a628..262a7509 100644 --- a/docs/tutorials/04-Questions.md +++ b/docs/tutorials/04-Questions.md @@ -6,10 +6,10 @@ section: "AngleSharp.Css" ## How to change the color output? -By default, AngleSharp.Css uses `rgba()` for the serialization of `Color`. To change this you can set +By default, AngleSharp.Css uses `rgba()` for the serialization of `CssColorValue`. To change this you can set ```cs -Color.UseHex = true; +CssColorValue.UseHex = true; ``` which will automatically use hex for all non-transparent colors. All other colors would still be represented via the `rgba()` function. @@ -17,13 +17,25 @@ which will automatically use hex for all non-transparent colors. All other color So you'd get: ```cs -Color.UseHex = true; -var color1 = new Color(65, 12, 48); +CssColorValue.UseHex = true; +var color1 = new CssColorValue(65, 12, 48); // color1.CssText = #410C30 -var color2 = new Color(65, 12, 48, 10); +var color2 = new CssColorValue(65, 12, 48, 10); // color2.CssText = rgba(65, 12, 48, 0.04) ``` +Alternatively, you can follow the serialization rules from the CSSOM specification, which omit the alpha channel of an opaque color: + +```cs +CssColorValue.UseSpecSerialization = true; +var color1 = new CssColorValue(65, 12, 48); +// color1.CssText = rgb(65, 12, 48) +var color2 = new CssColorValue(65, 12, 48, 10); +// color2.CssText = rgba(65, 12, 48, 0.04) +``` + +Both switches are global and `UseHex` wins if both are active. + ## Why is my linked stylesheet not loaded? Most commonly, resource loading is not enabled. For external stylesheets, configure a requester and enable resource loading. diff --git a/src/AngleSharp.Css.Docs/package.json b/src/AngleSharp.Css.Docs/package.json index df838e5a..9e402773 100644 --- a/src/AngleSharp.Css.Docs/package.json +++ b/src/AngleSharp.Css.Docs/package.json @@ -1,6 +1,6 @@ { "name": "@anglesharp/css", - "version": "1.0.2", + "version": "1.1.0", "preview": true, "description": "The doclet for the AngleSharp.Css documentation.", "keywords": [ diff --git a/src/AngleSharp.Css.Tests/CssConstructionFunctions.cs b/src/AngleSharp.Css.Tests/CssConstructionFunctions.cs index 5c3d377b..5d74129f 100644 --- a/src/AngleSharp.Css.Tests/CssConstructionFunctions.cs +++ b/src/AngleSharp.Css.Tests/CssConstructionFunctions.cs @@ -98,6 +98,13 @@ internal static Predicate CreateValidator(String name, String val return device => validator.Validate(feature, device); } + internal static Predicate CreateBooleanValidator(String name) + { + var validator = CreateMediaFeatureValidator(name); + var feature = new MediaFeature(name); + return device => validator.Validate(feature, device); + } + internal static CssFontFeatureValuesRule ParseFontFeatureValuesRule(String source) { ICssParser parser = new CssParser(); diff --git a/src/AngleSharp.Css.Tests/Extensions/MatchMedia.cs b/src/AngleSharp.Css.Tests/Extensions/MatchMedia.cs new file mode 100644 index 00000000..081f9bdb --- /dev/null +++ b/src/AngleSharp.Css.Tests/Extensions/MatchMedia.cs @@ -0,0 +1,187 @@ +#nullable disable +namespace AngleSharp.Css.Tests.Extensions +{ + using AngleSharp.Dom; + using AngleSharp.Html.Parser; + using NUnit.Framework; + + [TestFixture] + public class MatchMediaTests + { + [Test] + public void MatchMediaWithoutAnyQueryIsMatched() + { + var window = CreateWindow(new DefaultRenderDevice { ViewPortWidth = 1000, ViewPortHeight = 800 }); + Assert.IsTrue(window.MatchMedia("").IsMatched); + } + + [Test] + public void MatchMediaAllIsMatched() + { + var window = CreateWindow(new DefaultRenderDevice { ViewPortWidth = 1000, ViewPortHeight = 800 }); + Assert.IsTrue(window.MatchMedia("all").IsMatched); + } + + [Test] + public void MatchMediaScreenIsMatchedOnScreenDevice() + { + var window = CreateWindow(new DefaultRenderDevice { Category = DeviceCategory.Screen }); + Assert.IsTrue(window.MatchMedia("screen").IsMatched); + } + + [Test] + public void MatchMediaPrintIsNotMatchedOnScreenDevice() + { + var window = CreateWindow(new DefaultRenderDevice { Category = DeviceCategory.Screen }); + Assert.IsFalse(window.MatchMedia("print").IsMatched); + } + + [Test] + public void MatchMediaPrintIsMatchedOnPrinterDevice() + { + var window = CreateWindow(new DefaultRenderDevice { Category = DeviceCategory.Printer }); + Assert.IsTrue(window.MatchMedia("print").IsMatched); + } + + [Test] + public void MatchMediaWithCommaSeparatedQueriesIsMatchedWhenOneQueryMatches() + { + var window = CreateWindow(new DefaultRenderDevice { Category = DeviceCategory.Screen }); + Assert.IsTrue(window.MatchMedia("screen, print").IsMatched); + } + + [Test] + public void MatchMediaScreenIsNotMatchedOnPrinterDevice() + { + var window = CreateWindow(new DefaultRenderDevice { Category = DeviceCategory.Printer }); + Assert.IsFalse(window.MatchMedia("screen").IsMatched); + } + + [Test] + public void MatchMediaMinWidthIsMatchedForWideViewPort() + { + var window = CreateWindow(new DefaultRenderDevice { ViewPortWidth = 1000, ViewPortHeight = 800 }); + Assert.IsTrue(window.MatchMedia("(min-width: 600px)").IsMatched); + } + + [Test] + public void MatchMediaMinWidthIsNotMatchedForNarrowViewPort() + { + var window = CreateWindow(new DefaultRenderDevice { ViewPortWidth = 320, ViewPortHeight = 480 }); + Assert.IsFalse(window.MatchMedia("(min-width: 600px)").IsMatched); + } + + [Test] + public void MatchMediaMaxWidthIsMatchedForNarrowViewPort() + { + var window = CreateWindow(new DefaultRenderDevice { ViewPortWidth = 320, ViewPortHeight = 480 }); + Assert.IsTrue(window.MatchMedia("(max-width: 600px)").IsMatched); + } + + [Test] + public void MatchMediaMaxWidthIsNotMatchedForWideViewPort() + { + var window = CreateWindow(new DefaultRenderDevice { ViewPortWidth = 1000, ViewPortHeight = 800 }); + Assert.IsFalse(window.MatchMedia("(max-width: 600px)").IsMatched); + } + + [Test] + public void MatchMediaCombinedWidthRangeIsMatchedInBetween() + { + var window = CreateWindow(new DefaultRenderDevice { ViewPortWidth = 1000, ViewPortHeight = 800 }); + Assert.IsTrue(window.MatchMedia("(min-width: 600px) and (max-width: 1200px)").IsMatched); + } + + [Test] + public void MatchMediaOnlyScreenWithMinWidthIsMatchedForWideViewPort() + { + var window = CreateWindow(new DefaultRenderDevice { ViewPortWidth = 1000, ViewPortHeight = 800 }); + Assert.IsTrue(window.MatchMedia("only screen and (min-width: 600px)").IsMatched); + } + + [Test] + public void MatchMediaOnlyScreenWithMinWidthIsNotMatchedForNarrowViewPort() + { + var window = CreateWindow(new DefaultRenderDevice { ViewPortWidth = 320, ViewPortHeight = 480 }); + Assert.IsFalse(window.MatchMedia("only screen and (min-width: 600px)").IsMatched); + } + + [Test] + public void MatchMediaNotScreenIsNotMatchedOnScreenDevice() + { + var window = CreateWindow(new DefaultRenderDevice { Category = DeviceCategory.Screen }); + Assert.IsFalse(window.MatchMedia("not screen").IsMatched); + } + + [Test] + public void MatchMediaNotPrintIsMatchedOnScreenDevice() + { + var window = CreateWindow(new DefaultRenderDevice { Category = DeviceCategory.Screen }); + Assert.IsTrue(window.MatchMedia("not print").IsMatched); + } + + [Test] + public void MatchMediaNotAllIsNotMatched() + { + var window = CreateWindow(new DefaultRenderDevice { Category = DeviceCategory.Screen }); + Assert.IsFalse(window.MatchMedia("not all").IsMatched); + } + + [Test] + public void MatchMediaNotMinWidthIsMatchedForNarrowViewPort() + { + var window = CreateWindow(new DefaultRenderDevice { ViewPortWidth = 320, ViewPortHeight = 480 }); + Assert.IsTrue(window.MatchMedia("not (min-width: 600px)").IsMatched); + } + + [Test] + public void MatchMediaNotMinWidthIsNotMatchedForWideViewPort() + { + var window = CreateWindow(new DefaultRenderDevice { ViewPortWidth = 1000, ViewPortHeight = 800 }); + Assert.IsFalse(window.MatchMedia("not (min-width: 600px)").IsMatched); + } + + [Test] + public void MatchMediaUnknownFeatureIsNotMatched() + { + var window = CreateWindow(new DefaultRenderDevice { ViewPortWidth = 1000, ViewPortHeight = 800 }); + Assert.IsFalse(window.MatchMedia("(foo-bar: 3)").IsMatched); + } + + [Test] + public void MatchMediaMinHeightIsMatchedForTallViewPort() + { + var window = CreateWindow(new DefaultRenderDevice { ViewPortWidth = 1000, ViewPortHeight = 800 }); + Assert.IsTrue(window.MatchMedia("(min-height: 600px)").IsMatched); + } + + [Test] + public void MatchMediaWithoutRenderDeviceUsesTheDefaultDevice() + { + var context = BrowsingContext.New(Configuration.Default.WithCss()); + var window = CreateWindow(context); + Assert.IsTrue(window.MatchMedia("screen").IsMatched); + Assert.IsFalse(window.MatchMedia("print").IsMatched); + } + + [Test] + public void MatchMediaKeepsTheProvidedMediaText() + { + var window = CreateWindow(new DefaultRenderDevice { ViewPortWidth = 1000, ViewPortHeight = 800 }); + Assert.AreEqual("(min-width: 600px)", window.MatchMedia("(min-width: 600px)").MediaText); + } + + private static IWindow CreateWindow(IRenderDevice device) + { + var config = Configuration.Default.WithCss().WithRenderDevice(device); + return CreateWindow(BrowsingContext.New(config)); + } + + private static IWindow CreateWindow(IBrowsingContext context) + { + var parser = context.GetService(); + var document = parser.ParseDocument("Example"); + return document.DefaultView; + } + } +} diff --git a/src/AngleSharp.Css.Tests/Extensions/MediaPreferences.cs b/src/AngleSharp.Css.Tests/Extensions/MediaPreferences.cs new file mode 100644 index 00000000..a9ebe0ec --- /dev/null +++ b/src/AngleSharp.Css.Tests/Extensions/MediaPreferences.cs @@ -0,0 +1,163 @@ +#nullable disable +namespace AngleSharp.Css.Tests.Extensions +{ + using AngleSharp.Css.Dom; + using AngleSharp.Css.Tests.Mocks; + using AngleSharp.Dom; + using AngleSharp.Html.Parser; + using NUnit.Framework; + using System; + using System.Collections.Generic; + + [TestFixture] + public class MediaPreferencesTests + { + [Test] + public void MatchMediaPrefersColorSchemeDarkIsMatchedWhenDarkIsPreferred() + { + var window = CreateWindow(DeviceWith(FeatureNames.PrefersColorScheme, CssKeywords.Dark)); + Assert.IsTrue(window.MatchMedia("(prefers-color-scheme: dark)").IsMatched); + } + + [Test] + public void MatchMediaPrefersColorSchemeDarkIsNotMatchedWhenLightIsPreferred() + { + var window = CreateWindow(DeviceWith(FeatureNames.PrefersColorScheme, CssKeywords.Light)); + Assert.IsFalse(window.MatchMedia("(prefers-color-scheme: dark)").IsMatched); + } + + [Test] + public void MatchMediaPrefersColorSchemeDarkIsNotMatchedWithoutAnyPreference() + { + var window = CreateWindow(new DefaultRenderDevice()); + Assert.IsFalse(window.MatchMedia("(prefers-color-scheme: dark)").IsMatched); + } + + [Test] + public void MatchMediaNotPrefersColorSchemeDarkIsMatchedWhenLightIsPreferred() + { + var window = CreateWindow(DeviceWith(FeatureNames.PrefersColorScheme, CssKeywords.Light)); + Assert.IsTrue(window.MatchMedia("not (prefers-color-scheme: dark)").IsMatched); + } + + [Test] + public void MatchMediaPrefersColorSchemeDarkIsMatchedForAThirdPartyDevice() + { + var window = CreateWindow(new PreferringRenderDevice(FeatureNames.PrefersColorScheme, CssKeywords.Dark)); + Assert.IsTrue(window.MatchMedia("(prefers-color-scheme: dark)").IsMatched); + } + + [Test] + public void MatchMediaPrefersColorSchemeDarkIsNotMatchedForADeviceWithoutPreferences() + { + var window = CreateWindow(new PlainRenderDevice()); + Assert.IsFalse(window.MatchMedia("(prefers-color-scheme: dark)").IsMatched); + } + + [Test] + public void MatchMediaScreenAndPrefersColorSchemeDarkIsMatchedWhenDarkIsPreferred() + { + var window = CreateWindow(DeviceWith(FeatureNames.PrefersColorScheme, CssKeywords.Dark)); + Assert.IsTrue(window.MatchMedia("screen and (prefers-color-scheme: dark)").IsMatched); + } + + [Test] + public void MatchMediaPrefersColorSchemeInBooleanContextIsMatchedWhenSet() + { + var window = CreateWindow(DeviceWith(FeatureNames.PrefersColorScheme, CssKeywords.Dark)); + Assert.IsTrue(window.MatchMedia("(prefers-color-scheme)").IsMatched); + } + + [Test] + public void MatchMediaPrefersColorSchemeInBooleanContextIsNotMatchedWithoutAnyPreference() + { + var window = CreateWindow(new DefaultRenderDevice()); + Assert.IsFalse(window.MatchMedia("(prefers-color-scheme)").IsMatched); + } + + [Test] + public void MatchMediaPrefersReducedMotionIsMatchedWhenReduceIsPreferred() + { + var window = CreateWindow(DeviceWith(FeatureNames.PrefersReducedMotion, CssKeywords.Reduce)); + Assert.IsTrue(window.MatchMedia("(prefers-reduced-motion: reduce)").IsMatched); + Assert.IsTrue(window.MatchMedia("(prefers-reduced-motion)").IsMatched); + } + + [Test] + public void MatchMediaPrefersReducedMotionIsNotMatchedWhenNoPreferenceIsSet() + { + var window = CreateWindow(DeviceWith(FeatureNames.PrefersReducedMotion, CssKeywords.NoPreference)); + Assert.IsFalse(window.MatchMedia("(prefers-reduced-motion: reduce)").IsMatched); + Assert.IsFalse(window.MatchMedia("(prefers-reduced-motion)").IsMatched); + } + + [Test] + public void MatchMediaForcedColorsIsMatchedWhenActive() + { + var window = CreateWindow(DeviceWith(FeatureNames.ForcedColors, CssKeywords.Active)); + Assert.IsTrue(window.MatchMedia("(forced-colors: active)").IsMatched); + Assert.IsTrue(window.MatchMedia("(forced-colors)").IsMatched); + } + + [Test] + public void MatchMediaHoverIsMatchedFromThePreference() + { + var window = CreateWindow(DeviceWith(FeatureNames.Hover, CssKeywords.Hover)); + Assert.IsTrue(window.MatchMedia("(hover: hover)").IsMatched); + Assert.IsFalse(window.MatchMedia("(hover: none)").IsMatched); + } + + [Test] + public void PrefersReducedMotionMediaRuleIsAppliedInTheCascade() + { + var document = CreateDocument(DeviceWith(FeatureNames.PrefersReducedMotion, CssKeywords.Reduce)); + var style = document.QuerySelector("div").ComputeCurrentStyle(); + Assert.AreEqual("rgba(0, 128, 0, 1)", style.GetColor()); + } + + [Test] + public void PrefersReducedMotionMediaRuleIsSkippedWithoutThePreference() + { + var document = CreateDocument(new DefaultRenderDevice()); + var style = document.QuerySelector("div").ComputeCurrentStyle(); + Assert.AreEqual("rgba(255, 0, 0, 1)", style.GetColor()); + } + + [Test] + public void PrefersReducedMotionMediaRuleIsSkippedForADeviceWithoutPreferences() + { + var document = CreateDocument(new PlainRenderDevice()); + var style = document.QuerySelector("div").ComputeCurrentStyle(); + Assert.AreEqual("rgba(255, 0, 0, 1)", style.GetColor()); + } + + private static DefaultRenderDevice DeviceWith(String name, String value) => new DefaultRenderDevice + { + Preferences = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { name, value }, + }, + }; + + private static IDocument CreateDocument(IRenderDevice device) + { + var source = @"
"; + var config = Configuration.Default.WithCss().WithRenderDevice(device); + var context = BrowsingContext.New(config); + var parser = context.GetService(); + return parser.ParseDocument(source); + } + + private static IWindow CreateWindow(IRenderDevice device) + { + var config = Configuration.Default.WithCss().WithRenderDevice(device); + var context = BrowsingContext.New(config); + var parser = context.GetService(); + var document = parser.ParseDocument("Example"); + return document.DefaultView; + } + } +} diff --git a/src/AngleSharp.Css.Tests/Library/StringRepresentation.cs b/src/AngleSharp.Css.Tests/Library/StringRepresentation.cs index 31d97cfd..497a4122 100644 --- a/src/AngleSharp.Css.Tests/Library/StringRepresentation.cs +++ b/src/AngleSharp.Css.Tests/Library/StringRepresentation.cs @@ -16,6 +16,13 @@ namespace AngleSharp.Css.Tests.Library [TestFixture] public class StringRepresentationTests { + [TearDown] + public void ResetColorSerialization() + { + CssColorValue.UseHex = false; + CssColorValue.UseSpecSerialization = false; + } + [Test] public void PrettyStyleFormatterStringifyShouldWork_Issue41() { @@ -50,6 +57,62 @@ public void TransparentColorWorksWithHexOutput_Issue132() Assert.AreEqual("#410C300A", text); } + [Test] + public void OpaqueColorKeepsTheAlphaChannelByDefault_Issue227() + { + var color = new CssColorValue(65, 12, 48); + Assert.AreEqual("rgba(65, 12, 48, 1)", color.CssText); + } + + [Test] + public void OpaqueColorDropsTheAlphaChannelWithSpecOutput_Issue227() + { + var color = new CssColorValue(65, 12, 48); + CssColorValue.UseSpecSerialization = true; + Assert.AreEqual("rgb(65, 12, 48)", color.CssText); + } + + [Test] + public void TransparentColorKeepsTheAlphaChannelWithSpecOutput_Issue227() + { + var color = new CssColorValue(65, 12, 48, 128); + CssColorValue.UseSpecSerialization = true; + Assert.AreEqual("rgba(65, 12, 48, 0.5)", color.CssText); + } + + [Test] + public void OpaqueColorPrefersHexOutputOverSpecOutput_Issue227() + { + var color = new CssColorValue(65, 12, 48); + CssColorValue.UseHex = true; + CssColorValue.UseSpecSerialization = true; + Assert.AreEqual("#410C30", color.CssText); + } + + [Test] + public void TransparentColorPrefersHexOutputOverSpecOutput_Issue227() + { + var color = new CssColorValue(65, 12, 48, 10); + CssColorValue.UseHex = true; + CssColorValue.UseSpecSerialization = true; + Assert.AreEqual("#410C300A", color.CssText); + } + + [Test] + public void CurrentColorIsNotAffectedBySpecOutput_Issue227() + { + CssColorValue.UseSpecSerialization = true; + Assert.AreEqual("currentColor", CssColorValue.CurrentColor.CssText); + } + + [Test] + public void DeclarationUsesSpecOutputForOpaqueColors_Issue227() + { + CssColorValue.UseSpecSerialization = true; + var declaration = ParseDeclaration("color: rgba(255, 0, 0, 1)"); + Assert.AreEqual("color: rgb(255, 0, 0)", declaration.CssText); + } + [Test] public void ShorthandPaddingInheritPropertiesShouldBeIncluded_Issue100() { diff --git a/src/AngleSharp.Css.Tests/Mocks/PlainRenderDevice.cs b/src/AngleSharp.Css.Tests/Mocks/PlainRenderDevice.cs new file mode 100644 index 00000000..348cf430 --- /dev/null +++ b/src/AngleSharp.Css.Tests/Mocks/PlainRenderDevice.cs @@ -0,0 +1,43 @@ +namespace AngleSharp.Css.Tests.Mocks +{ + using AngleSharp.Css; + using System; + + /// + /// A render device that deliberately does not implement + /// , i.e., what an existing + /// third-party implementation of looks like. + /// + sealed class PlainRenderDevice : IRenderDevice + { + public DeviceCategory Category => DeviceCategory.Screen; + + public Int32 ColorBits => 32; + + public Int32 DeviceHeight => 800; + + public Int32 DeviceWidth => 1000; + + public Int32 Frequency => 60; + + public Boolean IsGrid => false; + + public Boolean IsInterlaced => false; + + public Boolean IsScripting => true; + + public Int32 MonochromeBits => 16; + + public Int32 Resolution => 96; + + public Int32 ViewPortHeight => 800; + + public Int32 ViewPortWidth => 1000; + + public Double RenderWidth => ViewPortWidth; + + public Double RenderHeight => ViewPortHeight; + + public Double FontSize => 16; + } +} diff --git a/src/AngleSharp.Css.Tests/Mocks/PreferringRenderDevice.cs b/src/AngleSharp.Css.Tests/Mocks/PreferringRenderDevice.cs new file mode 100644 index 00000000..994f6487 --- /dev/null +++ b/src/AngleSharp.Css.Tests/Mocks/PreferringRenderDevice.cs @@ -0,0 +1,55 @@ +namespace AngleSharp.Css.Tests.Mocks +{ + using AngleSharp.Css; + using System; + using System.Collections.Generic; + + /// + /// A third-party render device that opts into the user preferences + /// without deriving from . + /// + sealed class PreferringRenderDevice : IRenderDevice, IRenderDevicePreferences + { + private readonly Dictionary _preferences; + + public PreferringRenderDevice(String name, String value) + { + _preferences = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { name, value }, + }; + } + + public IReadOnlyDictionary Preferences => _preferences; + + public DeviceCategory Category => DeviceCategory.Screen; + + public Int32 ColorBits => 32; + + public Int32 DeviceHeight => 800; + + public Int32 DeviceWidth => 1000; + + public Int32 Frequency => 60; + + public Boolean IsGrid => false; + + public Boolean IsInterlaced => false; + + public Boolean IsScripting => true; + + public Int32 MonochromeBits => 16; + + public Int32 Resolution => 96; + + public Int32 ViewPortHeight => 800; + + public Int32 ViewPortWidth => 1000; + + public Double RenderWidth => ViewPortWidth; + + public Double RenderHeight => ViewPortHeight; + + public Double FontSize => 16; + } +} diff --git a/src/AngleSharp.Css.Tests/Rules/CssKeyframeRule.cs b/src/AngleSharp.Css.Tests/Rules/CssKeyframeRule.cs index efcc9a55..b433c453 100644 --- a/src/AngleSharp.Css.Tests/Rules/CssKeyframeRule.cs +++ b/src/AngleSharp.Css.Tests/Rules/CssKeyframeRule.cs @@ -1,5 +1,6 @@ namespace AngleSharp.Css.Tests.Rules { + using AngleSharp.Css.Dom; using NUnit.Framework; using System.Linq; using static CssConstructionFunctions; @@ -84,5 +85,56 @@ public void KeyframeRuleWithPercentage_Issue128() Assert.AreEqual(3, rule.Key.Stops.Count()); Assert.AreEqual(0, rule.Style.Length); } + + [Test] + public void KeyframeRuleWithUppercaseFrom() + { + var rule = ParseKeyframeRule(@" FROM { + margin-left: 0px; + }"); + Assert.IsNotNull(rule); + Assert.AreEqual("0%", rule.KeyText); + Assert.AreEqual(1, rule.Key.Stops.Count()); + Assert.AreEqual(1, rule.Style.Length); + } + + [Test] + public void KeyframeRuleWithUppercaseTo() + { + var rule = ParseKeyframeRule(@" TO { + margin-left: 200px; + }"); + Assert.IsNotNull(rule); + Assert.AreEqual("100%", rule.KeyText); + Assert.AreEqual(1, rule.Key.Stops.Count()); + Assert.AreEqual(1, rule.Style.Length); + } + + [Test] + public void KeyframeRuleWithMixedCaseFromAndTo() + { + var rule = ParseKeyframeRule(@" From, To { }"); + Assert.IsNotNull(rule); + Assert.AreEqual("0%, 100%", rule.KeyText); + Assert.AreEqual(2, rule.Key.Stops.Count()); + } + + [Test] + public void KeyframeRuleWithMalformedSelectorIsRejected() + { + var rule = ParseKeyframeRule("invalid { opacity: 0; }"); + + Assert.IsNull(rule); + } + + [Test] + public void KeyframesRuleOmitsMalformedSelectorAndKeepsFollowingRule() + { + var sheet = ParseStyleSheet("@keyframes fade { invalid { opacity: 0; } to { opacity: 1; } }"); + var keyframes = sheet.Rules.OfType().Single(); + + Assert.AreEqual(1, keyframes.Rules.Length); + Assert.AreEqual("100%", ((ICssKeyframeRule)keyframes.Rules[0]).KeyText); + } } } diff --git a/src/AngleSharp.Css.Tests/Rules/CssMediaFeatures.cs b/src/AngleSharp.Css.Tests/Rules/CssMediaFeatures.cs index 6aaee58c..2ca820a4 100644 --- a/src/AngleSharp.Css.Tests/Rules/CssMediaFeatures.cs +++ b/src/AngleSharp.Css.Tests/Rules/CssMediaFeatures.cs @@ -67,5 +67,21 @@ public void CssMediaAspectRatio() Assert.IsTrue(valid); Assert.IsFalse(invalid); } + + [Test] + public void CssMediaOrientationAndScanValidation() + { + var portrait = CreateValidator(FeatureNames.Orientation, "portrait"); + var landscape = CreateValidator(FeatureNames.Orientation, "landscape"); + var interlace = CreateValidator(FeatureNames.Scan, "interlace"); + var progressive = CreateValidator(FeatureNames.Scan, "progressive"); + var landscapeDevice = new DefaultRenderDevice { DeviceWidth = 1024, DeviceHeight = 768 }; + var interlacedDevice = new DefaultRenderDevice { IsInterlaced = true }; + + Assert.IsFalse(portrait(landscapeDevice)); + Assert.IsTrue(landscape(landscapeDevice)); + Assert.IsTrue(interlace(interlacedDevice)); + Assert.IsFalse(progressive(interlacedDevice)); + } } } diff --git a/src/AngleSharp.Css.Tests/Rules/CssMediaPreferenceFeatures.cs b/src/AngleSharp.Css.Tests/Rules/CssMediaPreferenceFeatures.cs new file mode 100644 index 00000000..491a67e6 --- /dev/null +++ b/src/AngleSharp.Css.Tests/Rules/CssMediaPreferenceFeatures.cs @@ -0,0 +1,258 @@ +#nullable disable +namespace AngleSharp.Css.Tests.Rules +{ + using AngleSharp.Css; + using AngleSharp.Css.FeatureValidators; + using AngleSharp.Css.Tests.Mocks; + using NUnit.Framework; + using System; + using System.Collections.Generic; + using static CssConstructionFunctions; + + [TestFixture] + public class CssMediaPreferenceFeaturesTests + { + [Test] + public void CssMediaPreferenceFeatureValidatorFactory() + { + Assert.IsInstanceOf(CreateMediaFeatureValidator(FeatureNames.PrefersColorScheme)); + Assert.IsInstanceOf(CreateMediaFeatureValidator(FeatureNames.PrefersReducedMotion)); + Assert.IsInstanceOf(CreateMediaFeatureValidator(FeatureNames.PrefersReducedTransparency)); + Assert.IsInstanceOf(CreateMediaFeatureValidator(FeatureNames.PrefersReducedData)); + Assert.IsInstanceOf(CreateMediaFeatureValidator(FeatureNames.PrefersContrast)); + Assert.IsInstanceOf(CreateMediaFeatureValidator(FeatureNames.ForcedColors)); + Assert.IsInstanceOf(CreateMediaFeatureValidator(FeatureNames.DisplayMode)); + Assert.IsInstanceOf(CreateMediaFeatureValidator(FeatureNames.Hover)); + Assert.IsInstanceOf(CreateMediaFeatureValidator(FeatureNames.AnyHover)); + Assert.IsInstanceOf(CreateMediaFeatureValidator(FeatureNames.Pointer)); + Assert.IsInstanceOf(CreateMediaFeatureValidator(FeatureNames.AnyPointer)); + } + + [Test] + public void CssMediaPrefersColorSchemeValidation() + { + var validate = CreateValidator(FeatureNames.PrefersColorScheme, CssKeywords.Dark); + Assert.IsTrue(validate(DeviceWith(FeatureNames.PrefersColorScheme, CssKeywords.Dark))); + Assert.IsFalse(validate(DeviceWith(FeatureNames.PrefersColorScheme, CssKeywords.Light))); + Assert.IsFalse(validate(new DefaultRenderDevice())); + } + + [Test] + public void CssMediaPrefersColorSchemeIsComparedCaseInsensitively() + { + var validate = CreateValidator(FeatureNames.PrefersColorScheme, CssKeywords.Dark); + Assert.IsTrue(validate(DeviceWith(FeatureNames.PrefersColorScheme, "DARK"))); + } + + [Test] + public void CssMediaPrefersColorSchemeIsFoundForAnUppercaseKey() + { + var validate = CreateValidator(FeatureNames.PrefersColorScheme, CssKeywords.Dark); + Assert.IsTrue(validate(DeviceWith("Prefers-Color-Scheme", CssKeywords.Dark))); + } + + [Test] + public void CssMediaPrefersColorSchemeInBooleanContext() + { + var validate = CreateBooleanValidator(FeatureNames.PrefersColorScheme); + Assert.IsTrue(validate(DeviceWith(FeatureNames.PrefersColorScheme, CssKeywords.Dark))); + Assert.IsTrue(validate(DeviceWith(FeatureNames.PrefersColorScheme, CssKeywords.Light))); + Assert.IsFalse(validate(DeviceWith(FeatureNames.PrefersColorScheme, CssKeywords.NoPreference))); + Assert.IsFalse(validate(new DefaultRenderDevice())); + } + + [Test] + public void CssMediaPrefersReducedMotionValidation() + { + var validate = CreateValidator(FeatureNames.PrefersReducedMotion, CssKeywords.Reduce); + Assert.IsTrue(validate(DeviceWith(FeatureNames.PrefersReducedMotion, CssKeywords.Reduce))); + Assert.IsFalse(validate(DeviceWith(FeatureNames.PrefersReducedMotion, CssKeywords.NoPreference))); + Assert.IsFalse(validate(new DefaultRenderDevice())); + } + + [Test] + public void CssMediaPrefersReducedMotionInBooleanContext() + { + var validate = CreateBooleanValidator(FeatureNames.PrefersReducedMotion); + Assert.IsTrue(validate(DeviceWith(FeatureNames.PrefersReducedMotion, CssKeywords.Reduce))); + Assert.IsFalse(validate(DeviceWith(FeatureNames.PrefersReducedMotion, CssKeywords.NoPreference))); + Assert.IsFalse(validate(new DefaultRenderDevice())); + } + + [Test] + public void CssMediaPrefersReducedTransparencyValidation() + { + var validate = CreateValidator(FeatureNames.PrefersReducedTransparency, CssKeywords.Reduce); + Assert.IsTrue(validate(DeviceWith(FeatureNames.PrefersReducedTransparency, CssKeywords.Reduce))); + Assert.IsFalse(validate(DeviceWith(FeatureNames.PrefersReducedTransparency, CssKeywords.NoPreference))); + Assert.IsFalse(validate(new DefaultRenderDevice())); + } + + [Test] + public void CssMediaPrefersReducedTransparencyInBooleanContext() + { + var validate = CreateBooleanValidator(FeatureNames.PrefersReducedTransparency); + Assert.IsTrue(validate(DeviceWith(FeatureNames.PrefersReducedTransparency, CssKeywords.Reduce))); + Assert.IsFalse(validate(DeviceWith(FeatureNames.PrefersReducedTransparency, CssKeywords.NoPreference))); + } + + [Test] + public void CssMediaPrefersReducedDataValidation() + { + var validate = CreateValidator(FeatureNames.PrefersReducedData, CssKeywords.Reduce); + Assert.IsTrue(validate(DeviceWith(FeatureNames.PrefersReducedData, CssKeywords.Reduce))); + Assert.IsFalse(validate(DeviceWith(FeatureNames.PrefersReducedData, CssKeywords.NoPreference))); + Assert.IsFalse(validate(new DefaultRenderDevice())); + } + + [Test] + public void CssMediaPrefersReducedDataInBooleanContext() + { + var validate = CreateBooleanValidator(FeatureNames.PrefersReducedData); + Assert.IsTrue(validate(DeviceWith(FeatureNames.PrefersReducedData, CssKeywords.Reduce))); + Assert.IsFalse(validate(DeviceWith(FeatureNames.PrefersReducedData, CssKeywords.NoPreference))); + } + + [Test] + public void CssMediaPrefersContrastValidation() + { + var validate = CreateValidator(FeatureNames.PrefersContrast, CssKeywords.More); + Assert.IsTrue(validate(DeviceWith(FeatureNames.PrefersContrast, CssKeywords.More))); + Assert.IsFalse(validate(DeviceWith(FeatureNames.PrefersContrast, CssKeywords.Less))); + Assert.IsFalse(validate(DeviceWith(FeatureNames.PrefersContrast, CssKeywords.Custom))); + Assert.IsFalse(validate(new DefaultRenderDevice())); + } + + [Test] + public void CssMediaPrefersContrastInBooleanContext() + { + var validate = CreateBooleanValidator(FeatureNames.PrefersContrast); + Assert.IsTrue(validate(DeviceWith(FeatureNames.PrefersContrast, CssKeywords.More))); + Assert.IsTrue(validate(DeviceWith(FeatureNames.PrefersContrast, CssKeywords.Less))); + Assert.IsTrue(validate(DeviceWith(FeatureNames.PrefersContrast, CssKeywords.Custom))); + Assert.IsFalse(validate(DeviceWith(FeatureNames.PrefersContrast, CssKeywords.NoPreference))); + } + + [Test] + public void CssMediaForcedColorsValidation() + { + var validate = CreateValidator(FeatureNames.ForcedColors, CssKeywords.Active); + Assert.IsTrue(validate(DeviceWith(FeatureNames.ForcedColors, CssKeywords.Active))); + Assert.IsFalse(validate(DeviceWith(FeatureNames.ForcedColors, CssKeywords.None))); + Assert.IsFalse(validate(new DefaultRenderDevice())); + } + + [Test] + public void CssMediaForcedColorsInBooleanContext() + { + var validate = CreateBooleanValidator(FeatureNames.ForcedColors); + Assert.IsTrue(validate(DeviceWith(FeatureNames.ForcedColors, CssKeywords.Active))); + Assert.IsFalse(validate(DeviceWith(FeatureNames.ForcedColors, CssKeywords.None))); + Assert.IsFalse(validate(new DefaultRenderDevice())); + } + + [Test] + public void CssMediaDisplayModeValidation() + { + var validate = CreateValidator(FeatureNames.DisplayMode, "standalone"); + Assert.IsTrue(validate(DeviceWith(FeatureNames.DisplayMode, "standalone"))); + Assert.IsFalse(validate(DeviceWith(FeatureNames.DisplayMode, "browser"))); + Assert.IsFalse(validate(new DefaultRenderDevice())); + } + + [Test] + public void CssMediaDisplayModeInBooleanContext() + { + var validate = CreateBooleanValidator(FeatureNames.DisplayMode); + Assert.IsTrue(validate(DeviceWith(FeatureNames.DisplayMode, "browser"))); + Assert.IsFalse(validate(new DefaultRenderDevice())); + } + + [Test] + public void CssMediaPreferencesAreNotReadFromADeviceWithoutTheInterface() + { + var device = new PlainRenderDevice(); + Assert.IsFalse(CreateValidator(FeatureNames.PrefersColorScheme, CssKeywords.Dark)(device)); + Assert.IsFalse(CreateValidator(FeatureNames.PrefersReducedMotion, CssKeywords.Reduce)(device)); + Assert.IsFalse(CreateValidator(FeatureNames.PrefersReducedTransparency, CssKeywords.Reduce)(device)); + Assert.IsFalse(CreateValidator(FeatureNames.PrefersReducedData, CssKeywords.Reduce)(device)); + Assert.IsFalse(CreateValidator(FeatureNames.PrefersContrast, CssKeywords.More)(device)); + Assert.IsFalse(CreateValidator(FeatureNames.ForcedColors, CssKeywords.Active)(device)); + Assert.IsFalse(CreateValidator(FeatureNames.DisplayMode, "browser")(device)); + Assert.IsFalse(CreateBooleanValidator(FeatureNames.PrefersColorScheme)(device)); + } + + [Test] + public void CssMediaHoverKeepsItsAnswerWithoutAPreference() + { + Assert.IsTrue(CreateValidator(FeatureNames.Hover, CssKeywords.None)(new DefaultRenderDevice())); + Assert.IsFalse(CreateValidator(FeatureNames.Hover, CssKeywords.Hover)(new DefaultRenderDevice())); + Assert.IsTrue(CreateValidator(FeatureNames.Hover, CssKeywords.None)(new PlainRenderDevice())); + Assert.IsFalse(CreateBooleanValidator(FeatureNames.Hover)(new DefaultRenderDevice())); + } + + [Test] + public void CssMediaHoverIsTakenFromThePreference() + { + var device = DeviceWith(FeatureNames.Hover, CssKeywords.Hover); + Assert.IsTrue(CreateValidator(FeatureNames.Hover, CssKeywords.Hover)(device)); + Assert.IsFalse(CreateValidator(FeatureNames.Hover, CssKeywords.None)(device)); + Assert.IsTrue(CreateBooleanValidator(FeatureNames.Hover)(device)); + Assert.IsFalse(CreateBooleanValidator(FeatureNames.Hover)(DeviceWith(FeatureNames.Hover, CssKeywords.None))); + } + + [Test] + public void CssMediaAnyHoverIsTakenFromThePreference() + { + var device = DeviceWith(FeatureNames.AnyHover, CssKeywords.Hover); + Assert.IsTrue(CreateValidator(FeatureNames.AnyHover, CssKeywords.Hover)(device)); + Assert.IsFalse(CreateValidator(FeatureNames.AnyHover, CssKeywords.None)(device)); + Assert.IsTrue(CreateBooleanValidator(FeatureNames.AnyHover)(device)); + Assert.IsTrue(CreateValidator(FeatureNames.AnyHover, CssKeywords.None)(new DefaultRenderDevice())); + } + + [Test] + public void CssMediaPointerKeepsItsAnswerWithoutAPreference() + { + Assert.IsTrue(CreateValidator(FeatureNames.Pointer, CssKeywords.None)(new DefaultRenderDevice())); + Assert.IsFalse(CreateValidator(FeatureNames.Pointer, CssKeywords.Fine)(new DefaultRenderDevice())); + Assert.IsTrue(CreateValidator(FeatureNames.Pointer, CssKeywords.None)(new PlainRenderDevice())); + Assert.IsFalse(CreateBooleanValidator(FeatureNames.Pointer)(new DefaultRenderDevice())); + } + + [Test] + public void CssMediaPointerIsTakenFromThePreference() + { + var device = DeviceWith(FeatureNames.Pointer, CssKeywords.Fine); + Assert.IsTrue(CreateValidator(FeatureNames.Pointer, CssKeywords.Fine)(device)); + Assert.IsFalse(CreateValidator(FeatureNames.Pointer, CssKeywords.Coarse)(device)); + Assert.IsTrue(CreateBooleanValidator(FeatureNames.Pointer)(device)); + Assert.IsFalse(CreateBooleanValidator(FeatureNames.Pointer)(DeviceWith(FeatureNames.Pointer, CssKeywords.None))); + } + + [Test] + public void CssMediaAnyPointerIsTakenFromThePreference() + { + var device = DeviceWith(FeatureNames.AnyPointer, CssKeywords.Coarse); + Assert.IsTrue(CreateValidator(FeatureNames.AnyPointer, CssKeywords.Coarse)(device)); + Assert.IsFalse(CreateValidator(FeatureNames.AnyPointer, CssKeywords.Fine)(device)); + Assert.IsTrue(CreateBooleanValidator(FeatureNames.AnyPointer)(device)); + Assert.IsTrue(CreateValidator(FeatureNames.AnyPointer, CssKeywords.None)(new DefaultRenderDevice())); + } + + [Test] + public void CssMediaPreferenceOfAnotherFeatureIsNotUsed() + { + var validate = CreateValidator(FeatureNames.PrefersColorScheme, CssKeywords.Dark); + Assert.IsFalse(validate(DeviceWith(FeatureNames.PrefersReducedMotion, CssKeywords.Dark))); + } + + private static DefaultRenderDevice DeviceWith(String name, String value) => new DefaultRenderDevice + { + Preferences = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { name, value }, + }, + }; + } +} diff --git a/src/AngleSharp.Css.Tests/Rules/CssSupports.cs b/src/AngleSharp.Css.Tests/Rules/CssSupports.cs index 1a512803..c419dd13 100644 --- a/src/AngleSharp.Css.Tests/Rules/CssSupports.cs +++ b/src/AngleSharp.Css.Tests/Rules/CssSupports.cs @@ -209,5 +209,57 @@ public void SupportsNegatedDisplayFlexRuleWithDeclarations() Assert.AreEqual("not (display: flex)", supports.ConditionText); Assert.IsFalse(supports.Condition.Check(device)); } + + [Test] + public void SupportsUppercaseAndKeywordRule() + { + var source = @"@supports ((background-color: red) AND (color: blue)) { }"; + var sheet = ParseStyleSheet(source); + var device = new DefaultRenderDevice(); + Assert.AreEqual(1, sheet.Rules.Length); + Assert.IsInstanceOf(sheet.Rules[0]); + var supports = sheet.Rules[0] as CssSupportsRule; + Assert.AreEqual("((background-color: red) and (color: blue))", supports.ConditionText); + Assert.IsTrue(supports.Condition.Check(device)); + } + + [Test] + public void SupportsUppercaseOrKeywordRule() + { + var source = @"@supports ((background-transparency: half) OR (color: blue)) { }"; + var sheet = ParseStyleSheet(source); + var device = new DefaultRenderDevice(); + Assert.AreEqual(1, sheet.Rules.Length); + Assert.IsInstanceOf(sheet.Rules[0]); + var supports = sheet.Rules[0] as CssSupportsRule; + Assert.AreEqual("((background-transparency: half) or (color: blue))", supports.ConditionText); + Assert.IsTrue(supports.Condition.Check(device)); + } + + [Test] + public void SupportsMixedCaseAndKeywordChainRule() + { + var source = @"@supports ((background-color: red) And (color: blue) aND (width: 10px)) { }"; + var sheet = ParseStyleSheet(source); + var device = new DefaultRenderDevice(); + Assert.AreEqual(1, sheet.Rules.Length); + Assert.IsInstanceOf(sheet.Rules[0]); + var supports = sheet.Rules[0] as CssSupportsRule; + Assert.AreEqual("((background-color: red) and (color: blue) and (width: 10px))", supports.ConditionText); + Assert.IsTrue(supports.Condition.Check(device)); + } + + [Test] + public void SupportsUppercaseAndKeywordKeepsInnerRules() + { + var source = @"@supports (color: red) AND (display: flex) { + body { width: 100%; } +}"; + var sheet = ParseStyleSheet(source); + Assert.AreEqual(1, sheet.Rules.Length); + Assert.IsInstanceOf(sheet.Rules[0]); + var supports = sheet.Rules[0] as CssSupportsRule; + Assert.AreEqual(1, supports.Rules.Length); + } } } diff --git a/src/AngleSharp.Css.Tests/Styling/CssSheet.cs b/src/AngleSharp.Css.Tests/Styling/CssSheet.cs index cf2d448a..eb3a6f6f 100644 --- a/src/AngleSharp.Css.Tests/Styling/CssSheet.cs +++ b/src/AngleSharp.Css.Tests/Styling/CssSheet.cs @@ -767,6 +767,225 @@ public void CssSheetWithDataUrlAsBackgroundImage() Assert.AreEqual("71px", decl.GetWidth()); } + [Test] + public void CssSheetWithUnquotedDataUrlAsBackgroundImage() + { + var sheet = ParseStyleSheet("a { background-image: url(data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=); color: red }"); + var rule = sheet.Rules[0] as CssStyleRule; + Assert.IsNotNull(rule); + Assert.AreEqual(2, rule.Style.Length); + var decl = rule.Style as ICssStyleDeclaration; + Assert.AreEqual("url(\"data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=\")", decl.GetBackgroundImage()); + } + + [Test] + public void CssSheetWithUnquotedUrlKeepsSemicolonAsLastDeclaration() + { + var sheet = ParseStyleSheet("a { background-image: url(data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=) }"); + var rule = sheet.Rules[0] as CssStyleRule; + Assert.IsNotNull(rule); + Assert.AreEqual(1, rule.Style.Length); + var decl = rule.Style as ICssStyleDeclaration; + Assert.AreEqual("url(\"data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=\")", decl.GetBackgroundImage()); + } + + [Test] + public void CssSheetWithUnquotedUrlIsCaseInsensitive() + { + var sheet = ParseStyleSheet("a { background-image: URL(data:x;y); color: red }"); + var decl = (sheet.Rules[0] as CssStyleRule).Style as ICssStyleDeclaration; + Assert.AreEqual("url(\"data:x;y\")", decl.GetBackgroundImage()); + Assert.AreEqual("rgba(255, 0, 0, 1)", decl.GetColor()); + } + + [Test] + public void CssSheetWithUnquotedUrlContainingCurlyBrace() + { + var sheet = ParseStyleSheet("a { background-image: url(a}b); color: red }"); + var decl = (sheet.Rules[0] as CssStyleRule).Style as ICssStyleDeclaration; + Assert.AreEqual("url(\"a}b\")", decl.GetBackgroundImage()); + Assert.AreEqual("rgba(255, 0, 0, 1)", decl.GetColor()); + } + + [Test] + public void CssSheetWithUnquotedUrlContainingEscapedSemicolon() + { + var sheet = ParseStyleSheet("a { background-image: url(a\\;b); color: red }"); + var decl = (sheet.Rules[0] as CssStyleRule).Style as ICssStyleDeclaration; + Assert.AreEqual("url(\"a;b\")", decl.GetBackgroundImage()); + Assert.AreEqual("rgba(255, 0, 0, 1)", decl.GetColor()); + } + + [Test] + public void CssSheetWithUnquotedUrlSurroundedByWhitespace() + { + var sheet = ParseStyleSheet("a { background-image: url( data:image/svg+xml;base64,AAA= ); color: red }"); + var decl = (sheet.Rules[0] as CssStyleRule).Style as ICssStyleDeclaration; + Assert.AreEqual("url(\"data:image/svg+xml;base64,AAA=\")", decl.GetBackgroundImage()); + } + + [Test] + public void CssSheetWithQuotedUrlContainingClosingParenthesis() + { + var sheet = ParseStyleSheet("a { background-image: url( \"a)b\" ); color: red }"); + var decl = (sheet.Rules[0] as CssStyleRule).Style as ICssStyleDeclaration; + Assert.AreEqual("url(\"a)b\")", decl.GetBackgroundImage()); + Assert.AreEqual("rgba(255, 0, 0, 1)", decl.GetColor()); + } + + [Test] + public void CssSheetWithUnquotedUrlInShorthandAndImportant() + { + var sheet = ParseStyleSheet("a { background: url(x;y) no-repeat !important; color: red }"); + var rule = sheet.Rules[0] as CssStyleRule; + var decl = rule.Style as ICssStyleDeclaration; + Assert.AreEqual("url(\"x;y\")", decl.GetBackgroundImage()); + Assert.AreEqual("important", decl.GetPropertyPriority("background")); + Assert.AreEqual("rgba(255, 0, 0, 1)", decl.GetColor()); + } + + [Test] + public void CssSheetWithUnquotedUrlInsideMediaRule() + { + var sheet = ParseStyleSheet("@media (min-width:1px) { a { background-image: url(data:image/svg+xml;base64,QQ==); color: red } }"); + var media = sheet.Rules[0] as CssMediaRule; + Assert.IsNotNull(media); + var decl = (media.Rules[0] as CssStyleRule).Style as ICssStyleDeclaration; + Assert.AreEqual("url(\"data:image/svg+xml;base64,QQ==\")", decl.GetBackgroundImage()); + } + + [Test] + public void CssSheetWithUnquotedUrlInSupportsCondition() + { + var sheet = ParseStyleSheet("@supports (background-image: url(a;b)) { a { color: red } }"); + Assert.AreEqual(1, sheet.Rules.Length); + var supports = sheet.Rules[0] as CssSupportsRule; + Assert.IsNotNull(supports); + Assert.AreEqual("(background-image: url(a;b))", supports.ConditionText); + } + + [Test] + public void CssSheetWithFunctionEndingInUrlIsNotAUrlToken() + { + var sheet = ParseStyleSheet("a { background-image: myurl(a;b); color: red }"); + var rule = sheet.Rules[0] as CssStyleRule; + Assert.AreEqual(1, rule.Style.Length); + var decl = rule.Style as ICssStyleDeclaration; + Assert.AreEqual("rgba(255, 0, 0, 1)", decl.GetColor()); + } + + [Test] + public void CssSheetWithEmptyUrlKeepsTheDeclaration() + { + var sheet = ParseStyleSheet("a { background-image: url(); color: red }"); + var rule = sheet.Rules[0] as CssStyleRule; + Assert.AreEqual(2, rule.Style.Length); + var decl = rule.Style as ICssStyleDeclaration; + Assert.AreEqual("url(\"\")", decl.GetBackgroundImage()); + Assert.AreEqual("rgba(255, 0, 0, 1)", decl.GetColor()); + } + + [Test] + public void CssSheetWithEmptyUrlContainingSpacesKeepsTheDeclaration() + { + var sheet = ParseStyleSheet("a { background-image: url( ); color: red }"); + var decl = (sheet.Rules[0] as CssStyleRule).Style as ICssStyleDeclaration; + Assert.AreEqual("url(\"\")", decl.GetBackgroundImage()); + } + + [Test] + public void CssSheetWithWhitespaceInsideUnquotedUrlDropsTheDeclaration() + { + var sheet = ParseStyleSheet("a { background-image: url(a b); color: red }"); + var rule = sheet.Rules[0] as CssStyleRule; + Assert.AreEqual(1, rule.Style.Length); + var decl = rule.Style as ICssStyleDeclaration; + Assert.AreEqual("rgba(255, 0, 0, 1)", decl.GetColor()); + } + + [Test] + public void CssSheetWithParenthesisInsideUnquotedUrlDropsTheDeclaration() + { + var sheet = ParseStyleSheet("a { background-image: url(a(b); color: red }"); + var rule = sheet.Rules[0] as CssStyleRule; + Assert.AreEqual(1, rule.Style.Length); + var decl = rule.Style as ICssStyleDeclaration; + Assert.AreEqual("rgba(255, 0, 0, 1)", decl.GetColor()); + } + + [Test] + public void CssSheetWithQuoteInsideUnquotedUrlDropsTheDeclaration() + { + var sheet = ParseStyleSheet("a { background-image: url(a\"b); color: red }"); + var rule = sheet.Rules[0] as CssStyleRule; + Assert.AreEqual(1, rule.Style.Length); + var decl = rule.Style as ICssStyleDeclaration; + Assert.AreEqual("rgba(255, 0, 0, 1)", decl.GetColor()); + } + + [Test] + public void CssSheetWithBadUrlInShorthandDropsTheWholeDeclaration() + { + var sheet = ParseStyleSheet("a { background: url(a b) red }"); + var rule = sheet.Rules[0] as CssStyleRule; + Assert.AreEqual(0, rule.Style.Length); + } + + [Test] + public void CssSheetWithBadUrlDoesNotAffectFollowingRules() + { + var sheet = ParseStyleSheet("a { background-image: url(a b) } b { color: red }"); + Assert.AreEqual(2, sheet.Rules.Length); + Assert.AreEqual(0, (sheet.Rules[0] as CssStyleRule).Style.Length); + var decl = (sheet.Rules[1] as CssStyleRule).Style as ICssStyleDeclaration; + Assert.AreEqual("rgba(255, 0, 0, 1)", decl.GetColor()); + } + + [Test] + public void CssSheetWithUnterminatedUnquotedUrlKeepsTheDeclaration() + { + var sheet = ParseStyleSheet("a { background-image: url(abc"); + var decl = (sheet.Rules[0] as CssStyleRule).Style as ICssStyleDeclaration; + Assert.AreEqual("url(\"abc\")", decl.GetBackgroundImage()); + } + + [Test] + public void CssSheetImportWithBadUrlIsDropped() + { + var sheet = ParseStyleSheet("@import url(a b); a { color: red }"); + Assert.AreEqual(1, sheet.Rules.Length); + Assert.IsInstanceOf(sheet.Rules[0]); + } + + [Test] + public void CssSheetImportWithUnterminatedUrlIsKept() + { + var sheet = ParseStyleSheet("@import url(abc"); + Assert.AreEqual(1, sheet.Rules.Length); + var import = sheet.Rules[0] as CssImportRule; + Assert.IsNotNull(import); + Assert.AreEqual("abc", import.Href); + } + + [Test] + public void CssSheetNamespaceWithBadUrlIsDropped() + { + var sheet = ParseStyleSheet("@namespace x url(a b); a { color: red }"); + Assert.AreEqual(1, sheet.Rules.Length); + Assert.IsInstanceOf(sheet.Rules[0]); + } + + [Test] + public void CssSheetNamespaceAcceptsAStringUri() + { + var sheet = ParseStyleSheet("@namespace x \"http://foo\"; a { color: red }"); + Assert.AreEqual(2, sheet.Rules.Length); + var ns = sheet.Rules[0] as CssNamespaceRule; + Assert.IsNotNull(ns); + Assert.AreEqual("x", ns.Prefix); + Assert.AreEqual("http://foo", ns.NamespaceUri); + } + [Test] public void CssSheetFromStreamWeirdBytesLeadingToInfiniteLoop() { diff --git a/src/AngleSharp.Css.Tests/Styling/CustomPropertyCompatibility.cs b/src/AngleSharp.Css.Tests/Styling/CustomPropertyCompatibility.cs new file mode 100644 index 00000000..d5f750e2 --- /dev/null +++ b/src/AngleSharp.Css.Tests/Styling/CustomPropertyCompatibility.cs @@ -0,0 +1,244 @@ +#nullable disable +namespace AngleSharp.Css.Tests.Styling +{ + using AngleSharp.Css.Dom; + using AngleSharp.Css.Parser; + using AngleSharp.Css.RenderTree; + using AngleSharp.Css.Values; + using AngleSharp.Dom; + using AngleSharp.Text; + using NUnit.Framework; + using System; + using System.Collections.Generic; + using System.Linq; + using static CssConstructionFunctions; + + [TestFixture] + public class CustomPropertyCompatibilityTests + { + [TestCase("visible", "visible")] + [TestCase("var(--b)", "hidden")] + public void OnlyComputedStylesResolveCustomProperties(String value, String expected) + { + using var document = ParseDocument("
"); + var element = document.QuerySelector("div"); + var child = document.QuerySelector("span"); + var styles = document.DefaultView.GetStyleCollection(new DefaultRenderDevice()); + var explicitStyle = styles.ComputeExplicitStyle(element); + var declarations = styles.GetDeclarations(element); + var cascade = styles.ComputeCascadedStyle(child, declarations); + var builder = RenderTreeBuilder.GetInstance(document.DefaultView); + var rendered = builder.RenderElement(element, styles.Device); + var renderedChild = rendered.Children.OfType().Single(); + + foreach (var raw in new[] { explicitStyle, declarations, cascade, styles.GetDeclarations(child), + rendered.SpecifiedStyle, renderedChild.SpecifiedStyle, builder.GetElementStyle(child) }) + { + Assert.AreEqual("var(--a)", raw.GetPropertyValue("--b")); + Assert.AreEqual("var(--b,hidden)", raw.GetPropertyValue("visibility")); + } + + Assert.AreEqual(expected, styles.ComputeDeclarations(child).GetPropertyValue("visibility")); + Assert.AreEqual(expected, renderedChild.ComputedStyle.GetPropertyValue("visibility")); + Assert.AreEqual("var(--a)", declarations.GetPropertyValue("--b")); + } + + [Test] + public void PublicFallbackParserPreservesNestedVariableObjects() + { + var source = new StringSource("var(--a,var(--b,red)))"); + var outer = source.ParseVarFallback() as CssVarValue; + Assert.IsNotNull(outer); + Assert.AreEqual("--a", outer.VariableName); + var inner = outer.DefaultValue as CssVarValue; + Assert.IsNotNull(inner); + Assert.AreEqual("--b", inner.VariableName); + Assert.AreEqual("red", inner.DefaultValue.CssText); + Assert.AreEqual("var(--a, var(--b, red))", outer.CssText); + Assert.AreEqual(')', source.Current); + } + + [Test] + public void ParsedReferencesPreserveNestedFallbackObjects() + { + var property = ParseDeclaration("visibility:var(--a,var(--b,var(--c,hidden)))"); + var reference = (CssReferenceValue)property.RawValue; + var second = reference.References[0].DefaultValue as CssVarValue; + Assert.IsNotNull(second); + Assert.AreEqual("--b", second.VariableName); + var third = second.DefaultValue as CssVarValue; + Assert.IsNotNull(third); + Assert.AreEqual("--c", third.VariableName); + Assert.AreEqual("hidden", third.DefaultValue.CssText); + } + + [Test] + public void PublicReferenceParserPreservesTheSourcePosition() + { + var source = new StringSource("var(--before) var(--after)"); + source.NextTo("var(--before) ".Length); + var index = source.Index; + var reference = source.ParseVars(); + Assert.AreEqual(index, source.Index); + Assert.AreEqual(1, reference.References.Length); + Assert.AreEqual("--after", reference.References[0].VariableName); + Assert.AreEqual("var(--before) var(--after)", reference.CssText); + Assert.AreEqual("after", ((ICssValue)reference).Compute(new TestComputeContext()).CssText); + } + + [TestCase("var(--a)", false)] + [TestCase("var(--a,)", true)] + [TestCase("var(--a, )", true)] + public void EmptyFallbacksAreDistinctFromAbsentFallbacks(String text, Boolean hasFallback) + { + var source = new StringSource(text); + var reference = (CssVarValue)source.ParseVarFallback(); + Assert.AreEqual("--a", reference.VariableName); + Assert.AreEqual(hasFallback, reference.DefaultValue is not null); + Assert.AreEqual(String.Empty, reference.DefaultValue?.CssText ?? String.Empty); + Assert.IsTrue(source.IsDone); + } + + [TestCase(false)] + [TestCase(true)] + public void DeepPublicFallbackTreesRemainIterative(Boolean parseDirectly) + { + const Int32 count = 8192; + var text = String.Concat(Enumerable.Repeat("var(--missing,", count)) + "visible" + new String(')', count); + var reference = parseDirectly ? (CssVarValue)new StringSource(text).ParseVarFallback() : + ((CssReferenceValue)ParseDeclaration("visibility:" + text).RawValue).References[0]; + var current = reference; + var depth = 1; + + while (current.DefaultValue is CssVarValue nested) + { + current = nested; + depth++; + } + + Assert.AreEqual(count, depth); + Assert.AreEqual("visible", current.DefaultValue.CssText); + Assert.AreEqual(text.Replace(",", ", "), reference.CssText); + var context = new TestComputeContext { Converter = ParseDeclaration("visibility:visible").Converter }; + Assert.AreEqual("visible", reference.Compute(context).CssText); + } + + [Test] + public void DirectReferenceComputationUsesSuppliedAndMutableReferences() + { + var reference = new CssReferenceValue("var(--literal)", new[] + { + Tuple.Create(new TextRange(default, default), new CssVarValue("--supplied")), + }); + var context = new TestComputeContext(); + Assert.AreEqual("supplied", ((ICssValue)reference).Compute(context).CssText); + reference.References[0] = new CssVarValue("--modified"); + Assert.AreEqual("modified", ((ICssValue)reference).Compute(context).CssText); + Assert.AreEqual("var(--literal)", reference.CssText); + } + + [Test] + public void DirectReferenceComputationRetainsFirstSuccessfulReference() + { + var reference = new CssReferenceValue("var(--literal)", new[] + { + Tuple.Create(new TextRange(default, default), new CssVarValue("--missing")), + Tuple.Create(new TextRange(default, default), new CssVarValue("--supplied")), + Tuple.Create(new TextRange(default, default), new CssVarValue("--unused")), + }); + var context = new TestComputeContext(); + Assert.AreEqual("supplied", ((ICssValue)reference).Compute(context).CssText); + CollectionAssert.AreEqual(new[] { "--missing", "--supplied" }, context.Names); + } + + [Test] + public void ModifiedParsedReferencesAffectComputedStyles() + { + using var document = ParseDocument("
"); + var element = document.QuerySelector("div"); + var reference = (CssReferenceValue)element.GetStyle().GetProperty("visibility").RawValue; + reference.References[0] = new CssVarValue("--b"); + Assert.AreEqual("hidden", element.ComputeCurrentStyle().GetPropertyValue("visibility")); + Assert.AreEqual("var(--a)", reference.CssText); + } + + [TestCase("--b", "hidden")] + [TestCase("--alias", "collapse")] + public void ModifiedCustomPropertyReferencesParticipateInResolution(String name, String expected) + { + using var document = ParseDocument("
"); + var element = document.QuerySelector("div"); + var reference = (CssReferenceValue)element.GetStyle().GetProperty("--alias").RawValue; + reference.References[0] = new CssVarValue(name); + Assert.AreEqual(expected, element.ComputeCurrentStyle().GetPropertyValue("visibility")); + Assert.AreEqual("var(--a)", element.GetStyle().GetPropertyValue("--alias")); + } + + [Test] + public void ConstructedCustomPropertyReferencesAreNotReparsedFromLiteralText() + { + using var document = ParseDocument("
"); + var element = document.QuerySelector("div"); + ((CssProperty)element.GetStyle().GetProperty("--alias")).RawValue = new CssReferenceValue("var(--a)", new[] + { + Tuple.Create(new TextRange(default, default), new CssVarValue("--missing")), + Tuple.Create(new TextRange(default, default), new CssVarValue("--b")), + }); + Assert.AreEqual("hidden", element.ComputeCurrentStyle().GetPropertyValue("visibility")); + } + + [Test] + public void ModifiedShorthandReferencesParticipateInResolution() + { + using var document = ParseDocument("
"); + var element = document.QuerySelector("div"); + var child = (CssChildValue)element.GetStyle().GetProperty("margin-top").RawValue; + var reference = (CssReferenceValue)child.Parent; + reference.References[0] = new CssVarValue("--b"); + var computed = element.ComputeCurrentStyle(); + Assert.AreEqual("3px", computed.GetPropertyValue("margin-top")); + Assert.AreEqual("4px", computed.GetPropertyValue("margin-right")); + } + + [Test] + public void DirectVariableComputationRetainsFallbackOnFailedComputation() + { + var reference = new CssVarValue("--invalid", new CssIdentifierValue("fallback")); + var context = new TestComputeContext(); + Assert.AreEqual("fallback", reference.Compute(context).CssText); + } + + [Test] + public void ComputationDoesNotSuppressValueExceptions() + { + var reference = new CssVarValue("--throw", new CssIdentifierValue("fallback")); + Assert.Throws(() => reference.Compute(new TestComputeContext())); + } + + private sealed class TestComputeContext : ICssComputeContext + { + public IRenderDevice Device { get; } = new DefaultRenderDevice(); + public IBrowsingContext Context => null; + public IValueConverter Converter { get; set; } + public List Names { get; } = new(); + + public ICssValue Resolve(String name) + { + Names.Add(name); + + if (name == "--invalid") + { + return new CssAnyValue("not a value"); + } + + if (name == "--throw") + { + throw new InvalidOperationException("Test exception"); + } + + return name == "--missing" ? null : new CssIdentifierValue(name.Substring(2)); + } + } + } +} diff --git a/src/AngleSharp.Css.Tests/Styling/CustomPropertyCycles.cs b/src/AngleSharp.Css.Tests/Styling/CustomPropertyCycles.cs new file mode 100644 index 00000000..0d9a912d --- /dev/null +++ b/src/AngleSharp.Css.Tests/Styling/CustomPropertyCycles.cs @@ -0,0 +1,415 @@ +#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.Collections; + using System.Collections.Generic; + using System.Linq; + using System.Text; + using System.Threading.Tasks; + using static CssConstructionFunctions; + + [TestFixture] + public class CustomPropertyCyclesTests + { + [Test] + public async Task OriginalReproductionDoesNotOverflow() + { + using var context = BrowsingContext.New(Configuration.Default.WithCss()); + using var document = await context.OpenAsync(response => response.Content( + "")); + var style = document.QuerySelector("button").ComputeCurrentStyle(); + Assert.AreEqual("rgba(0, 0, 0, 1)", style.GetPropertyValue("color")); + Assert.AreEqual(String.Empty, style.GetPropertyValue("--a")); + Assert.AreEqual(String.Empty, style.GetPropertyValue("--b")); + } + + [TestCase("--a:var(--a)")] + [TestCase("--a:var(--a,visible)")] + [TestCase("--a:var(--b);--b:var(--a)")] + [TestCase("--a:var(--b,visible);--b:var(--a,visible)")] + [TestCase("--a:var(--b);--b:var(--c);--c:var(--a)")] + [TestCase("--present:visible;--a:var(--present,var(--a))")] + [TestCase("--present:visible;--a:var(--present,calc(var(--a)))")] + [TestCase(@"--a:var(--\61,visible)")] + [TestCase(@"--a:v\61 r(--a,visible)")] + [TestCase("--a:var(--b,var(--c));--b:var(--a);--c:var(--b,visible)")] + public void EveryCyclicMemberIsInvalid(String declarations) + { + using var document = ParseDocument("
"); + var element = document.QuerySelector("div"); + element.SetAttribute("style", declarations + ";visibility:var(--a,hidden);--outside:var(--a,visible);display:block"); + var style = element.ComputeCurrentStyle(); + Assert.AreEqual("hidden", style.GetPropertyValue("visibility")); + Assert.AreEqual("visible", style.GetPropertyValue("--outside")); + Assert.AreEqual("block", style.GetPropertyValue("display")); + + foreach (var name in new[] { "--a", "--b", "--c" }) + { + Assert.AreEqual(String.Empty, style.GetPropertyValue(name), name); + } + } + + [TestCase("var(--missing,hidden)", "hidden")] + [TestCase("var(--missing,var(--other,hidden))", "hidden")] + [TestCase("var(--missing,var(--other,var(--third,hidden)))", "hidden")] + [TestCase("var(--missing)", "visible")] + [TestCase("var(--missing,)", "visible")] + public void MissingVariablesAndNestedFallbacks(String value, String expected) + { + using var document = ParseDocument("
"); + var element = document.QuerySelector("div"); + element.SetAttribute("style", "visibility:" + value); + Assert.AreEqual(expected, element.ComputeCurrentStyle().GetPropertyValue("visibility")); + } + + [TestCase("--a:var(--a)", "var(--a)")] + [TestCase("--a:12px", "var(--a,visible)")] + [TestCase("--a:var(--missing,)", "var(--a,visible)")] + public void InvalidAtComputedValueTimeUsesInheritanceNotPreviousDeclaration(String custom, String value) + { + using var document = ParseDocument("
"); + var element = document.QuerySelector("span"); + element.SetAttribute("style", custom + ";visibility:visible;visibility:" + value + ";width:10px;width:var(--missing)"); + var style = element.ComputeCurrentStyle(); + Assert.AreEqual("hidden", style.GetPropertyValue("visibility")); + Assert.AreEqual("auto", style.GetPropertyValue("width")); + } + + [Test] + public void ResolvedTokensAreNotConvertedUsingTheCustomPropertyConverter() + { + using var document = ParseDocument("
"); + var style = document.QuerySelector("div").ComputeCurrentStyle(); + Assert.AreEqual("0", style.GetPropertyValue("--a")); + Assert.AreEqual("0", style.GetPropertyValue("opacity")); + Assert.AreEqual("0", style.GetPropertyValue("width")); + } + + [TestCase("--a:red;--b:var(--a);--c:var(--a);--d:var(--b) var(--c)", "red red")] + [TestCase("--d:var(--missing,)", "")] + public void AcyclicDiamondsAndEmptyFallbacksRemainValid(String declarations, String expected) + { + using var document = ParseDocument("
"); + var element = document.QuerySelector("div"); + element.SetAttribute("style", declarations); + var style = element.ComputeCurrentStyle(); + Assert.AreEqual(expected, style.GetPropertyValue("--d")); + Assert.IsNotInstanceOf(style.GetProperty("--d").RawValue); + } + + [TestCase("--a:visible;--b:var(--a)", "--a:hidden", "visible")] + [TestCase("--a:var(--b);--b:var(--a)", "--a:visible", "hidden")] + [TestCase("--a:visible;--b:var(--a)", "--b:initial", "hidden")] + [TestCase("--a:visible;--b:var(--a)", "--a:hidden;--b:inherit", "visible")] + [TestCase("--a:visible;--b:var(--a)", "--a:hidden;--b:unset", "visible")] + public void InheritanceUsesTheParentsResolvedCustomValues(String parent, String child, String expected) + { + using var document = ParseDocument("
"); + document.QuerySelector("div").SetAttribute("style", parent); + var element = document.QuerySelector("span"); + element.SetAttribute("style", child + ";visibility:var(--b,hidden)"); + Assert.AreEqual(expected, element.ComputeCurrentStyle().GetPropertyValue("visibility")); + } + + [TestCase("var(--b)", "hidden")] + [TestCase("hidden", "hidden")] + [TestCase("visible", "visible")] + public void SharedRulesAreStillLocalDeclarations(String childValue, String expected) + { + using var document = ParseDocument( + "
Child
Sibling
"); + var child = document.QuerySelector("#c"); + var sibling = document.QuerySelector("#s"); + var parent = document.QuerySelector("#p"); + var styles = document.DefaultView.GetStyleCollection(new DefaultRenderDevice()); + var parentStyle = styles.ComputeDeclarations(parent); + + Assert.AreEqual(expected, child.ComputeCurrentStyle().GetPropertyValue("visibility")); + Assert.AreEqual(expected, styles.ComputeDeclarationsWithParent(child, parentStyle).GetPropertyValue("visibility")); + var rendered = RenderTreeBuilder.GetInstance(document.DefaultView).RenderElement(parent, styles.Device); + var renderedChild = rendered.Children.OfType().Single(node => node.Ref == child); + Assert.AreEqual(expected, renderedChild.ComputedStyle.GetPropertyValue("visibility")); + var cascade = styles.ComputeCascadedStyle(child, parentStyle); + Assert.AreEqual(expected, cascade.Compute(new CssComputeContext(styles.Device, document.Context, cascade, parentStyle)).GetPropertyValue("visibility")); + Assert.AreEqual("visible", sibling.ComputeCurrentStyle().GetPropertyValue("visibility")); + Assert.AreEqual("visible", parent.ComputeCurrentStyle().GetPropertyValue("visibility")); + Assert.AreEqual(expected, child.ComputeCurrentStyle().GetPropertyValue("visibility")); + + using var inlineDocument = ParseDocument("
" + + "
"); + Assert.AreEqual(expected, inlineDocument.QuerySelector("div div").ComputeCurrentStyle().GetPropertyValue("visibility")); + } + + [Test] + public void InheritedOrdinaryValuesAreNotRecomputedAgainstChildVariables() + { + using var document = ParseDocument("
"); + Assert.AreEqual("hidden", document.QuerySelector("span").ComputeCurrentStyle().GetPropertyValue("visibility")); + } + + [TestCase("initial", "visible")] + [TestCase("inherit", "hidden")] + [TestCase("unset", "hidden")] + public void SubstitutedCssWideKeywordsAreAppliedToConsumers(String keyword, String expected) + { + using var document = ParseDocument("
"); + var element = document.QuerySelector("span"); + var styles = document.DefaultView.GetStyleCollection(new DefaultRenderDevice()); + var computed = element.ComputeCurrentStyle(); + Assert.AreEqual(expected, computed.GetPropertyValue("visibility")); + Assert.AreEqual(keyword, computed.GetPropertyValue("--a")); + Assert.AreEqual(keyword, computed.Compute(new CssComputeContext(styles.Device, document.Context, computed)).GetPropertyValue("--a")); + } + + [TestCase("--a:var(--a);--a:visible", "visible")] + [TestCase("--a:var(--a)!important;--a:visible", "hidden")] + [TestCase("--a:visible;--a:var(--a)", "hidden")] + public void OnlyTheWinningDeclarationParticipatesInTheGraph(String text, String expected) + { + using var document = ParseDocument("
"); + Assert.AreEqual(expected, document.QuerySelector("div").ComputeCurrentStyle().GetPropertyValue("visibility")); + } + + [TestCase("--a:var(--a);margin:var(--a)", "0", "0")] + [TestCase("--a:var(--a);margin:var(--a,1px 2px)", "1px", "2px")] + [TestCase("--a:1px 2px;margin:var(--a)", "1px", "2px")] + [TestCase("--a:var(--a);margin:3px var(--a,4px)", "3px", "4px")] + public void ShorthandsUseTheCompleteSubstitutedValue(String text, String top, String right) + { + using var document = ParseDocument("
"); + var style = document.QuerySelector("div").ComputeCurrentStyle(); + Assert.AreEqual(top, style.GetPropertyValue("margin-top")); + Assert.AreEqual(right, style.GetPropertyValue("margin-right")); + Assert.AreEqual(top, style.GetPropertyValue("margin-bottom")); + Assert.AreEqual(right, style.GetPropertyValue("margin-left")); + } + + [TestCase("'var(--a)'")] + [TestCase("\"var(--a)\"")] + [TestCase("url('var(--a)')")] + [TestCase("visible /*var(--a)*/")] + [TestCase("myvar(--a)")] + public void LiteralVariableTextDoesNotCreateDependencies(String text) + { + var value = new CssVariableValue(text); + Assert.IsEmpty(value.Dependencies); + Assert.AreEqual(text, value.Substitute(_ => null)); + } + + [TestCase(@"var(--\61)", "--a")] + [TestCase(@"v\61 r(--a)", "--a")] + [TestCase("VAR(--A)", "--A")] + [TestCase("var(/*comment*/--a)", "--a")] + public void DependenciesUseDecodedCaseSensitiveNames(String text, String name) + { + var value = new CssVariableValue(text); + Assert.AreEqual(new[] { name }, value.Dependencies.ToArray()); + Assert.AreEqual("red", value.Substitute(n => n == name ? new CssAnyValue("red") : null)); + } + + [Test] + public void CustomNamesAreCaseSensitiveThroughoutCssomAndComputation() + { + using var document = ParseDocument("
"); + var element = document.QuerySelector("div"); + Assert.AreEqual("hidden", element.GetStyle().GetPropertyValue("--a")); + Assert.AreEqual("visible", element.GetStyle().GetPropertyValue("--A")); + Assert.AreEqual("visible", element.ComputeCurrentStyle().GetPropertyValue("visibility")); + element.GetStyle().RemoveProperty("--A"); + Assert.AreEqual("hidden", element.ComputeCurrentStyle().GetPropertyValue("--a")); + Assert.AreEqual(String.Empty, element.ComputeCurrentStyle().GetPropertyValue("--A")); + } + + [Test] + public void MutationAndPriorityDoNotChangeSharedDeclarationObjects() + { + using var document = ParseDocument("" + + "
"); + var element = document.QuerySelector("#a"); + var other = document.QuerySelector("#b"); + var sheet = (ICssStyleSheet)document.GetStyleSheets().Single(); + var source = sheet.Rules[0].CssText; + var inline = element.GetStyle().CssText; + Assert.AreEqual("hidden", element.ComputeCurrentStyle().GetPropertyValue("visibility")); + Assert.AreEqual(source, sheet.Rules[0].CssText); + Assert.AreEqual(inline, element.GetStyle().CssText); + element.GetStyle().SetProperty("--b", "visible"); + Assert.AreEqual("visible", element.ComputeCurrentStyle().GetPropertyValue("visibility")); + Assert.AreEqual("hidden", other.ComputeCurrentStyle().GetPropertyValue("visibility")); + Assert.AreEqual(source, sheet.Rules[0].CssText); + element.GetStyle().RemoveProperty("--b"); + Assert.AreEqual("hidden", element.ComputeCurrentStyle().GetPropertyValue("visibility")); + } + + [Test] + public void SubstitutionPreservesSurroundingTokensAndTokenBoundaries() + { + using var document = ParseDocument("
"); + var style = document.QuerySelector("div").ComputeCurrentStyle(); + Assert.AreEqual("rgba(255, 0, 0, 1)", style.GetPropertyValue("color")); + Assert.AreEqual("auto", style.GetPropertyValue("width")); + } + + [Test] + public void MatchingIsReusedAtEachInheritanceBoundary() + { + using var document = ParseDocument("
"); + var styles = new CountingStyleCollection(document.DefaultView.GetStyleCollection(new DefaultRenderDevice())); + var element = document.QuerySelector("span"); + styles.ComputeDeclarations(element); + Assert.AreEqual(element.GetAncestors().OfType().Count() + 1, styles.Enumerations); + var parent = styles.ComputeDeclarations(element.ParentElement); + var before = styles.Enumerations; + styles.ComputeDeclarationsWithParent(element, parent); + Assert.AreEqual(before + 1, styles.Enumerations); + } + + [Test] + public void ComponentDetectionAgreesWithReachability() + { + const Int32 count = 16; + var random = new Random(241); + + for (var sample = 0; sample < 100; sample++) + { + var reachable = new Boolean[count, count]; + var text = new StringBuilder(); + + for (var i = 0; i < count; i++) + { + text.Append("--v").Append(i).Append(':'); + + if (random.Next(3) == 0) + { + text.Append("red;"); + } + else + { + var first = random.Next(count); + var second = random.Next(count); + reachable[i, first] = reachable[i, second] = true; + text.Append("var(--v").Append(first).Append(",var(--v").Append(second).Append(",red));"); + } + } + + for (var k = 0; k < count; k++) + { + for (var i = 0; i < count; i++) + { + for (var j = 0; j < count; j++) + { + reachable[i, j] |= reachable[i, k] && reachable[k, j]; + } + } + } + + var resolver = new CssCustomPropertyResolver(ParseDeclarations(text.ToString())); + + for (var i = 0; i < count; i++) + { + var value = resolver.Resolve("--v" + i); + Assert.AreEqual(reachable[i, i], value is null, "Sample {0}, variable {1}", sample, i); + + if (value is not null) + { + Assert.AreEqual("red", value.CssText.Replace("/**/", String.Empty)); + } + } + } + } + + [Test] + public void SubstitutionLimitIncludesTheBoundary() + { + var variable = new CssVariableValue("var(--a)"); + var maximum = new String('x', CssVariableValue.MaxSubstitutionLength); + Assert.AreEqual(maximum, variable.Substitute(_ => new CssAnyValue(maximum))); + Assert.IsNull(variable.Substitute(_ => new CssAnyValue(maximum + "x"))); + } + + [TestCase(false)] + [TestCase(true)] + public void LongNamedChainsAndCyclesUseBoundedStackSpace(Boolean cycle) + { + const Int32 count = 4096; + var text = new StringBuilder(); + + for (var i = 0; i < count - 1; i++) + { + text.Append("--v").Append(i).Append(":var(--v").Append(i + 1).Append(");"); + } + + text.Append("--v").Append(count - 1).Append(cycle ? ":var(--v0);" : ":visible;"); + text.Append("visibility:var(--v0,hidden)"); + using var document = ParseDocument("
"); + var element = document.QuerySelector("div"); + element.SetAttribute("style", text.ToString()); + Assert.AreEqual(cycle ? "hidden" : "visible", element.ComputeCurrentStyle().GetPropertyValue("visibility")); + } + + [TestCase(false)] + [TestCase(true)] + public void DeepFallbacksUseBoundedStackSpace(Boolean rawFallback) + { + const Int32 count = 8192; + var prefix = rawFallback ? "var(--missing,calc(" : "var(--missing,"; + var suffix = rawFallback ? "))" : ")"; + var text = String.Concat(Enumerable.Repeat(prefix, count)) + "red" + String.Concat(Enumerable.Repeat(suffix, count)); + using var document = ParseDocument(""); + var element = document.QuerySelector("span"); + element.GetStyle().SetProperty("--a", text); + element.GetStyle().SetProperty("color", "var(--a,blue)"); + Assert.IsNotNull(element.GetStyle().GetProperty("--a").RawValue); + var style = element.ComputeCurrentStyle(); + Assert.IsNotNull(style); + + if (!rawFallback) + { + Assert.AreEqual("rgba(255, 0, 0, 1)", style.GetPropertyValue("color")); + } + } + + [Test] + public void ExponentialSubstitutionIsBounded() + { + var text = new StringBuilder("--v0:red;"); + + for (var i = 1; i < 24; i++) + { + text.Append("--v").Append(i).Append(":var(--v").Append(i - 1).Append(") var(--v").Append(i - 1).Append(");"); + } + + using var document = ParseDocument("
"); + var element = document.QuerySelector("div"); + element.SetAttribute("style", text + "visibility:var(--v23,hidden)"); + Assert.AreEqual("hidden", element.ComputeCurrentStyle().GetPropertyValue("visibility")); + } + + private sealed class CountingStyleCollection : IStyleCollection + { + private readonly IStyleCollection _inner; + + public CountingStyleCollection(IStyleCollection inner) => _inner = inner; + + public IRenderDevice Device => _inner.Device; + + public Int32 Enumerations { get; private set; } + + public IEnumerator GetEnumerator() + { + Enumerations++; + return _inner.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + } +} diff --git a/src/AngleSharp.Css.Tests/Values/Calc.cs b/src/AngleSharp.Css.Tests/Values/Calc.cs index b3bb8813..1b3cae70 100644 --- a/src/AngleSharp.Css.Tests/Values/Calc.cs +++ b/src/AngleSharp.Css.Tests/Values/Calc.cs @@ -1,8 +1,13 @@ +#nullable disable namespace AngleSharp.Css.Tests.Values { + using AngleSharp.Css.Dom; using AngleSharp.Css.Parser; + using AngleSharp.Css.Values; + using AngleSharp.Dom; using AngleSharp.Text; using NUnit.Framework; + using System; using static CssConstructionFunctions; [TestFixture] @@ -100,5 +105,122 @@ public void IntegerCanBeUsedWithCalc() Assert.IsTrue(property.HasValue); Assert.AreEqual("calc(21 + 5 - 4 * 2)", property.Value); } + + [Test] + public void CalcAdditionOfLengthsIsComputed() + { + var document = ParseDocument("

"); + var style = document.QuerySelector("p").ComputeCurrentStyle(); + Assert.AreEqual("120px", style.GetWidth()); + } + + [Test] + public void CalcSubtractionOfLengthsIsComputed() + { + var document = ParseDocument("

"); + var style = document.QuerySelector("p").ComputeCurrentStyle(); + Assert.AreEqual("80px", style.GetWidth()); + } + + [Test] + public void CalcAdditionOfTimesIsComputed() + { + var document = ParseDocument("

"); + var style = document.QuerySelector("p").ComputeCurrentStyle(); + Assert.AreEqual("50ms", style.GetTransitionDuration()); + } + + [TestCase("calc(2 * 10px)", "20px")] + [TestCase("calc(10px * 2)", "20px")] + [TestCase("calc(20px / 2)", "10px")] + public void CalcLengthWithUnitlessOperandIsComputed(String expression, String expected) + { + var document = ParseDocument($"

"); + var style = document.QuerySelector("p").ComputeCurrentStyle(); + Assert.AreEqual(expected, style.GetWidth()); + } + + [TestCase("calc(10px - 2px - 3px)", "5px")] + [TestCase("calc(100px - 10px - 20px - 30px)", "40px")] + [TestCase("calc(30px - 10px + 5px)", "25px")] + [TestCase("calc(10px + 20px - 5px)", "25px")] + [TestCase("calc(50px - (10px - 5px))", "45px")] + public void CalcSameOperatorChainIsLeftAssociative(String expression, String expected) + { + var document = ParseDocument($"

"); + var style = document.QuerySelector("p").ComputeCurrentStyle(); + Assert.AreEqual(expected, style.GetWidth()); + } + + [TestCase("calc(100px / 2 / 5)", "10px")] + [TestCase("calc(100px / 2 * 5)", "250px")] + [TestCase("calc(100px * 2 / 5)", "40px")] + [TestCase("calc(1px * 2 * 3)", "6px")] + public void CalcMultiplicativeChainIsLeftAssociative(String expression, String expected) + { + var document = ParseDocument($"

"); + var style = document.QuerySelector("p").ComputeCurrentStyle(); + Assert.AreEqual(expected, style.GetWidth()); + } + + [TestCase("calc(2 * 3px + 1px)", "7px")] + [TestCase("calc(21px + 5px - 4px * 2)", "18px")] + public void CalcMixedPrecedenceIsComputed(String expression, String expected) + { + var document = ParseDocument($"

"); + var style = document.QuerySelector("p").ComputeCurrentStyle(); + Assert.AreEqual(expected, style.GetWidth()); + } + + [TestCase("opacity", "calc(10px / 20px)", "0.5")] + [TestCase("opacity", "calc(2s / 8s)", "0.25")] + [TestCase("flex-grow", "calc(100px / 50px)", "2")] + [TestCase("z-index", "calc(100px / 25px)", "4")] + [TestCase("line-height", "calc(40px / 20px)", "2")] + public void CalcDivisionOfEqualUnitsYieldsNumber(String property, String expression, String expected) + { + var document = ParseDocument($"

"); + var style = document.QuerySelector("p").ComputeCurrentStyle(); + Assert.AreEqual(expected, style.GetPropertyValue(property)); + } + + [TestCase("width", "calc(100px / 2)", "50px")] + [TestCase("width", "calc(100px * 3 / 2)", "150px")] + [TestCase("width", "calc(100px / 2px * 3px)", "150px")] + [TestCase("transition-duration", "calc(2s / 4)", "500ms")] + public void CalcDivisionByNumberKeepsUnitOfLeftOperand(String property, String expression, String expected) + { + var document = ParseDocument($"

"); + var style = document.QuerySelector("p").ComputeCurrentStyle(); + Assert.AreEqual(expected, style.GetPropertyValue(property)); + } + + [TestCase("opacity", "calc(1 / 4)", "0.25")] + [TestCase("opacity", "calc(2 * 3)", "6")] + [TestCase("flex-shrink", "calc(20 / 8)", "2.5")] + public void CalcOfUnitlessOperandsStaysUnitless(String property, String expression, String expected) + { + var document = ParseDocument($"

"); + var style = document.QuerySelector("p").ComputeCurrentStyle(); + Assert.AreEqual(expected, style.GetPropertyValue(property)); + } + + [TestCase(typeof(CssAngleValue))] + [TestCase(typeof(CssFrequencyValue))] + [TestCase(typeof(CssIntegerValue))] + [TestCase(typeof(CssLengthValue))] + [TestCase(typeof(CssNumberValue))] + [TestCase(typeof(CssPercentageValue))] + [TestCase(typeof(CssResolutionValue))] + [TestCase(typeof(CssTimeValue))] + public void MetricValueCanBeCreatedWithAnotherValue(Type type) + { + var template = (ICssMetricValue)Activator.CreateInstance(type, 2.0); + var result = template.WithValue(5.0); + + Assert.IsInstanceOf(type, result); + Assert.AreEqual(5.0, ((ICssMetricValue)result).Value); + Assert.AreEqual(template.UnitString, ((ICssMetricValue)result).UnitString); + } } } diff --git a/src/AngleSharp.Css.Tests/Values/ErrorHandling.cs b/src/AngleSharp.Css.Tests/Values/ErrorHandling.cs index 4cfcbd21..7fff3936 100644 --- a/src/AngleSharp.Css.Tests/Values/ErrorHandling.cs +++ b/src/AngleSharp.Css.Tests/Values/ErrorHandling.cs @@ -11,8 +11,10 @@ namespace AngleSharp.Css.Tests.Values public class ErrorHandlingTests { [Test] - public void ParseInlineStyleWithToleratedInvalidValueShouldReturnThatValue() + public void ParseInlineStyleWithBadUnquotedUrlShouldDropThatDeclaration() { + // An unquoted url() may not contain '(' - that makes it a bad url, and + // the whole declaration is dropped rather than guessing at its value. var source = "
"; var document = ParseDocument(source, new CssParserOptions { @@ -20,6 +22,19 @@ public void ParseInlineStyleWithToleratedInvalidValueShouldReturnThatValue() IsIncludingUnknownRules = true }); var div = document.QuerySelector("div"); + Assert.AreEqual(0, div.GetStyle().Length); + } + + [Test] + public void ParseInlineStyleWithQuotedUrlShouldReturnThatValue() + { + var source = "
"; + var document = ParseDocument(source, new CssParserOptions + { + IsIncludingUnknownDeclarations = true, + IsIncludingUnknownRules = true + }); + var div = document.QuerySelector("div"); Assert.AreEqual(1, div.GetStyle().Length); Assert.AreEqual("background-image", div.GetStyle()[0]); Assert.AreEqual("url(\"javascript:alert(1)\")", div.GetStyle().GetBackgroundImage()); diff --git a/src/AngleSharp.Css/Constants/CssKeywords.cs b/src/AngleSharp.Css/Constants/CssKeywords.cs index 4d36e61b..7b03468b 100644 --- a/src/AngleSharp.Css/Constants/CssKeywords.cs +++ b/src/AngleSharp.Css/Constants/CssKeywords.cs @@ -2126,5 +2126,35 @@ public static class CssKeywords /// The manipulation keyword for touch-action property. /// public static readonly String Manipulation = "manipulation"; + + /// + /// The no-preference keyword for the user preference media features. + /// + public static readonly String NoPreference = "no-preference"; + + /// + /// The reduce keyword for the user preference media features. + /// + public static readonly String Reduce = "reduce"; + + /// + /// The more keyword for the prefers-contrast media feature. + /// + public static readonly String More = "more"; + + /// + /// The less keyword for the prefers-contrast media feature. + /// + public static readonly String Less = "less"; + + /// + /// The custom keyword for the prefers-contrast media feature. + /// + public static readonly String Custom = "custom"; + + /// + /// The active keyword for the forced-colors media feature. + /// + public static readonly String Active = "active"; } } diff --git a/src/AngleSharp.Css/Constants/FeatureNames.cs b/src/AngleSharp.Css/Constants/FeatureNames.cs index 21a8b198..f75c5ea1 100644 --- a/src/AngleSharp.Css/Constants/FeatureNames.cs +++ b/src/AngleSharp.Css/Constants/FeatureNames.cs @@ -206,5 +206,50 @@ public static class FeatureNames /// Gets the name of the hover feature. /// public readonly static String Hover = "hover"; + + /// + /// Gets the name of the any-pointer feature. + /// + public readonly static String AnyPointer = "any-pointer"; + + /// + /// Gets the name of the any-hover feature. + /// + public readonly static String AnyHover = "any-hover"; + + /// + /// Gets the name of the prefers-color-scheme feature. + /// + public readonly static String PrefersColorScheme = "prefers-color-scheme"; + + /// + /// Gets the name of the prefers-reduced-motion feature. + /// + public readonly static String PrefersReducedMotion = "prefers-reduced-motion"; + + /// + /// Gets the name of the prefers-reduced-transparency feature. + /// + public readonly static String PrefersReducedTransparency = "prefers-reduced-transparency"; + + /// + /// Gets the name of the prefers-reduced-data feature. + /// + public readonly static String PrefersReducedData = "prefers-reduced-data"; + + /// + /// Gets the name of the prefers-contrast feature. + /// + public readonly static String PrefersContrast = "prefers-contrast"; + + /// + /// Gets the name of the forced-colors feature. + /// + public readonly static String ForcedColors = "forced-colors"; + + /// + /// Gets the name of the display-mode feature. + /// + public readonly static String DisplayMode = "display-mode"; } } diff --git a/src/AngleSharp.Css/DefaultRenderDevice.cs b/src/AngleSharp.Css/DefaultRenderDevice.cs index e2e5a7fe..3ddf2b3f 100644 --- a/src/AngleSharp.Css/DefaultRenderDevice.cs +++ b/src/AngleSharp.Css/DefaultRenderDevice.cs @@ -1,11 +1,12 @@ namespace AngleSharp.Css { using System; + using System.Collections.Generic; /// /// Represents the default render device. /// - public class DefaultRenderDevice : IRenderDevice + public class DefaultRenderDevice : IRenderDevice, IRenderDevicePreferences { /// public DeviceCategory Category @@ -70,6 +71,13 @@ public Int32 MonochromeBits set; } = 16; + /// + public IReadOnlyDictionary Preferences + { + get; + set; + } = new Dictionary(StringComparer.OrdinalIgnoreCase); + /// public Int32 Resolution { diff --git a/src/AngleSharp.Css/Dom/Internal/CssMediaQueryList.cs b/src/AngleSharp.Css/Dom/Internal/CssMediaQueryList.cs index b57932d7..4bb56b9f 100644 --- a/src/AngleSharp.Css/Dom/Internal/CssMediaQueryList.cs +++ b/src/AngleSharp.Css/Dom/Internal/CssMediaQueryList.cs @@ -50,8 +50,11 @@ public CssMediaQueryList(IWindow window, IMediaList media) #region Helpers - //TODO use Validate with RenderDevice - private Boolean ComputeMatched(IWindow window) => false; + private Boolean ComputeMatched(IWindow window) + { + var device = window.Document.Context.GetService() ?? new DefaultRenderDevice(); + return _media.Validate(device); + } private void Resized(Object sender, Event ev) { diff --git a/src/AngleSharp.Css/Dom/Internal/CssProperty.cs b/src/AngleSharp.Css/Dom/Internal/CssProperty.cs index e199e267..3a1413a4 100644 --- a/src/AngleSharp.Css/Dom/Internal/CssProperty.cs +++ b/src/AngleSharp.Css/Dom/Internal/CssProperty.cs @@ -111,7 +111,10 @@ public Boolean IsImportant public ICssProperty Compute(ICssComputeContext context) { var propertyContext = new PropertyComputeContext(context, _converter); - var computedValue = _value?.Compute(propertyContext); + var computedValue = _name.StartsWith("--", StringComparison.Ordinal) ? + context.Resolve(_name) ?? CssInvalidValue.Instance : + _value is CssChildValue child ? child.Compute(propertyContext, _name) : + _value is CssReferenceValue reference ? reference.ComputeSubstituted(propertyContext) : _value?.Compute(propertyContext); if (computedValue != _value) { diff --git a/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs b/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs index 1163a282..bc44cbb0 100644 --- a/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs +++ b/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs @@ -37,7 +37,7 @@ sealed class CssStyleDeclaration : ICssStyleDeclaration public CssStyleDeclaration(IBrowsingContext context) { _declarations = new List(); - _declarationIndex = new Dictionary(StringComparer.OrdinalIgnoreCase); + _declarationIndex = new Dictionary(StringComparer.Ordinal); _context = context; } @@ -79,11 +79,13 @@ public String CssText public ICssProperty GetProperty(String name) { + name = name.StartsWith("--", StringComparison.Ordinal) ? name : name.ToLowerFast(); + if (_declarationIndex.TryGetValue(name, out var index) && index < _declarations.Count) { var declaration = _declarations[index]; - if (declaration.Name.Isi(name)) + if (declaration.Name.Is(name)) { return declaration; } @@ -391,6 +393,7 @@ private void SetProperty(ICssProperty property) private void RemovePropertyByName(String propertyName) { + propertyName = propertyName.StartsWith("--", StringComparison.Ordinal) ? propertyName : propertyName.ToLowerFast(); var info = _context.GetDeclarationInfo(propertyName); var longhands = info.Longhands; diff --git a/src/AngleSharp.Css/Extensions/CssMetricValueExtensions.cs b/src/AngleSharp.Css/Extensions/CssMetricValueExtensions.cs new file mode 100644 index 00000000..828229f2 --- /dev/null +++ b/src/AngleSharp.Css/Extensions/CssMetricValueExtensions.cs @@ -0,0 +1,43 @@ +#nullable disable +namespace AngleSharp.Css.Values +{ + using AngleSharp.Css.Dom; + using System; +#if NET5_0_OR_GREATER + using System.Diagnostics.CodeAnalysis; +#endif + + /// + /// A set of helpers for dealing with metric values. + /// + static class CssMetricValueExtensions + { + /// + /// Creates a new metric value of the same type as the given template, but + /// carrying the provided value. + /// + /// The value determining the type to create. + /// The value to use for the created instance. + /// The newly created metric value. +#if NET5_0_OR_GREATER + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicConstructors, typeof(CssAngleValue))] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicConstructors, typeof(CssFrequencyValue))] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicConstructors, typeof(CssIntegerValue))] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicConstructors, typeof(CssLengthValue))] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicConstructors, typeof(CssNumberValue))] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicConstructors, typeof(CssPercentageValue))] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicConstructors, typeof(CssResolutionValue))] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicConstructors, typeof(CssTimeValue))] + [UnconditionalSuppressMessage("Trimming", "IL2072", + Justification = "The constructors of the metric values shipped with AngleSharp.Css are preserved via " + + "DynamicDependency. Metric values implemented outside of AngleSharp.Css have to preserve their " + + "public constructor taking a single Double themselves, e.g., via DynamicDependency.")] +#endif + public static ICssValue WithValue(this ICssMetricValue template, Double value) => + // The single argument constructor of CssLengthValue defaults to pixels, which would + // turn a unitless length (e.g., the result of calc(1 / 4)) into a length in pixels. + template is CssLengthValue length ? + new CssLengthValue(value, length.Type) : + (ICssValue)Activator.CreateInstance(template.GetType(), value); + } +} diff --git a/src/AngleSharp.Css/Extensions/CssOmExtensions.cs b/src/AngleSharp.Css/Extensions/CssOmExtensions.cs index c2f05b58..c57b2564 100644 --- a/src/AngleSharp.Css/Extensions/CssOmExtensions.cs +++ b/src/AngleSharp.Css/Extensions/CssOmExtensions.cs @@ -1,6 +1,7 @@ #nullable disable namespace AngleSharp.Css.Dom { + using AngleSharp.Css.Converters; using AngleSharp.Css.Parser; using AngleSharp.Css.Values; using AngleSharp.Dom; @@ -92,10 +93,40 @@ public static ICssStyleDeclaration Compute(this ICssStyleDeclaration style, ICss foreach (var property in style) { - computedStyle.AddProperty(property.Compute(context)); + var computed = property.Compute(context); + + var substitutedKeyword = property.RawValue is not ICssSpecialValue && computed.RawValue is ICssSpecialValue; + + if ((computed.RawValue is null || substitutedKeyword) && property.RawValue is not null && property is CssProperty cssProperty) + { + var inherit = computed.RawValue is CssInheritValue || + (computed.RawValue is not CssInitialValue && property.CanBeInherited); + var inherited = inherit && context is CssComputeContext cssContext ? + cssContext.InheritedValue(property.Name) : null; + var initial = context.Context.GetDeclarationInfo(property.Name).InitialValue; + var value = inherited ?? (initial is null ? null : cssProperty.Converter.Convert(initial.CssText)?.Compute(context)); + computed = new CssProperty(property.Name, cssProperty.Converter, cssProperty.Flags, value, property.IsImportant); + } + + computedStyle.AddProperty(computed); } return computedStyle; } + + internal static CssStyleDeclaration PrepareComputedDeclarations(this ICssStyleDeclaration style, ICssStyleDeclaration parent, ICssComputeContext context) + { + var declarations = new CssStyleDeclaration(context.Context); + + // Resolve local custom declarations before merging the parent. In + // particular, initial must not disappear through IsInherited. + foreach (var property in style) + { + declarations.AddProperty(property.Name.StartsWith("--", StringComparison.Ordinal) ? property.Compute(context) : property); + } + + declarations.UpdateDeclarations(parent); + return declarations; + } } } diff --git a/src/AngleSharp.Css/Extensions/CssValueExtensions.cs b/src/AngleSharp.Css/Extensions/CssValueExtensions.cs index 84cccc0c..5f936ae6 100644 --- a/src/AngleSharp.Css/Extensions/CssValueExtensions.cs +++ b/src/AngleSharp.Css/Extensions/CssValueExtensions.cs @@ -349,7 +349,7 @@ public static Boolean Is(this ICssValue? value, String keyword) { return true; } - else if (value?.GetType() == typeof(CssConstantValue<>) && value.CssText.Isi(keyword)) + else if (value?.GetType() is { IsGenericType: true } type && type.GetGenericTypeDefinition() == typeof(CssConstantValue<>) && value.CssText.Isi(keyword)) { return true; } diff --git a/src/AngleSharp.Css/Extensions/DeclarationInfoExtensions.cs b/src/AngleSharp.Css/Extensions/DeclarationInfoExtensions.cs index e2a7a549..a61f2257 100644 --- a/src/AngleSharp.Css/Extensions/DeclarationInfoExtensions.cs +++ b/src/AngleSharp.Css/Extensions/DeclarationInfoExtensions.cs @@ -56,7 +56,7 @@ public static IEnumerable NotNull(this IEnumerable enumerable) if (value is ICssRawValue || value is CssChildValue) { - var child = new CssChildValue(value); + var child = new CssChildValue(value, shorthandName: info.Name); return Enumerable .Repeat(child, longhands.Length) .ToArray(); diff --git a/src/AngleSharp.Css/Extensions/MediaListExtensions.cs b/src/AngleSharp.Css/Extensions/MediaListExtensions.cs index 036a3d8b..88d26a6f 100644 --- a/src/AngleSharp.Css/Extensions/MediaListExtensions.cs +++ b/src/AngleSharp.Css/Extensions/MediaListExtensions.cs @@ -31,11 +31,12 @@ public static Boolean Validate(this IMediaFeature feature, IRenderDevice device) return validator?.Validate(feature, device) ?? false; } - public static Boolean Validate(this IMediaList list, IRenderDevice device) => !list.Any(m => !m.Validate(device)); + public static Boolean Validate(this IMediaList list, IRenderDevice device) => !list.Any() || list.Any(m => m.Validate(device)); public static Boolean Validate(this ICssMedium medium, IRenderDevice device) { - if (!String.IsNullOrEmpty(medium.Type) && KnownTypes.Contains(medium.Type) == medium.IsInverse) + if (!String.IsNullOrEmpty(medium.Type) && + ((medium.Type.Is(CssKeywords.All) && medium.IsInverse) || (!KnownTypes.Contains(medium.Type) && !medium.IsInverse))) { return false; } diff --git a/src/AngleSharp.Css/Extensions/RenderDeviceExtensions.cs b/src/AngleSharp.Css/Extensions/RenderDeviceExtensions.cs new file mode 100644 index 00000000..7e4c93eb --- /dev/null +++ b/src/AngleSharp.Css/Extensions/RenderDeviceExtensions.cs @@ -0,0 +1,33 @@ +namespace AngleSharp.Css +{ + using System; + + /// + /// Convenience methods for reading a render device's user preferences, + /// such as the ones a DefaultRenderDevice is configured with. + /// + public static class RenderDeviceExtensions + { + /// + /// Gets the value of the given user preference, or null if the device + /// carries no preferences at all, or none for the given media feature. + /// + /// The render device to read, which may be null. + /// The media feature name, e.g., prefers-color-scheme. + /// The preference's keyword, or null if there is none. + public static String? GetPreference(this IRenderDevice? device, String name) + { + if (device is IRenderDevicePreferences source) + { + var preferences = source.Preferences; + + if (preferences is not null && preferences.TryGetValue(name, out var value) && !String.IsNullOrEmpty(value)) + { + return value; + } + } + + return null; + } + } +} diff --git a/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs b/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs index de94d76f..50606462 100644 --- a/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs +++ b/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs @@ -44,13 +44,7 @@ public static IStyleCollection GetStyleCollection(this IWindow window, IRenderDe /// The optional pseudo selector to use. /// The style declaration containing all the declarations. public static ICssStyleDeclaration ComputeDeclarations(this IStyleCollection styles, IElement element, String? pseudoSelector = null) - { - var ctx = element.Owner?.Context; - var declarations = GetDeclarations(styles, element, pseudoSelector); - var context = new CssComputeContext(styles.Device, ctx, declarations); - - return declarations.Compute(context); - } + => GetComputedDeclarations(styles, element, pseudoSelector); /// /// Gets the declarations for the given element in the context of @@ -63,27 +57,55 @@ public static ICssStyleDeclaration ComputeDeclarations(this IStyleCollection sty public static ICssStyleDeclaration GetDeclarations(this IStyleCollection styles, IElement element, String? pseudoSelector = null) { var ctx = element.Owner?.Context; - var computedStyle = new CssStyleDeclaration(ctx); - var nodes = element.GetAncestors().OfType(); + var declarations = new CssStyleDeclaration(ctx); + var ancestors = element.GetAncestors().OfType(); if (!String.IsNullOrEmpty(pseudoSelector)) { - var pseudoElement = element?.Pseudo(pseudoSelector!.TrimStart(':')); + element = element.Pseudo(pseudoSelector!.TrimStart(':')) ?? element; + } + + declarations.SetDeclarations(styles.ComputeExplicitStyle(element)); + + foreach (var ancestor in ancestors) + { + declarations.UpdateDeclarations(styles.ComputeExplicitStyle(ancestor)); + } + + return declarations; + } + + private static ICssStyleDeclaration GetComputedDeclarations(IStyleCollection styles, IElement element, String? pseudoSelector) + { + var ctx = element.Owner?.Context; + ICssStyleDeclaration? parent = null; + var nodes = new Stack(); + + if (!String.IsNullOrEmpty(pseudoSelector)) + { + var pseudoElement = element.Pseudo(pseudoSelector!.TrimStart(':')); if (pseudoElement is not null) { - element = pseudoElement; + nodes.Push(pseudoElement); } } - computedStyle.SetDeclarations(styles.ComputeExplicitStyle(element!)); + nodes.Push(element); - foreach (var node in nodes) + foreach (var ancestor in element.GetAncestors().OfType()) { - computedStyle.UpdateDeclarations(styles.ComputeExplicitStyle(node)); + nodes.Push(ancestor); } - return computedStyle; + while (nodes.Count > 0) + { + var explicitStyle = styles.ComputeExplicitStyle(nodes.Pop()); + var context = new CssComputeContext(styles.Device, ctx, explicitStyle, parent); + parent = explicitStyle.PrepareComputedDeclarations(parent!, context).Compute(context); + } + + return parent!; } /// @@ -96,9 +118,9 @@ public static ICssStyleDeclaration GetDeclarations(this IStyleCollection styles, /// Returns the cascaded read-only style declaration. public static ICssStyleDeclaration ComputeCascadedStyle(this IStyleCollection styles, IElement element, ICssStyleDeclaration parent) { - var computedStyle = (CssStyleDeclaration)styles.ComputeExplicitStyle(element); - computedStyle.UpdateDeclarations(parent); - return computedStyle; + var declarations = (CssStyleDeclaration)styles.ComputeExplicitStyle(element); + declarations.UpdateDeclarations(parent); + return declarations; } /// @@ -140,18 +162,9 @@ public static ICssStyleDeclaration ComputeExplicitStyle(this IStyleCollection st internal static ICssStyleDeclaration ComputeDeclarationsWithParent(this IStyleCollection styles, IElement element, ICssStyleDeclaration parentComputedStyle) { var ctx = element.Owner?.Context; - var computedStyle = new CssStyleDeclaration(ctx); - - // Element's own cascaded style (CSS rule matching + inline style). - computedStyle.SetDeclarations(styles.ComputeExplicitStyle(element)); - - // Inherit from the parent's already-computed style instead of walking - // all ancestors individually. The parent style already includes the - // full ancestor inheritance chain. - computedStyle.UpdateDeclarations(parentComputedStyle); - - var context = new CssComputeContext(styles.Device, ctx, computedStyle); - return computedStyle.Compute(context); + var explicitStyle = styles.ComputeExplicitStyle(element); + var context = new CssComputeContext(styles.Device, ctx, explicitStyle, parentComputedStyle); + return explicitStyle.PrepareComputedDeclarations(parentComputedStyle, context).Compute(context); } #endregion diff --git a/src/AngleSharp.Css/Factories/DefaultFeatureValidatorFactory.cs b/src/AngleSharp.Css/Factories/DefaultFeatureValidatorFactory.cs index a062df5f..ca64b711 100644 --- a/src/AngleSharp.Css/Factories/DefaultFeatureValidatorFactory.cs +++ b/src/AngleSharp.Css/Factories/DefaultFeatureValidatorFactory.cs @@ -55,9 +55,18 @@ public class DefaultFeatureValidatorFactory : IFeatureValidatorFactory { FeatureNames.Grid, () => new GridFeatureValidator() }, { FeatureNames.Scan, () => new ScanFeatureValidator() }, { FeatureNames.UpdateFrequency, () => new UpdateFrequencyFeatureValidator() }, - { FeatureNames.Scripting, () => new ScanFeatureValidator() }, - { FeatureNames.Pointer, () => new PointerFeatureValidator() }, - { FeatureNames.Hover, () => new HoverFeatureValidator() }, + { FeatureNames.Scripting, () => new ScriptingFeatureValidator() }, + { FeatureNames.Pointer, () => new PointerFeatureValidator(FeatureNames.Pointer) }, + { FeatureNames.AnyPointer, () => new PointerFeatureValidator(FeatureNames.AnyPointer) }, + { FeatureNames.Hover, () => new HoverFeatureValidator(FeatureNames.Hover) }, + { FeatureNames.AnyHover, () => new HoverFeatureValidator(FeatureNames.AnyHover) }, + { FeatureNames.PrefersColorScheme, () => new PreferenceFeatureValidator(FeatureNames.PrefersColorScheme, CssKeywords.NoPreference) }, + { FeatureNames.PrefersReducedMotion, () => new PreferenceFeatureValidator(FeatureNames.PrefersReducedMotion, CssKeywords.NoPreference) }, + { FeatureNames.PrefersReducedTransparency, () => new PreferenceFeatureValidator(FeatureNames.PrefersReducedTransparency, CssKeywords.NoPreference) }, + { FeatureNames.PrefersReducedData, () => new PreferenceFeatureValidator(FeatureNames.PrefersReducedData, CssKeywords.NoPreference) }, + { FeatureNames.PrefersContrast, () => new PreferenceFeatureValidator(FeatureNames.PrefersContrast, CssKeywords.NoPreference) }, + { FeatureNames.ForcedColors, () => new PreferenceFeatureValidator(FeatureNames.ForcedColors, CssKeywords.None) }, + { FeatureNames.DisplayMode, () => new PreferenceFeatureValidator(FeatureNames.DisplayMode, null) }, }; /// diff --git a/src/AngleSharp.Css/FeatureValidators/HoverFeatureValidator.cs b/src/AngleSharp.Css/FeatureValidators/HoverFeatureValidator.cs index 0f0e3b16..82a5998d 100644 --- a/src/AngleSharp.Css/FeatureValidators/HoverFeatureValidator.cs +++ b/src/AngleSharp.Css/FeatureValidators/HoverFeatureValidator.cs @@ -7,8 +7,22 @@ namespace AngleSharp.Css.FeatureValidators sealed class HoverFeatureValidator : IFeatureValidator { + private readonly String _name; + + public HoverFeatureValidator(String name) + { + _name = name; + } + public Boolean Validate(IMediaFeature feature, IRenderDevice renderDevice) { + var preference = renderDevice.GetPreference(_name); + + if (preference is not null) + { + return PreferenceFeatureValidator.Matches(feature, preference, CssKeywords.None); + } + var hover = HoverAbilityConverter.Convert(feature.Value); if (hover != null) diff --git a/src/AngleSharp.Css/FeatureValidators/PointerFeatureValidator.cs b/src/AngleSharp.Css/FeatureValidators/PointerFeatureValidator.cs index 9627f025..81d18671 100644 --- a/src/AngleSharp.Css/FeatureValidators/PointerFeatureValidator.cs +++ b/src/AngleSharp.Css/FeatureValidators/PointerFeatureValidator.cs @@ -7,8 +7,22 @@ namespace AngleSharp.Css.FeatureValidators sealed class PointerFeatureValidator : IFeatureValidator { + private readonly String _name; + + public PointerFeatureValidator(String name) + { + _name = name; + } + public Boolean Validate(IMediaFeature feature, IRenderDevice renderDevice) { + var preference = renderDevice.GetPreference(_name); + + if (preference is not null) + { + return PreferenceFeatureValidator.Matches(feature, preference, CssKeywords.None); + } + var accuracy = PointerAccuracyConverter.Convert(feature.Value); if (accuracy != null) diff --git a/src/AngleSharp.Css/FeatureValidators/PreferenceFeatureValidator.cs b/src/AngleSharp.Css/FeatureValidators/PreferenceFeatureValidator.cs new file mode 100644 index 00000000..da1a0cda --- /dev/null +++ b/src/AngleSharp.Css/FeatureValidators/PreferenceFeatureValidator.cs @@ -0,0 +1,54 @@ +namespace AngleSharp.Css.FeatureValidators +{ + using AngleSharp.Css.Dom; + using AngleSharp.Text; + using System; + + /// + /// Validates a user preference media feature, e.g., prefers-color-scheme, + /// against the preferences carried by the render device. + /// https://drafts.csswg.org/mediaqueries-5/#mf-user-preferences + /// + sealed class PreferenceFeatureValidator : IFeatureValidator + { + private readonly String _name; + private readonly String? _noPreference; + + /// + /// Creates a validator for the given media feature. + /// + /// The name of the media feature, which is also the key of the preference. + /// The keyword that evaluates to false in a boolean context, if any. + public PreferenceFeatureValidator(String name, String? noPreference) + { + _name = name; + _noPreference = noPreference; + } + + public Boolean Validate(IMediaFeature feature, IRenderDevice renderDevice) + { + var preference = renderDevice.GetPreference(_name); + return preference is not null && Matches(feature, preference, _noPreference); + } + + /// + /// Compares the queried keyword against the preference of the device. + /// A feature used without a value is evaluated in a boolean context, + /// where the keyword standing for "no preference" yields false. + /// https://drafts.csswg.org/mediaqueries-5/#mq-boolean-context + /// + /// The feature to examine. + /// The preference carried by the device. + /// The keyword that evaluates to false in a boolean context, if any. + /// True if the feature is present, otherwise false. + public static Boolean Matches(IMediaFeature feature, String preference, String? noPreference) + { + if (!feature.HasValue) + { + return noPreference is null || !preference.Isi(noPreference); + } + + return preference.Isi(feature.Value); + } + } +} diff --git a/src/AngleSharp.Css/IRenderDevicePreferences.cs b/src/AngleSharp.Css/IRenderDevicePreferences.cs new file mode 100644 index 00000000..0466e6c0 --- /dev/null +++ b/src/AngleSharp.Css/IRenderDevicePreferences.cs @@ -0,0 +1,20 @@ +namespace AngleSharp.Css +{ + using System; + using System.Collections.Generic; + + /// + /// Represents a render device that also carries the user preferences, + /// e.g., the preferred color scheme. + /// + public interface IRenderDevicePreferences + { + /// + /// Gets the user preferences, keyed by the name of the media feature + /// they answer, e.g., "prefers-color-scheme" mapped to "dark". A name + /// that is not contained remains an unknown media feature, i.e., a + /// query using it never matches. + /// + IReadOnlyDictionary Preferences { get; } + } +} diff --git a/src/AngleSharp.Css/Parser/CssBuilder.cs b/src/AngleSharp.Css/Parser/CssBuilder.cs index 344369cd..a798a690 100644 --- a/src/AngleSharp.Css/Parser/CssBuilder.cs +++ b/src/AngleSharp.Css/Parser/CssBuilder.cs @@ -73,6 +73,7 @@ public ICssRule CreateRule(ICssStyleSheet sheet, CssToken token) case CssTokenType.String: case CssTokenType.Url: + case CssTokenType.BadUrl: case CssTokenType.CurlyBracketClose: case CssTokenType.RoundBracketClose: case CssTokenType.SquareBracketClose: @@ -267,11 +268,14 @@ private CssNamespaceRule CreateNamespace(CssNamespaceRule rule, CssToken current rule.Prefix = GetRuleName(ref token); CollectTrivia(rule.Owner, ref token); - if (token.Type == CssTokenType.Url) + if (!token.Is(CssTokenType.String, CssTokenType.Url)) { - rule.NamespaceUri = token.Data; + RaiseErrorOccurred(CssParseError.InvalidToken, token.Position); + JumpToEnd(ref token); + return null; } + rule.NamespaceUri = token.Data; JumpToEnd(ref token); return rule; } @@ -535,7 +539,16 @@ public CssStyleRule CreateStyle(CssStyleRule rule, CssToken current) public CssKeyframeRule CreateKeyframeRule(CssKeyframeRule rule, CssToken current) { CollectTrivia(rule.Owner, ref current); + var position = current.Position; rule.KeyText = GetArgument(ref current); + + if (rule.Key is null) + { + RaiseErrorOccurred(CssParseError.InvalidKeyframe, position); + JumpToRuleEnd(ref current); + return null; + } + FillDeclarations(rule.Owner, rule.Style, NextToken()); return rule; } @@ -547,11 +560,14 @@ private CssKeyframesRule FillKeyframeRules(CssKeyframesRule parentRule) while (token.IsNot(CssTokenType.EndOfFile, CssTokenType.CurlyBracketClose)) { - var rule = new CssKeyframeRule(parentRule.Owner); - CreateKeyframeRule(rule, token); + var rule = CreateKeyframeRule(new CssKeyframeRule(parentRule.Owner), token); token = NextToken(); CollectTrivia(parentRule.Owner, ref token); - parentRule.Add(rule); + + if (rule is not null) + { + parentRule.Add(rule); + } } return parentRule; diff --git a/src/AngleSharp.Css/Parser/CssTokenType.cs b/src/AngleSharp.Css/Parser/CssTokenType.cs index e3d28221..82d85e23 100644 --- a/src/AngleSharp.Css/Parser/CssTokenType.cs +++ b/src/AngleSharp.Css/Parser/CssTokenType.cs @@ -14,6 +14,10 @@ enum CssTokenType : byte /// Url, /// + /// A bad URL token, i.e. a url() that could not be parsed. + /// + BadUrl, + /// /// A color token. /// Color, diff --git a/src/AngleSharp.Css/Parser/CssTokenizer.cs b/src/AngleSharp.Css/Parser/CssTokenizer.cs index 47cd672c..712f6a7f 100644 --- a/src/AngleSharp.Css/Parser/CssTokenizer.cs +++ b/src/AngleSharp.Css/Parser/CssTokenizer.cs @@ -8,6 +8,7 @@ namespace AngleSharp.Css.Parser using AngleSharp.Text; using System; using System.Globalization; + using System.Text; /// /// The CSS tokenizer. @@ -74,6 +75,12 @@ public String ContentFrom(Int32 position) break; } + if ((current == 'u' || current == 'U') && !IsIdentContinuation(previous) && TryAppendUrl(sb, ref current, ref previous)) + { + trailingWhitespace = 0; + continue; + } + if ((current == Symbols.DoubleQuote || current == Symbols.SingleQuote) && previous != Symbols.ReverseSolidus) { trailingWhitespace = 0; @@ -137,6 +144,80 @@ public String ContentFrom(Int32 position) return sb.ToPool(); } + /// + /// Checks if the given character would continue an identifier, i.e. if a + /// following "url(" belongs to a longer function name such as "myurl(". + /// + private static Boolean IsIdentContinuation(Char current) => + current != Symbols.EndOfFile && (current.IsName() || current == Symbols.ReverseSolidus); + + /// + /// Appends a url token starting at the current position, if there is one. + /// The contents of an unquoted url token may contain ';', '{' and '}', + /// which must not be mistaken for the end of the surrounding value. + /// + private Boolean TryAppendUrl(StringBuilder sb, ref Char current, ref Char previous) + { + var start = Position; + var r = GetNext(); + var l = (r == 'r' || r == 'R') ? GetNext() : Symbols.EndOfFile; + var open = (l == 'l' || l == 'L') ? GetNext() : Symbols.EndOfFile; + + if (open != Symbols.RoundBracketOpen) + { + Back(Position - start); + return false; + } + + sb.Append(current).Append(r).Append(l).Append(open); + previous = open; + current = GetNext(); + + while (current.IsSpaceCharacter()) + { + sb.Append(current); + previous = current; + current = GetNext(); + } + + // A quoted url() is an ordinary function token; the string is handled by + // the caller. Only the unquoted form treats ';', '{' and '}' as content. + if (current == Symbols.DoubleQuote || current == Symbols.SingleQuote) + { + return true; + } + + while (current != Symbols.EndOfFile) + { + sb.Append(current); + + if (current == Symbols.RoundBracketClose) + { + previous = current; + current = GetNext(); + return true; + } + + if (current == Symbols.ReverseSolidus) + { + previous = current; + current = GetNext(); + + if (current == Symbols.EndOfFile) + { + break; + } + + sb.Append(current); + } + + previous = current; + current = GetNext(); + } + + return true; + } + internal void RaiseErrorOccurred(CssParseError error, TextPosition position) { Error?.Invoke(this, new CssErrorEvent(error, position)); @@ -260,6 +341,10 @@ private CssToken Data(Char current) Advance(2); return NewCloseComment(); } + else if (c1 == Symbols.Minus) + { + return IdentStart(current); + } } else { @@ -706,7 +791,7 @@ private CssToken IdentStart(Char current) { current = GetNext(); - if (current.IsNameStart() || IsValidEscape(current)) + if (current.IsNameStart() || current == Symbols.Minus || IsValidEscape(current)) { StringBuffer.Append(Symbols.Minus); return IdentRest(current); @@ -1006,7 +1091,7 @@ private CssToken UrlStart() { case Symbols.EndOfFile: RaiseErrorOccurred(CssParseError.EOF); - return NewUrl(String.Empty, bad: true); + return NewUrl(String.Empty, bad: false); case Symbols.DoubleQuote: return UrlDQ(); @@ -1136,7 +1221,7 @@ private CssToken UrlUQ(Char current) } else if (current == Symbols.EndOfFile) { - return NewUrl(FlushBuffer(), bad: true); + return NewUrl(FlushBuffer(), bad: false); } else if (current is Symbols.DoubleQuote or Symbols.SingleQuote or Symbols.RoundBracketOpen || current.IsNonPrintable()) { @@ -1191,50 +1276,26 @@ private CssToken UrlEnd() private CssToken UrlBad() { var current = Current; - var curly = 0; - var round = 1; + // The remnants of a bad url are consumed so that parsing can resume + // after it, but they are not part of any value - they are discarded. while (current != Symbols.EndOfFile) { - if (current == Symbols.Semicolon) - { - Back(); - return NewUrl(FlushBuffer(), true); - } - else if (current == Symbols.CurlyBracketClose && --curly == -1) - { - Back(); - return NewUrl(FlushBuffer(), true); - } - else if (current == Symbols.RoundBracketClose && --round == 0) + if (current == Symbols.RoundBracketClose) { - StringBuffer.Append(current); - return NewUrl(FlushBuffer(), true); + break; } else if (IsValidEscape(current)) { current = GetNext(); - StringBuffer.Append(ConsumeEscape(current)); - } - else - { - if (current == Symbols.RoundBracketOpen) - { - ++round; - } - else if (curly == Symbols.CurlyBracketOpen) - { - ++curly; - } - - StringBuffer.Append(current); + ConsumeEscape(current); } current = GetNext(); } - RaiseErrorOccurred(CssParseError.EOF); - return NewUrl(FlushBuffer(), bad: true); + FlushBuffer(); + return NewUrl(String.Empty, bad: true); } /// @@ -1406,7 +1467,7 @@ private CssToken NewDimension(String data) private CssToken NewUrl(String data, Boolean bad = false) { - return new CssToken(CssTokenType.Url, data) { Position = _position }; + return new CssToken(bad ? CssTokenType.BadUrl : CssTokenType.Url, data) { Position = _position }; } private CssToken NewRange(String data) diff --git a/src/AngleSharp.Css/Parser/Micro/CalcParser.cs b/src/AngleSharp.Css/Parser/Micro/CalcParser.cs index 49bbd36d..7a72f4bf 100644 --- a/src/AngleSharp.Css/Parser/Micro/CalcParser.cs +++ b/src/AngleSharp.Css/Parser/Micro/CalcParser.cs @@ -47,51 +47,12 @@ private static ICssValue ParseExpression(this StringSource source) } private static ICssValue ParseAddExpression(this StringSource source) - { - var left = ParseSubExpression(source); - - if (source.Current == Symbols.Plus) - { - source.SkipCurrentAndSpaces(); - var right = ParseAddExpression(source); - - if (right == null) - { - return null; - } - - return new CssCalcAddExpression(left, right); - } - - return left; - } - - private static ICssValue ParseSubExpression(this StringSource source) { var left = ParseMulExpression(source); - if (source.Current == Symbols.Minus) - { - source.SkipCurrentAndSpaces(); - var right = ParseSubExpression(source); - - if (right == null) - { - return null; - } - - return new CssCalcSubExpression(left, right); - } - - return left; - } - - private static ICssValue ParseMulExpression(this StringSource source) - { - var left = ParseDivExpression(source); - - if (source.Current == Symbols.Asterisk) + while (left != null && (source.Current == Symbols.Plus || source.Current == Symbols.Minus)) { + var add = source.Current == Symbols.Plus; source.SkipCurrentAndSpaces(); var right = ParseMulExpression(source); @@ -100,27 +61,32 @@ private static ICssValue ParseMulExpression(this StringSource source) return null; } - return new CssCalcMulExpression(left, right); + left = add ? + new CssCalcAddExpression(left, right) : + (ICssValue)new CssCalcSubExpression(left, right); } return left; } - private static ICssValue ParseDivExpression(this StringSource source) + private static ICssValue ParseMulExpression(this StringSource source) { var left = ParseBracketExpression(source); - if (source.Current == Symbols.Solidus) + while (left != null && (source.Current == Symbols.Asterisk || source.Current == Symbols.Solidus)) { + var mul = source.Current == Symbols.Asterisk; source.SkipCurrentAndSpaces(); - var right = ParseDivExpression(source); + var right = ParseBracketExpression(source); if (right == null) { return null; } - return new CssCalcDivExpression(left, right); + left = mul ? + new CssCalcMulExpression(left, right) : + (ICssValue)new CssCalcDivExpression(left, right); } return left; diff --git a/src/AngleSharp.Css/Parser/Micro/ConditionParser.cs b/src/AngleSharp.Css/Parser/Micro/ConditionParser.cs index 9fa66f65..55561c83 100644 --- a/src/AngleSharp.Css/Parser/Micro/ConditionParser.cs +++ b/src/AngleSharp.Css/Parser/Micro/ConditionParser.cs @@ -57,8 +57,8 @@ private static IConditionFunction ConjunctionOrDisjunction(this StringSource sou if (ident != null) { - var isAnd = ident.Is(CssKeywords.And); - var isOr = ident.Is(CssKeywords.Or); + var isAnd = ident.Isi(CssKeywords.And); + var isOr = ident.Isi(CssKeywords.Or); if (isAnd || isOr) { @@ -141,7 +141,7 @@ private static IEnumerable Scan(this StringSource source, St source.SkipSpacesAndComments(); ident = source.ParseIdent(); } - while (ident != null && ident.Is(keyword)); + while (ident != null && ident.Isi(keyword)); return conditions; } diff --git a/src/AngleSharp.Css/Parser/Micro/CssUriParser.cs b/src/AngleSharp.Css/Parser/Micro/CssUriParser.cs index 57b52236..083f2180 100644 --- a/src/AngleSharp.Css/Parser/Micro/CssUriParser.cs +++ b/src/AngleSharp.Css/Parser/Micro/CssUriParser.cs @@ -16,18 +16,29 @@ public static class CssUriParser /// public static CssUrlValue ParseUri(this StringSource source) { + var start = source.Index; + if (source.IsFunction(FunctionNames.Url)) { var current = source.SkipSpacesAndComments(); - return current switch + var result = current switch { Symbols.DoubleQuote => DoubleQuoted(source), Symbols.SingleQuote => SingleQuoted(source), - Symbols.RoundBracketClose => new CssUrlValue(String.Empty), + Symbols.RoundBracketClose => Empty(source), Symbols.EndOfFile => new CssUrlValue(String.Empty), _ => Unquoted(source), }; + + if (result is null) + { + // A bad url yields no value at all. Nothing is consumed either, so + // that the caller sees the url() as unparsed instead of as absent. + source.BackTo(start); + } + + return result; } return null; @@ -43,7 +54,7 @@ private static CssUrlValue DoubleQuoted(StringSource source) if (current.IsLineBreak()) { - return Bad(source, buffer); + return Bad(buffer); } else if (Symbols.EndOfFile == current) { @@ -89,7 +100,7 @@ private static CssUrlValue SingleQuoted(StringSource source) if (current.IsLineBreak()) { - return Bad(source, buffer); + return Bad(buffer); } else if (current == Symbols.EndOfFile) { @@ -142,7 +153,7 @@ private static CssUrlValue Unquoted(StringSource source) } else if (current is Symbols.DoubleQuote or Symbols.SingleQuote or Symbols.RoundBracketOpen || current.IsNonPrintable()) { - return Bad(source, buffer); + return Bad(buffer); } else if (current != Symbols.ReverseSolidus) { @@ -154,7 +165,7 @@ private static CssUrlValue Unquoted(StringSource source) } else { - return Bad(source, buffer); + return Bad(buffer); } current = source.Next(); @@ -171,52 +182,19 @@ private static CssUrlValue End(StringSource source, StringBuilder buffer) return new CssUrlValue(buffer.ToPool()); } - return Bad(source, buffer); + return Bad(buffer); } - private static CssUrlValue Bad(StringSource source, StringBuilder buffer) + private static CssUrlValue Empty(StringSource source) { - var current = source.Current; - var curly = 0; - var round = 1; - - while (current != Symbols.EndOfFile) - { - if (current == Symbols.Semicolon) - { - return new CssUrlValue(buffer.ToPool()); - } - else if (current == Symbols.CurlyBracketClose && --curly == -1) - { - return new CssUrlValue(buffer.ToPool()); - } - else if (current == Symbols.RoundBracketClose && --round == 0) - { - source.Next(); - return new CssUrlValue(buffer.ToPool()); - } - else if (source.IsValidEscape()) - { - buffer.Append(source.ConsumeEscape()); - } - else - { - if (current == Symbols.RoundBracketOpen) - { - ++round; - } - else if (current == Symbols.CurlyBracketOpen) - { - ++curly; - } - - buffer.Append(current); - } + source.Next(); + return new CssUrlValue(String.Empty); + } - current = source.Next(); - } - - return new CssUrlValue(buffer.ToPool()); + private static CssUrlValue Bad(StringBuilder buffer) + { + buffer.ToPool(); + return null; } } } diff --git a/src/AngleSharp.Css/Parser/Micro/FunctionParser.cs b/src/AngleSharp.Css/Parser/Micro/FunctionParser.cs index 72d269f6..dbc20746 100644 --- a/src/AngleSharp.Css/Parser/Micro/FunctionParser.cs +++ b/src/AngleSharp.Css/Parser/Micro/FunctionParser.cs @@ -73,7 +73,7 @@ public static CssReferenceValue ParseVars(this StringSource source) continue; } } - + break; } @@ -81,7 +81,7 @@ public static CssReferenceValue ParseVars(this StringSource source) if (refs != null) { - return new CssReferenceValue(source.Content, refs); + return new CssReferenceValue(new CssVariableValue(source.Content), refs); } return null; @@ -116,15 +116,45 @@ public static CssVarValue ParseVar(this StringSource source) /// public static ICssValue ParseVarFallback(this StringSource source) { - if (!source.IsFunction(FunctionNames.Var)) + var names = new Stack(); + ICssValue fallback = null; + var readFallback = true; + + while (source.IsFunction(FunctionNames.Var)) + { + var name = source.ParseCustomIdent(); + var separator = source.SkipGetSkip(); + + if (name is null || (separator != Symbols.Comma && separator != Symbols.RoundBracketClose)) + { + readFallback = false; + break; + } + + names.Push(name); + + if (separator == Symbols.RoundBracketClose) + { + readFallback = false; + break; + } + + source.SkipSpacesAndComments(); + } + + if (readFallback) { var content = source.TakeUntilClosed(); source.SkipCurrentAndSpaces(); - return new CssAnyValue(content); + fallback = new CssAnyValue(content); } - return source.ParseVar(); + while (names.Count > 0) + { + fallback = new CssVarValue(names.Pop(), fallback); + } + return fallback; } /// diff --git a/src/AngleSharp.Css/Parser/Micro/KeyframeParser.cs b/src/AngleSharp.Css/Parser/Micro/KeyframeParser.cs index ca6bc7fa..8ea41c07 100644 --- a/src/AngleSharp.Css/Parser/Micro/KeyframeParser.cs +++ b/src/AngleSharp.Css/Parser/Micro/KeyframeParser.cs @@ -43,11 +43,11 @@ public static IKeyframeSelector ParseKeyframeSelector(this StringSource source) stops.Add(test.Value); } - else if (id.Is(CssKeywords.From)) + else if (id.Isi(CssKeywords.From)) { stops.Add(0f); } - else if (id.Is(CssKeywords.To)) + else if (id.Isi(CssKeywords.To)) { stops.Add(1f); } diff --git a/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs b/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs index cda0ef31..bdbc9070 100644 --- a/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs +++ b/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs @@ -92,16 +92,8 @@ private ElementRenderNode RenderElement( specifiedStyle.UpdateDeclarations(parentSpecifiedStyle); } - var computedDeclarations = new CssStyleDeclaration(_context); - computedDeclarations.SetDeclarations(explicitStyle); - - if (parentComputedStyle is not null) - { - computedDeclarations.UpdateDeclarations(parentComputedStyle); - } - - var computeContext = new CssComputeContext(collection.Device, _context, computedDeclarations); - var computedStyle = computedDeclarations.Compute(computeContext); + var computeContext = new CssComputeContext(collection.Device, _context, explicitStyle, parentComputedStyle); + var computedStyle = explicitStyle.PrepareComputedDeclarations(parentComputedStyle!, computeContext).Compute(computeContext); var children = new List(); var node = new ElementRenderNode(element, parent, children, specifiedStyle, computedStyle); diff --git a/src/AngleSharp.Css/Values/CssChildValue.cs b/src/AngleSharp.Css/Values/CssChildValue.cs index 7aeda032..6ba38a1b 100644 --- a/src/AngleSharp.Css/Values/CssChildValue.cs +++ b/src/AngleSharp.Css/Values/CssChildValue.cs @@ -2,6 +2,7 @@ namespace AngleSharp.Css.Values { using AngleSharp.Css.Dom; + using AngleSharp.Css.Parser; using System; using System.Collections.Generic; @@ -14,6 +15,7 @@ sealed class CssChildValue : ICssValue, IEquatable private readonly ICssValue _parent; private readonly ICssValue _value; + private readonly String _shorthandName; #endregion @@ -24,10 +26,12 @@ sealed class CssChildValue : ICssValue, IEquatable /// /// The reference to the shorthand value. /// The value of the child, if any. - public CssChildValue(ICssValue parent, ICssValue value = null) + /// The shorthand that supplied the pending value. + public CssChildValue(ICssValue parent, ICssValue value = null, String shorthandName = null) { _parent = parent; _value = value; + _shorthandName = shorthandName; } #endregion @@ -73,7 +77,50 @@ ICssValue ICssValue.Compute(ICssComputeContext context) { var parent = _parent.Compute(context); var value = _value?.Compute(context); - return new CssChildValue(parent, value); + return new CssChildValue(parent, value, _shorthandName); + } + + internal ICssValue Compute(ICssComputeContext context, String longhandName) + { + var parent = _parent; + var shorthandName = _shorthandName; + + while (parent is CssChildValue child) + { + shorthandName = child._shorthandName; + parent = child.Parent; + } + + if (shorthandName is not null && parent is ICssRawValue) + { + var values = parent is CssReferenceValue reference ? reference.GetVariableValues() : + new[] { new CssVariableValue(parent.CssText) }; + String text = null; + + foreach (var candidate in values) + { + text = candidate.Substitute(context.Resolve); + + if (text is not null) + { + break; + } + } + + if (text is null) + { + return null; + } + + // Parse the substituted shorthand once its complete token stream + // is known, rather than feeding it to an individual longhand's + // converter and discarding the remaining components. + var parser = context.Context?.GetService() ?? new CssParser(context.Context); + var declarations = parser.ParseDeclaration(shorthandName + ":" + text); + return declarations.GetProperty(longhandName)?.RawValue?.Compute(context); + } + + return ((ICssValue)this).Compute(context); } Boolean IEquatable.Equals(ICssValue other) => other is CssChildValue value && Equals(value); diff --git a/src/AngleSharp.Css/Values/CssComputeContext.cs b/src/AngleSharp.Css/Values/CssComputeContext.cs index 09055071..d2e7255f 100644 --- a/src/AngleSharp.Css/Values/CssComputeContext.cs +++ b/src/AngleSharp.Css/Values/CssComputeContext.cs @@ -8,13 +8,15 @@ sealed class CssComputeContext : ICssComputeContext { private readonly IRenderDevice _device; private readonly IBrowsingContext? _context; - private readonly ICssProperties _properties; + private readonly CssCustomPropertyResolver _variables; + private readonly ICssProperties? _parent; - public CssComputeContext(IRenderDevice device, IBrowsingContext? context, ICssProperties properties) + public CssComputeContext(IRenderDevice device, IBrowsingContext? context, ICssProperties properties, ICssProperties? parent = null) { _device = device ?? new DefaultRenderDevice(); _context = context; - _properties = properties; + _variables = new CssCustomPropertyResolver(properties, parent); + _parent = parent; } public IRenderDevice Device => _device; @@ -23,16 +25,9 @@ public CssComputeContext(IRenderDevice device, IBrowsingContext? context, ICssPr public IValueConverter? Converter => null; - public ICssValue? Resolve(String name) - { - if (name.StartsWith("--")) - { - var property = _properties.FirstOrDefault(m => m.Name.Equals(name, StringComparison.Ordinal)); - return property?.RawValue; - } + public ICssValue? Resolve(String name) => _variables.Resolve(name); - return null; - } + internal ICssValue? InheritedValue(String name) => _parent?.FirstOrDefault(m => m.Name == name)?.RawValue; } } diff --git a/src/AngleSharp.Css/Values/CssCustomPropertyResolver.cs b/src/AngleSharp.Css/Values/CssCustomPropertyResolver.cs new file mode 100644 index 00000000..926b4eb3 --- /dev/null +++ b/src/AngleSharp.Css/Values/CssCustomPropertyResolver.cs @@ -0,0 +1,180 @@ +namespace AngleSharp.Css.Values +{ + using AngleSharp.Css.Dom; + using System; + using System.Collections.Generic; + using System.Linq; + + sealed class CssCustomPropertyResolver + { + private readonly Dictionary _values = new(StringComparer.Ordinal); + + public CssCustomPropertyResolver(IEnumerable properties, ICssProperties? parent = null) + { + if (parent is not null) + { + foreach (var property in parent) + { + if (property.Name.StartsWith("--", StringComparison.Ordinal)) + { + _values[property.Name] = property.RawValue is CssInvalidValue ? null : property.RawValue; + } + } + } + + var nodes = new Dictionary(StringComparer.Ordinal); + + foreach (var property in properties) + { + if (property.Name.StartsWith("--", StringComparison.Ordinal)) + { + var value = property.RawValue; + + if (value is CssAnyValue { IsResolved: true }) + { + _values[property.Name] = value; + continue; + } + + var values = value is CssReferenceValue references ? references.GetVariableValues().ToArray() : + value is null || value is CssInvalidValue ? Array.Empty() : + new[] { new CssVariableValue(value.CssText) }; + var keyword = values.Length == 1 ? values[0].Keyword : null; + + if (String.Equals(keyword, CssKeywords.Inherit, StringComparison.OrdinalIgnoreCase) || + String.Equals(keyword, CssKeywords.Unset, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + _values[property.Name] = null; + + if (values.Length > 0 && !String.Equals(keyword, CssKeywords.Initial, StringComparison.OrdinalIgnoreCase)) + { + nodes[property.Name] = new Node(property.Name, values); + } + } + } + + foreach (var node in nodes.Values) + { + foreach (var value in node.Values) + { + if (!value.IsValid) + { + continue; + } + + foreach (var name in value.Dependencies) + { + if (nodes.TryGetValue(name, out var dependency)) + { + node.Dependencies.Add(dependency); + } + } + } + } + + // Iterative Tarjan traversal: complete components in dependency order. + // All fallback edges participate, even if substitution won't use them. + var index = 0; + var active = new Stack(); + var visits = new Stack(); + var component = new List(); + + foreach (var root in nodes.Values) + { + if (root.Index >= 0) + { + continue; + } + + Enter(root); + + while (visits.Count > 0) + { + var node = visits.Peek(); + + if (node.NextDependency < node.Dependencies.Count) + { + var dependency = node.Dependencies[node.NextDependency++]; + + if (dependency.Index < 0) + { + Enter(dependency); + } + else if (dependency.Active) + { + node.LowLink = Math.Min(node.LowLink, dependency.Index); + } + + continue; + } + + visits.Pop(); + + if (visits.Count > 0) + { + var previous = visits.Peek(); + previous.LowLink = Math.Min(previous.LowLink, node.LowLink); + } + + if (node.LowLink == node.Index) + { + component.Clear(); + Node member; + + do + { + member = active.Pop(); + member.Active = false; + component.Add(member); + } + while (member != node); + + if (component.Count == 1 && !node.Dependencies.Contains(node)) + { + foreach (var value in node.Values) + { + var text = value.Substitute(Resolve); + + if (text is not null) + { + _values[node.Name] = new CssAnyValue(text, isResolved: true); + break; + } + } + } + } + } + } + + void Enter(Node node) + { + node.Index = node.LowLink = index++; + node.Active = true; + active.Push(node); + visits.Push(node); + } + } + + public ICssValue? Resolve(String name) => _values.TryGetValue(name, out var value) ? value : null; + + private sealed class Node + { + public Node(String name, CssVariableValue[] values) + { + Name = name; + Values = values; + } + + public String Name { get; } + public CssVariableValue[] Values { get; } + public List Dependencies { get; } = new(); + public Int32 Index { get; set; } = -1; + public Int32 LowLink { get; set; } + public Int32 NextDependency { get; set; } + public Boolean Active { get; set; } + } + } +} diff --git a/src/AngleSharp.Css/Values/CssVariableValue.cs b/src/AngleSharp.Css/Values/CssVariableValue.cs new file mode 100644 index 00000000..85d403b7 --- /dev/null +++ b/src/AngleSharp.Css/Values/CssVariableValue.cs @@ -0,0 +1,260 @@ +namespace AngleSharp.Css.Values +{ + using AngleSharp.Css.Dom; + using AngleSharp.Css.Parser; + using AngleSharp.Css.Parser.Tokens; + using AngleSharp.Text; + using System; + using System.Collections.Generic; + using System.Text; + + // A flat token stream keeps both dependency discovery and nested fallback + // substitution off the CLR stack. Strings, URLs and comments are opaque. + sealed class CssVariableValue + { + // Bound expansion of small, exponentially growing variable definitions. + internal const Int32 MaxSubstitutionLength = 1024 * 1024; + + private readonly List _tokens = new(); + private readonly Dictionary _references = new(); + + public CssVariableValue(String text) + { + Text = text; + var tokenizer = new CssTokenizer(new TextSource(text)); + var blocks = new Stack(); + var ends = new Dictionary(); + + while (true) + { + var token = tokenizer.Get(); + + if (token.Type == CssTokenType.EndOfFile) + { + break; + } + + var index = _tokens.Count; + _tokens.Add(token); + + if (IsOpen(token.Type)) + { + blocks.Push(index); + } + else if (IsClose(token.Type) && blocks.Count > 0) + { + var start = blocks.Pop(); + IsValid &= Matches(_tokens[start].Type, token.Type); + ends[start] = index; + } + } + + // CSS syntax closes outstanding blocks at EOF. Keep the original + // text for CSSOM serialization, including incomplete URL strings. + while (blocks.Count > 0) + { + var start = blocks.Pop(); + ends[start] = _tokens.Count; + _tokens.Add(new CssToken(CloseType(_tokens[start].Type), String.Empty) + { + Position = new TextPosition(0, 0, text.Length + 1), + }); + } + + for (var i = 0; i < _tokens.Count; i++) + { + var token = _tokens[i]; + + if (token.Type == CssTokenType.Function && token.Data.Equals(FunctionNames.Var, StringComparison.OrdinalIgnoreCase)) + { + var name = SkipTrivia(i + 1); + var separator = SkipTrivia(name + 1); + var valid = ends.TryGetValue(i, out var end) && + name < end && _tokens[name].Type == CssTokenType.Ident && + _tokens[name].Data.StartsWith("--", StringComparison.Ordinal) && + _tokens[name].Data.Length > 2 && + (separator == end || _tokens[separator].Type == CssTokenType.Comma); + + IsValid &= valid; + + if (valid) + { + _references.Add(i, new Reference(_tokens[name].Data, end, separator < end ? separator + 1 : -1)); + } + } + } + } + + public String Text { get; } + + public Boolean IsValid { get; } = true; + + public Boolean HasReferences => _references.Count > 0; + + public IEnumerable Dependencies + { + get + { + foreach (var reference in _references.Values) + { + yield return reference.Name; + } + } + } + + public String? Keyword + { + get + { + var index = SkipTrivia(0); + return index < _tokens.Count && _tokens[index].Type == CssTokenType.Ident && + SkipTrivia(index + 1) == _tokens.Count ? _tokens[index].Data : null; + } + } + + public String? Substitute(Func resolve) + { + if (!IsValid) + { + return null; + } + + if (!HasReferences) + { + return Text; + } + + var result = new StringBuilder(); + var fallbacks = new Stack(); + var cursor = 0; + + for (var i = 0; i < _tokens.Count; i++) + { + if (fallbacks.Count > 0 && fallbacks.Peek() == i) + { + if (!Append(result, cursor, Offset(i)) || !Separate(result, NeedsSeparator(EndOffset(i)))) + { + return null; + } + + cursor = EndOffset(i); + fallbacks.Pop(); + } + else if (_references.TryGetValue(i, out var reference)) + { + if (!Append(result, cursor, Offset(i)) || !Separate(result, result.Length > 0)) + { + return null; + } + + var value = resolve(reference.Name); + + if (value is not null) + { + var text = value.CssText; + + if (text.Length > MaxSubstitutionLength - result.Length) + { + return null; + } + + result.Append(text); + cursor = EndOffset(reference.End); + i = reference.End; + + if (!Separate(result, NeedsSeparator(cursor))) + { + return null; + } + } + else if (reference.Fallback >= 0) + { + cursor = Offset(reference.Fallback - 1) + 1; + i = reference.Fallback - 1; + fallbacks.Push(reference.End); + } + else + { + return null; + } + } + } + + return Append(result, cursor, Text.Length) ? result.ToString().Trim() : null; + } + + private Boolean Append(StringBuilder result, Int32 start, Int32 end) + { + var length = end - start; + + if (length > MaxSubstitutionLength - result.Length) + { + return false; + } + + result.Append(Text, start, length); + return true; + } + + private static Boolean Separate(StringBuilder result, Boolean needed) + { + if (needed && result.Length > 0 && !result[result.Length - 1].IsSpaceCharacter()) + { + if (result.Length > MaxSubstitutionLength - 4) + { + return false; + } + + // Substitution must not turn adjacent tokens into a new token + // (for example, var(--number)px must not become a dimension). + result.Append("/**/"); + } + + return true; + } + + private Int32 Offset(Int32 index) => _tokens[index].Position.Position - 1; + + private Int32 EndOffset(Int32 index) => Math.Min(Offset(index) + 1, Text.Length); + + private Boolean NeedsSeparator(Int32 index) => index < Text.Length && !Text[index].IsSpaceCharacter(); + + private Int32 SkipTrivia(Int32 index) + { + while (index < _tokens.Count && (_tokens[index].Type == CssTokenType.Whitespace || _tokens[index].Type == CssTokenType.Comment)) + { + index++; + } + + return index; + } + + private static Boolean IsOpen(CssTokenType type) => + type == CssTokenType.Function || type == CssTokenType.RoundBracketOpen || + type == CssTokenType.SquareBracketOpen || type == CssTokenType.CurlyBracketOpen; + + private static Boolean IsClose(CssTokenType type) => + type == CssTokenType.RoundBracketClose || type == CssTokenType.SquareBracketClose || + type == CssTokenType.CurlyBracketClose; + + private static CssTokenType CloseType(CssTokenType type) => + type == CssTokenType.SquareBracketOpen ? CssTokenType.SquareBracketClose : + type == CssTokenType.CurlyBracketOpen ? CssTokenType.CurlyBracketClose : CssTokenType.RoundBracketClose; + + private static Boolean Matches(CssTokenType open, CssTokenType close) => CloseType(open) == close; + + private readonly struct Reference + { + public Reference(String name, Int32 end, Int32 fallback) + { + Name = name; + End = end; + Fallback = fallback; + } + + public String Name { get; } + public Int32 End { get; } + public Int32 Fallback { get; } + } + } +} diff --git a/src/AngleSharp.Css/Values/Expressions/CssCalcAddExpression.cs b/src/AngleSharp.Css/Values/Expressions/CssCalcAddExpression.cs index 2b5add84..ed1bd7f2 100644 --- a/src/AngleSharp.Css/Values/Expressions/CssCalcAddExpression.cs +++ b/src/AngleSharp.Css/Values/Expressions/CssCalcAddExpression.cs @@ -60,7 +60,7 @@ ICssValue ICssValue.Compute(ICssComputeContext context) if (left is ICssMetricValue x && right is ICssMetricValue y && x.UnitString == y.UnitString) { var result = x.Value + y.Value; - return (ICssValue)Activator.CreateInstance(x.GetType(), result); + return x.WithValue(result); } return null; diff --git a/src/AngleSharp.Css/Values/Expressions/CssCalcDivExpression.cs b/src/AngleSharp.Css/Values/Expressions/CssCalcDivExpression.cs index c3fcfdae..68245cc6 100644 --- a/src/AngleSharp.Css/Values/Expressions/CssCalcDivExpression.cs +++ b/src/AngleSharp.Css/Values/Expressions/CssCalcDivExpression.cs @@ -54,18 +54,33 @@ public CssCalcDivExpression(ICssValue left, ICssValue right) ICssValue ICssValue.Compute(ICssComputeContext context) { - var left = _left.Compute(context); - var right = _right.Compute(context); + var left = ComputeValue(_left, context); + var right = ComputeValue(_right, context); - if (left is ICssMetricValue x && right is ICssMetricValue y && x.UnitString == y.UnitString) + if (left is ICssMetricValue x && right is ICssMetricValue y) { - var result = x.Value / y.Value; - return (ICssValue)Activator.CreateInstance(x.GetType(), result); + // Dividing by a plain number scales the left operand, keeping its unit. + if (y.UnitString.Length == 0) + { + return x.WithValue(x.Value / y.Value); + } + + // Dividing two values sharing a unit cancels the unit out, i.e. the + // result is a plain number (calc(40px / 20px) is 2, not 2px). + if (x.UnitString == y.UnitString) + { + return new CssLengthValue(x.Value / y.Value, CssLengthValue.Unit.None); + } } return null; } + private static ICssValue ComputeValue(ICssValue value, ICssComputeContext context) + { + return value is CssLengthValue length && length.Type == CssLengthValue.Unit.None ? value : value.Compute(context); + } + Boolean IEquatable.Equals(ICssValue other) => Object.ReferenceEquals(this, other); #endregion diff --git a/src/AngleSharp.Css/Values/Expressions/CssCalcMulExpression.cs b/src/AngleSharp.Css/Values/Expressions/CssCalcMulExpression.cs index 51daa6cb..a6d2858a 100644 --- a/src/AngleSharp.Css/Values/Expressions/CssCalcMulExpression.cs +++ b/src/AngleSharp.Css/Values/Expressions/CssCalcMulExpression.cs @@ -54,18 +54,33 @@ public CssCalcMulExpression(ICssValue left, ICssValue right) ICssValue ICssValue.Compute(ICssComputeContext context) { - var left = _left.Compute(context); - var right = _right.Compute(context); + var left = ComputeValue(_left, context); + var right = ComputeValue(_right, context); if (left is ICssMetricValue x && right is ICssMetricValue y && x.UnitString == y.UnitString) { var result = x.Value * y.Value; - return (ICssValue)Activator.CreateInstance(x.GetType(), result); + return x.WithValue(result); + } + + if (left is ICssMetricValue unitlessLeft && right is ICssMetricValue unitRight && unitlessLeft.UnitString.Length == 0) + { + return unitRight.WithValue(unitlessLeft.Value * unitRight.Value); + } + + if (left is ICssMetricValue unitLeft && right is ICssMetricValue unitlessRight && unitlessRight.UnitString.Length == 0) + { + return unitLeft.WithValue(unitLeft.Value * unitlessRight.Value); } return null; } + private static ICssValue ComputeValue(ICssValue value, ICssComputeContext context) + { + return value is CssLengthValue length && length.Type == CssLengthValue.Unit.None ? value : value.Compute(context); + } + Boolean IEquatable.Equals(ICssValue other) => Object.ReferenceEquals(this, other); #endregion diff --git a/src/AngleSharp.Css/Values/Expressions/CssCalcSubExpression.cs b/src/AngleSharp.Css/Values/Expressions/CssCalcSubExpression.cs index e8d7642f..2aa2102f 100644 --- a/src/AngleSharp.Css/Values/Expressions/CssCalcSubExpression.cs +++ b/src/AngleSharp.Css/Values/Expressions/CssCalcSubExpression.cs @@ -60,7 +60,7 @@ ICssValue ICssValue.Compute(ICssComputeContext context) if (left is ICssMetricValue x && right is ICssMetricValue y && x.UnitString == y.UnitString) { var result = x.Value - y.Value; - return (ICssValue)Activator.CreateInstance(x.GetType(), result); + return x.WithValue(result); } return null; diff --git a/src/AngleSharp.Css/Values/Functions/CssVarValue.cs b/src/AngleSharp.Css/Values/Functions/CssVarValue.cs index 957fedf5..58a79680 100644 --- a/src/AngleSharp.Css/Values/Functions/CssVarValue.cs +++ b/src/AngleSharp.Css/Values/Functions/CssVarValue.cs @@ -5,6 +5,7 @@ namespace AngleSharp.Css.Values using AngleSharp.Text; using System; using System.Collections.Generic; + using System.Text; /// /// Represents a CSS var replacement. @@ -78,18 +79,33 @@ public String CssText { get { - var fn = FunctionNames.Var; - var args = new List - { - _variableName, - }; + var text = StringBuilderPool.Obtain(); + var value = this; + var depth = 0; - if (_defaultValue is not null) + // use a max-depth of 16384 to avoid stack overflows in case of circular references + while (depth < 16384) { - args.Add(_defaultValue.CssText); + text.Append(FunctionNames.Var).Append('(').Append(value._variableName); + depth++; + + if (value._defaultValue is not null) + { + text.Append(", "); + + if (value._defaultValue is CssVarValue nested) + { + value = nested; + continue; + } + + text.Append(value._defaultValue.CssText); + } + + break; } - return fn.CssFunction(String.Join(", ", args)); + return text.Append(')', depth).ToPool(); } } @@ -121,14 +137,25 @@ public Boolean Equals(CssVarValue other) /// The resolved value or null. public ICssValue Compute(ICssComputeContext context) { - var value = context.Resolve(_variableName)?.Compute(context); + var reference = this; - if (value is not null) + while (true) { - return value; - } + var value = context.Resolve(reference._variableName)?.Compute(context); - return _defaultValue?.Compute(context); + if (value is not null) + { + return value; + } + + if (reference._defaultValue is CssVarValue nested) + { + reference = nested; + continue; + } + + return reference._defaultValue?.Compute(context); + } } Boolean IEquatable.Equals(ICssValue other) => other is CssVarValue value && Equals(value); diff --git a/src/AngleSharp.Css/Values/Primitives/CssColorValue.cs b/src/AngleSharp.Css/Values/Primitives/CssColorValue.cs index 5caa2fb2..46be0a84 100644 --- a/src/AngleSharp.Css/Values/Primitives/CssColorValue.cs +++ b/src/AngleSharp.Css/Values/Primitives/CssColorValue.cs @@ -469,6 +469,12 @@ public static CssColorValue FromHwba(Double h, Double w, Double b, Double alpha) /// public static Boolean UseHex { get; set; } + /// + /// Gets or sets if the CSSOM serialization rules should be used, i.e., + /// if the alpha channel of an opaque color should be omitted. + /// + public static Boolean UseSpecSerialization { get; set; } + /// /// Gets the CSS text representation. /// @@ -495,6 +501,17 @@ public String CssText return color; } + else if (UseSpecSerialization && _alpha == 255) + { + var fn = FunctionNames.Rgb; + var args = String.Join(", ", new[] + { + R.ToString(CultureInfo.InvariantCulture), + G.ToString(CultureInfo.InvariantCulture), + B.ToString(CultureInfo.InvariantCulture), + }); + return fn.CssFunction(args); + } else { var fn = FunctionNames.Rgba; diff --git a/src/AngleSharp.Css/Values/Raws/CssAnyValue.cs b/src/AngleSharp.Css/Values/Raws/CssAnyValue.cs index f942f97c..33418082 100644 --- a/src/AngleSharp.Css/Values/Raws/CssAnyValue.cs +++ b/src/AngleSharp.Css/Values/Raws/CssAnyValue.cs @@ -3,6 +3,8 @@ namespace AngleSharp.Css.Values { using AngleSharp.Css.Converters; using AngleSharp.Css.Dom; + using AngleSharp.Css.Parser; + using AngleSharp.Text; using System; /// @@ -22,11 +24,15 @@ sealed class CssAnyValue : ICssRawValue /// Creates a new unknown value with the given literal content. /// /// The serialized value representation.. - public CssAnyValue(String text) + /// Whether variable substitution has already been performed. + public CssAnyValue(String text, Boolean isResolved = false) { _text = text; + IsResolved = isResolved; } + internal Boolean IsResolved { get; } + #endregion #region Properties @@ -51,11 +57,14 @@ ICssValue ICssValue.Compute(ICssComputeContext context) if (converter is not null && converter is not AnyValueConverter) { - var value = converter.Convert(_text); - return value?.Compute(context); + var source = new StringSource(_text); + source.SkipSpacesAndComments(); + var value = converter.Convert(source); + source.SkipSpacesAndComments(); + return source.IsDone ? value?.Compute(context) : null; } - return null; + return IsResolved ? this : null; } Boolean IEquatable.Equals(ICssValue other) => other is CssAnyValue o && _text == o.CssText; diff --git a/src/AngleSharp.Css/Values/Raws/CssInvalidValue.cs b/src/AngleSharp.Css/Values/Raws/CssInvalidValue.cs new file mode 100644 index 00000000..0f5a2705 --- /dev/null +++ b/src/AngleSharp.Css/Values/Raws/CssInvalidValue.cs @@ -0,0 +1,22 @@ +namespace AngleSharp.Css.Values +{ + using AngleSharp.Css.Dom; + using System; + + // Unlike a missing declaration, guaranteed-invalid is inherited as-is and + // cannot be repaired by resolving its original references on a descendant. + sealed class CssInvalidValue : ICssValue + { + public static readonly CssInvalidValue Instance = new(); + + private CssInvalidValue() + { + } + + public String CssText => String.Empty; + + public ICssValue Compute(ICssComputeContext context) => this; + + public Boolean Equals(ICssValue? other) => other is CssInvalidValue; + } +} diff --git a/src/AngleSharp.Css/Values/Raws/CssReferenceValue.cs b/src/AngleSharp.Css/Values/Raws/CssReferenceValue.cs index f1ab66ea..b1844e46 100644 --- a/src/AngleSharp.Css/Values/Raws/CssReferenceValue.cs +++ b/src/AngleSharp.Css/Values/Raws/CssReferenceValue.cs @@ -17,6 +17,8 @@ public sealed class CssReferenceValue : ICssRawValue private readonly String _value; private readonly TextRange[] _ranges; private readonly CssVarValue[] _references; + private readonly CssVariableValue _tokens; + private readonly CssVarValue[] _parsedReferences; #endregion @@ -34,6 +36,13 @@ public CssReferenceValue(String value, IEnumerable _references = references.Select(m => m.Item2).ToArray(); } + internal CssReferenceValue(CssVariableValue value, IEnumerable> references) + : this(value.Text, references) + { + _parsedReferences = (CssVarValue[])_references.Clone(); + _tokens = value; + } + #endregion #region Properties @@ -77,6 +86,56 @@ ICssValue ICssValue.Compute(ICssComputeContext context) return null; } + internal ICssValue ComputeSubstituted(ICssComputeContext context) + { + // Direct value computation retains the public References contract. + // Only unmodified parser-owned values use token-stream substitution + // at the property computation boundary. + if (HasCustomReferences) + { + return ((ICssValue)this).Compute(context); + } + + var text = _tokens.Substitute(context.Resolve); + return text is null ? null : ((ICssValue)new CssAnyValue(text)).Compute(context); + } + + internal IEnumerable GetVariableValues() + { + if (HasCustomReferences) + { + foreach (var reference in _references) + { + yield return new CssVariableValue(reference.CssText); + } + } + else + { + yield return _tokens; + } + } + + private Boolean HasCustomReferences + { + get + { + if (_tokens is null) + { + return true; + } + + for (var i = 0; i < _references.Length; i++) + { + if (!Object.ReferenceEquals(_references[i], _parsedReferences[i])) + { + return true; + } + } + + return false; + } + } + Boolean IEquatable.Equals(ICssValue other) => Object.ReferenceEquals(this, other); #endregion diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 37fcde26..bace0b84 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.0.2 + 1.1.0 enable latest true