From f399f6374b2e702ab5a978f81b989a02b8b2684c Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Wed, 2 Sep 2026 18:40:28 +0300 Subject: [PATCH 01/26] Evaluate the media query list in matchMedia against the render device CssMediaQueryList.ComputeMatched returned a constant false, so window.matchMedia(...).IsMatched answered false for every query, including "all" and the empty query, which always match. It now validates the media list against the render device from the browsing context, reusing the very same evaluation that @media rules already go through for the cascade, and falls back to DefaultRenderDevice when no device is registered - the same fallback GetComputedStyle uses. Reported in AngleSharp/AngleSharp#1307. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NqCcJrL3MJecCPRBMQsZyC --- .../Extensions/MatchMedia.cs | 166 ++++++++++++++++++ .../Dom/Internal/CssMediaQueryList.cs | 7 +- 2 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 src/AngleSharp.Css.Tests/Extensions/MatchMedia.cs diff --git a/src/AngleSharp.Css.Tests/Extensions/MatchMedia.cs b/src/AngleSharp.Css.Tests/Extensions/MatchMedia.cs new file mode 100644 index 00000000..c07c5b0d --- /dev/null +++ b/src/AngleSharp.Css.Tests/Extensions/MatchMedia.cs @@ -0,0 +1,166 @@ +#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 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 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/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) { From 19e9fe00460eef9e96bb1c11c24eb795743b0a48 Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Wed, 2 Sep 2026 18:43:43 +0300 Subject: [PATCH 02/26] Add an opt-in switch for CSSOM-compliant color serialization CssColorValue always serializes through rgba(), so an opaque color comes out as rgba(r, g, b, 1) where the CSSOM serialization rules ask for rgb(r, g, b). Changing that by default would be breaking, so this adds UseSpecSerialization next to UseHex: off by default, and when switched on an opaque color serializes as rgb(r, g, b) while anything with an alpha below 1 keeps rgba(r, g, b, a). UseHex still wins when both are active. Reducing a color that was written as a named color back to its name is a separate step and is not part of this change. Closes #227. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NqCcJrL3MJecCPRBMQsZyC --- docs/tutorials/04-Questions.md | 22 +++++-- .../Library/StringRepresentation.cs | 63 +++++++++++++++++++ .../Values/Primitives/CssColorValue.cs | 17 +++++ 3 files changed, 97 insertions(+), 5 deletions(-) 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.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/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; From 883fa7e8a30da03ff6678f808542d697b0ae65cb Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Thu, 3 Sep 2026 09:11:50 +0200 Subject: [PATCH 03/26] Changed version --- src/AngleSharp.Css.Docs/package.json | 2 +- src/Directory.Build.props | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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 From 54f32fb26a9751a5728fcb7e3d66f802ca8395de Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Thu, 3 Sep 2026 09:11:57 +0200 Subject: [PATCH 04/26] Fixed #233 --- src/AngleSharp.Css/Factories/DefaultFeatureValidatorFactory.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AngleSharp.Css/Factories/DefaultFeatureValidatorFactory.cs b/src/AngleSharp.Css/Factories/DefaultFeatureValidatorFactory.cs index a062df5f..6779b61f 100644 --- a/src/AngleSharp.Css/Factories/DefaultFeatureValidatorFactory.cs +++ b/src/AngleSharp.Css/Factories/DefaultFeatureValidatorFactory.cs @@ -55,7 +55,7 @@ public class DefaultFeatureValidatorFactory : IFeatureValidatorFactory { FeatureNames.Grid, () => new GridFeatureValidator() }, { FeatureNames.Scan, () => new ScanFeatureValidator() }, { FeatureNames.UpdateFrequency, () => new UpdateFrequencyFeatureValidator() }, - { FeatureNames.Scripting, () => new ScanFeatureValidator() }, + { FeatureNames.Scripting, () => new ScriptingFeatureValidator() }, { FeatureNames.Pointer, () => new PointerFeatureValidator() }, { FeatureNames.Hover, () => new HoverFeatureValidator() }, }; From ad80087795f76fc12d230993d2d371fb9acbdc59 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Thu, 3 Sep 2026 09:13:38 +0200 Subject: [PATCH 05/26] Updated changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 465f5936..0c59f3be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# 1.1.0 + +Released on Saturday, September 5 2026 + +- Fixed wrong media feature used for scripting (#233) +- Added optional CSSOM compliant color seralization (#229) @lahma +- Added media query list evaluation using `IRenderDevice` (#228) @lahma + # 1.0.2 Released on Friday, August 21 2026. From a88ad31d214208a0509c9417e49bac4525b740cd Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Thu, 3 Sep 2026 09:17:02 +0200 Subject: [PATCH 06/26] Fixed #231 --- CHANGELOG.md | 1 + src/AngleSharp.Css.Tests/Extensions/MatchMedia.cs | 14 ++++++++++++++ .../Extensions/MediaListExtensions.cs | 3 ++- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c59f3be..6b808608 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ Released on Saturday, September 5 2026 - Fixed wrong media feature used for scripting (#233) +- Fixed `not ` is always false (#231) - Added optional CSSOM compliant color seralization (#229) @lahma - Added media query list evaluation using `IRenderDevice` (#228) @lahma diff --git a/src/AngleSharp.Css.Tests/Extensions/MatchMedia.cs b/src/AngleSharp.Css.Tests/Extensions/MatchMedia.cs index c07c5b0d..4f846e1f 100644 --- a/src/AngleSharp.Css.Tests/Extensions/MatchMedia.cs +++ b/src/AngleSharp.Css.Tests/Extensions/MatchMedia.cs @@ -106,6 +106,20 @@ public void MatchMediaNotScreenIsNotMatchedOnScreenDevice() 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() { diff --git a/src/AngleSharp.Css/Extensions/MediaListExtensions.cs b/src/AngleSharp.Css/Extensions/MediaListExtensions.cs index 036a3d8b..20b6843a 100644 --- a/src/AngleSharp.Css/Extensions/MediaListExtensions.cs +++ b/src/AngleSharp.Css/Extensions/MediaListExtensions.cs @@ -35,7 +35,8 @@ public static Boolean Validate(this IMediaFeature feature, IRenderDevice 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; } From bd968f17df060b0e925a43d26d2031791751706f Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Thu, 3 Sep 2026 09:19:21 +0200 Subject: [PATCH 07/26] Fixed #232 --- CHANGELOG.md | 1 + .../Rules/CssMediaFeatures.cs | 16 ++++++++++++++++ .../Extensions/CssValueExtensions.cs | 2 +- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b808608..702940cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ Released on Saturday, September 5 2026 - Fixed wrong media feature used for scripting (#233) +- Fixed wrong rientation and scan evaluation (#232) - Fixed `not ` is always false (#231) - Added optional CSSOM compliant color seralization (#229) @lahma - Added media query list evaluation using `IRenderDevice` (#228) @lahma 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/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; } From 87a30c75a8356c0f333c9c45d627571b0c1e1854 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Thu, 3 Sep 2026 09:23:30 +0200 Subject: [PATCH 08/26] Fixed #230 --- CHANGELOG.md | 1 + src/AngleSharp.Css.Tests/Extensions/MatchMedia.cs | 7 +++++++ src/AngleSharp.Css/Extensions/MediaListExtensions.cs | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 702940cf..f2a6b936 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ 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) diff --git a/src/AngleSharp.Css.Tests/Extensions/MatchMedia.cs b/src/AngleSharp.Css.Tests/Extensions/MatchMedia.cs index 4f846e1f..081f9bdb 100644 --- a/src/AngleSharp.Css.Tests/Extensions/MatchMedia.cs +++ b/src/AngleSharp.Css.Tests/Extensions/MatchMedia.cs @@ -43,6 +43,13 @@ public void MatchMediaPrintIsMatchedOnPrinterDevice() 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() { diff --git a/src/AngleSharp.Css/Extensions/MediaListExtensions.cs b/src/AngleSharp.Css/Extensions/MediaListExtensions.cs index 20b6843a..88d26a6f 100644 --- a/src/AngleSharp.Css/Extensions/MediaListExtensions.cs +++ b/src/AngleSharp.Css/Extensions/MediaListExtensions.cs @@ -31,7 +31,7 @@ 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) { From 16a92f4733da418e423e81ee6de27ee78e92d531 Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Thu, 3 Sep 2026 10:25:38 +0300 Subject: [PATCH 09/26] Answer the user-preference media features from the render device Adds IRenderDevicePreferences, a small interface with a single IReadOnlyDictionary Preferences member that DefaultRenderDevice implements, so a host can say which user preferences its device carries without every existing IRenderDevice implementation having to change. A generic PreferenceFeatureValidator is registered for prefers-color-scheme, prefers-reduced-motion, prefers-reduced-transparency, prefers-contrast, prefers-reduced-data, forced-colors and display-mode, and hover/any-hover and pointer/any-pointer now read the dictionary when it carries them, while keeping their previous answer when it does not. A key that is not set leaves its feature unknown, i.e., the query does not match. Fixes #234 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NqCcJrL3MJecCPRBMQsZyC --- README.md | 31 +++ docs/general/05-Extensibility.md | 33 +++ .../CssConstructionFunctions.cs | 7 + .../Extensions/MediaPreferences.cs | 163 +++++++++++ .../Mocks/PlainRenderDevice.cs | 43 +++ .../Mocks/PreferringRenderDevice.cs | 55 ++++ .../Rules/CssMediaPreferenceFeatures.cs | 258 ++++++++++++++++++ src/AngleSharp.Css/Constants/CssKeywords.cs | 30 ++ src/AngleSharp.Css/Constants/FeatureNames.cs | 45 +++ src/AngleSharp.Css/DefaultRenderDevice.cs | 10 +- .../Extensions/RenderDeviceExtensions.cs | 26 ++ .../DefaultFeatureValidatorFactory.cs | 13 +- .../HoverFeatureValidator.cs | 14 + .../PointerFeatureValidator.cs | 14 + .../PreferenceFeatureValidator.cs | 54 ++++ .../IRenderDevicePreferences.cs | 20 ++ 16 files changed, 813 insertions(+), 3 deletions(-) create mode 100644 src/AngleSharp.Css.Tests/Extensions/MediaPreferences.cs create mode 100644 src/AngleSharp.Css.Tests/Mocks/PlainRenderDevice.cs create mode 100644 src/AngleSharp.Css.Tests/Mocks/PreferringRenderDevice.cs create mode 100644 src/AngleSharp.Css.Tests/Rules/CssMediaPreferenceFeatures.cs create mode 100644 src/AngleSharp.Css/Extensions/RenderDeviceExtensions.cs create mode 100644 src/AngleSharp.Css/FeatureValidators/PreferenceFeatureValidator.cs create mode 100644 src/AngleSharp.Css/IRenderDevicePreferences.cs 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/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/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/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/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/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/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/Extensions/RenderDeviceExtensions.cs b/src/AngleSharp.Css/Extensions/RenderDeviceExtensions.cs new file mode 100644 index 00000000..309cd01b --- /dev/null +++ b/src/AngleSharp.Css/Extensions/RenderDeviceExtensions.cs @@ -0,0 +1,26 @@ +namespace AngleSharp.Css +{ + using System; + + 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. + /// + 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/Factories/DefaultFeatureValidatorFactory.cs b/src/AngleSharp.Css/Factories/DefaultFeatureValidatorFactory.cs index 6779b61f..ca64b711 100644 --- a/src/AngleSharp.Css/Factories/DefaultFeatureValidatorFactory.cs +++ b/src/AngleSharp.Css/Factories/DefaultFeatureValidatorFactory.cs @@ -56,8 +56,17 @@ public class DefaultFeatureValidatorFactory : IFeatureValidatorFactory { FeatureNames.Scan, () => new ScanFeatureValidator() }, { FeatureNames.UpdateFrequency, () => new UpdateFrequencyFeatureValidator() }, { FeatureNames.Scripting, () => new ScriptingFeatureValidator() }, - { FeatureNames.Pointer, () => new PointerFeatureValidator() }, - { FeatureNames.Hover, () => new HoverFeatureValidator() }, + { 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; } + } +} From c5c01becd709acd8c05220012207f204e34a404a Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Thu, 3 Sep 2026 16:29:33 +0300 Subject: [PATCH 10/26] Make the render-device preference helper public Review feedback on #235: the convenience layer over IRenderDevice is useful to hosts that configure a DefaultRenderDevice, so the class is public and documented. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NqCcJrL3MJecCPRBMQsZyC --- src/AngleSharp.Css/Extensions/RenderDeviceExtensions.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/AngleSharp.Css/Extensions/RenderDeviceExtensions.cs b/src/AngleSharp.Css/Extensions/RenderDeviceExtensions.cs index 309cd01b..7e4c93eb 100644 --- a/src/AngleSharp.Css/Extensions/RenderDeviceExtensions.cs +++ b/src/AngleSharp.Css/Extensions/RenderDeviceExtensions.cs @@ -2,12 +2,19 @@ namespace AngleSharp.Css { using System; - static class RenderDeviceExtensions + /// + /// 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) From 1d570d0cb215bf1d628d6a8664c93715443d47d0 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Thu, 3 Sep 2026 16:19:29 +0200 Subject: [PATCH 11/26] Updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2a6b936..7ef49e36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Released on Saturday, September 5 2026 - Fixed wrong rientation and scan evaluation (#232) - Fixed `not ` is always false (#231) - 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 From 3680c6a8e05486940d821a091356697c794f658e Mon Sep 17 00:00:00 2001 From: Sebastien Ros <1165805+sebastienros@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:32:57 -0700 Subject: [PATCH 12/26] Fix calc() computation in trimmed / NativeAOT apps The calc add/sub/mul/div expressions create the resulting metric value via Activator.CreateInstance(x.GetType(), result). The trimmer cannot see that call target, so the single-Double constructors of the built-in metric values were removed, making every calc() computation throw MissingMethodException in NativeAOT (and trimmed) applications. The reflection call now lives in a single helper that roots the public constructors of all built-in metric values via DynamicDependency, so the behavior matches the JIT one. ICssMetricValue is public, hence external implementations stay supported - they just have to preserve their own constructor. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a2612f6f-7d43-40de-a31f-96a2600e6f81 --- src/AngleSharp.Css.Tests/Values/Calc.cs | 47 +++++++++++++++++++ .../Extensions/CssMetricValueExtensions.cs | 39 +++++++++++++++ .../Expressions/CssCalcAddExpression.cs | 2 +- .../Expressions/CssCalcDivExpression.cs | 2 +- .../Expressions/CssCalcMulExpression.cs | 2 +- .../Expressions/CssCalcSubExpression.cs | 2 +- 6 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 src/AngleSharp.Css/Extensions/CssMetricValueExtensions.cs diff --git a/src/AngleSharp.Css.Tests/Values/Calc.cs b/src/AngleSharp.Css.Tests/Values/Calc.cs index b3bb8813..0275d163 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,47 @@ 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(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/Extensions/CssMetricValueExtensions.cs b/src/AngleSharp.Css/Extensions/CssMetricValueExtensions.cs new file mode 100644 index 00000000..caf9d9ff --- /dev/null +++ b/src/AngleSharp.Css/Extensions/CssMetricValueExtensions.cs @@ -0,0 +1,39 @@ +#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) => + (ICssValue)Activator.CreateInstance(template.GetType(), value); + } +} 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..324245d2 100644 --- a/src/AngleSharp.Css/Values/Expressions/CssCalcDivExpression.cs +++ b/src/AngleSharp.Css/Values/Expressions/CssCalcDivExpression.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/CssCalcMulExpression.cs b/src/AngleSharp.Css/Values/Expressions/CssCalcMulExpression.cs index 51daa6cb..724fa03f 100644 --- a/src/AngleSharp.Css/Values/Expressions/CssCalcMulExpression.cs +++ b/src/AngleSharp.Css/Values/Expressions/CssCalcMulExpression.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/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; From a6f4cc10f6a5526ddb0a36be319d6d3c8506247f Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Fri, 4 Sep 2026 09:57:04 +0200 Subject: [PATCH 13/26] Fixed unitless scaling in calc --- CHANGELOG.md | 2 ++ CONTRIBUTORS.md | 1 + src/AngleSharp.Css.Tests/Values/Calc.cs | 10 ++++++++++ .../Values/Expressions/CssCalcDivExpression.cs | 5 +++++ .../Values/Expressions/CssCalcMulExpression.cs | 10 ++++++++++ 5 files changed, 28 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ef49e36..9a1b4e6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Released on Saturday, September 5 2026 - 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) - 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 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/src/AngleSharp.Css.Tests/Values/Calc.cs b/src/AngleSharp.Css.Tests/Values/Calc.cs index 0275d163..85f01b14 100644 --- a/src/AngleSharp.Css.Tests/Values/Calc.cs +++ b/src/AngleSharp.Css.Tests/Values/Calc.cs @@ -130,6 +130,16 @@ public void CalcAdditionOfTimesIsComputed() 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(typeof(CssAngleValue))] [TestCase(typeof(CssFrequencyValue))] [TestCase(typeof(CssIntegerValue))] diff --git a/src/AngleSharp.Css/Values/Expressions/CssCalcDivExpression.cs b/src/AngleSharp.Css/Values/Expressions/CssCalcDivExpression.cs index 324245d2..443b47bc 100644 --- a/src/AngleSharp.Css/Values/Expressions/CssCalcDivExpression.cs +++ b/src/AngleSharp.Css/Values/Expressions/CssCalcDivExpression.cs @@ -63,6 +63,11 @@ ICssValue ICssValue.Compute(ICssComputeContext context) return x.WithValue(result); } + if (left is ICssMetricValue unitLeft && right is ICssMetricValue unitlessRight && unitlessRight.UnitString.Length == 0) + { + return unitLeft.WithValue(unitLeft.Value / unitlessRight.Value); + } + return null; } diff --git a/src/AngleSharp.Css/Values/Expressions/CssCalcMulExpression.cs b/src/AngleSharp.Css/Values/Expressions/CssCalcMulExpression.cs index 724fa03f..e3e8ce03 100644 --- a/src/AngleSharp.Css/Values/Expressions/CssCalcMulExpression.cs +++ b/src/AngleSharp.Css/Values/Expressions/CssCalcMulExpression.cs @@ -63,6 +63,16 @@ ICssValue ICssValue.Compute(ICssComputeContext context) 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; } From c77da45e89d0da81cbb07906ac67493b38388f98 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Fri, 4 Sep 2026 10:08:38 +0200 Subject: [PATCH 14/26] Improved scaling code --- .../Values/Expressions/CssCalcDivExpression.cs | 9 +++++++-- .../Values/Expressions/CssCalcMulExpression.cs | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/AngleSharp.Css/Values/Expressions/CssCalcDivExpression.cs b/src/AngleSharp.Css/Values/Expressions/CssCalcDivExpression.cs index 443b47bc..01ef8508 100644 --- a/src/AngleSharp.Css/Values/Expressions/CssCalcDivExpression.cs +++ b/src/AngleSharp.Css/Values/Expressions/CssCalcDivExpression.cs @@ -54,8 +54,8 @@ 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) { @@ -71,6 +71,11 @@ ICssValue ICssValue.Compute(ICssComputeContext context) 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 e3e8ce03..a6d2858a 100644 --- a/src/AngleSharp.Css/Values/Expressions/CssCalcMulExpression.cs +++ b/src/AngleSharp.Css/Values/Expressions/CssCalcMulExpression.cs @@ -54,8 +54,8 @@ 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) { @@ -76,6 +76,11 @@ ICssValue ICssValue.Compute(ICssComputeContext context) 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 From 2eddd84def30503e1eb6e637553ec051f4981eba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Barr=C3=A9?= Date: Fri, 4 Sep 2026 20:50:40 -0400 Subject: [PATCH 15/26] Fix truncation of unquoted url() values containing ; { or } CssTokenizer.ContentFrom re-scans the raw source to recover a declaration value or an at-rule prelude, and breaks at the first ';', '{' or '}'. It special-cased quoted strings but knew nothing about url tokens, where all three characters are legal content. As a result "url(data:image/svg+xml;base64,...)" was cut at the first semicolon and became url("data:image/svg+xml"); the remainder failed to re-tokenize as a declaration and was dropped, so a round-trip through CssText silently destroyed the asset. Unquoted data URIs are emitted by every major bundler, so this affected many real stylesheets. Via the GetArgument path the same flaw discarded whole rules: a @supports condition containing url(a;b) lost its entire rule. Teach ContentFrom about url tokens: on an ident "url" immediately followed by '(', consume through the matching unescaped ')' before the break check resumes. Behaviour was validated against Chrome's CSSOM over ~40 inputs, which pinned down three subtleties: - The ident must be immediately followed by '(' - "myurl(", "-url(" and "url\t(" are ordinary function tokens where ';' does terminate the declaration, so a plain substring match would introduce a new bug. - url( followed by a quote is a function token, not a url token: the string wins and a ')' inside it does not close the url. The scan skips whitespace after '(' and defers to the existing string handling. - Bad-url cases such as url(a b) still consume through the matching ')', so a single "consume to unescaped ')'" rule extracts the correct span in every case. Escapes inside the url are honoured, so url(a\;b) also parses correctly where it previously produced url("a\\"). The remaining divergences from Chrome (url(a b), url(a(b), url()) live in UrlUQ/UrlBad and are unchanged by this commit. --- src/AngleSharp.Css.Tests/Styling/CssSheet.cs | 107 +++++++++++++++++++ src/AngleSharp.Css/Parser/CssTokenizer.cs | 81 ++++++++++++++ 2 files changed, 188 insertions(+) diff --git a/src/AngleSharp.Css.Tests/Styling/CssSheet.cs b/src/AngleSharp.Css.Tests/Styling/CssSheet.cs index cf2d448a..2a621a6b 100644 --- a/src/AngleSharp.Css.Tests/Styling/CssSheet.cs +++ b/src/AngleSharp.Css.Tests/Styling/CssSheet.cs @@ -767,6 +767,113 @@ 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 CssSheetFromStreamWeirdBytesLeadingToInfiniteLoop() { diff --git a/src/AngleSharp.Css/Parser/CssTokenizer.cs b/src/AngleSharp.Css/Parser/CssTokenizer.cs index 47cd672c..963f8e27 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)); From 207340b60d2b26a5372fe0196fac5a98832e8bcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Barr=C3=A9?= Date: Fri, 4 Sep 2026 20:57:55 -0400 Subject: [PATCH 16/26] Fixed case-sensitive matching of CSS keywords in parsers The @supports condition parser compared the "and" / "or" keywords with ordinal equality, so an uppercase or mixed-case keyword silently discarded the whole conditional group and every rule inside it. The keyframe selector parser did the same for "from" / "to", leaving the rule in the CSSOM with a null key. Switched both to the case-insensitive Isi helper, including the chain continuation in Scan, which compared each subsequent keyword against the raw text of the first one. --- .../Rules/CssKeyframeRule.cs | 33 ++++++++++++ src/AngleSharp.Css.Tests/Rules/CssSupports.cs | 52 +++++++++++++++++++ .../Parser/Micro/ConditionParser.cs | 6 +-- .../Parser/Micro/KeyframeParser.cs | 4 +- 4 files changed, 90 insertions(+), 5 deletions(-) diff --git a/src/AngleSharp.Css.Tests/Rules/CssKeyframeRule.cs b/src/AngleSharp.Css.Tests/Rules/CssKeyframeRule.cs index efcc9a55..9fc71c79 100644 --- a/src/AngleSharp.Css.Tests/Rules/CssKeyframeRule.cs +++ b/src/AngleSharp.Css.Tests/Rules/CssKeyframeRule.cs @@ -84,5 +84,38 @@ 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()); + } } } 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/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/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); } From 0988087f3121ee64b038c3079fb40bc43af9dfc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Barr=C3=A9?= Date: Fri, 4 Sep 2026 20:58:12 -0400 Subject: [PATCH 17/26] Fixed associativity of repeated calc() operators The calc() operand parser recursed into itself for the right operand of every operator, which made the resulting expression tree right associative. Chains of two or more identical operators were therefore evaluated in the wrong order: calc(10px - 2px - 3px) built Sub(10px, Sub(2px, 3px)) and computed to 11px instead of 5px, and calc(100px / 2 / 5) built 100 / (2 / 5) instead of (100 / 2) / 5. Mixed precedence expressions happened to come out right, so only chains of same precedence operators were affected. No parse error was raised; the wrong number was simply handed to the computed style. Replace the four right recursive levels with two iterative loops that fold the operands to the left, matching the grammar expression := term (('+' | '-') term)* term := factor (('*' | '/') factor)* This also collapses the artificial split between the Add/Sub and the Mul/Div levels, which is what introduced the asymmetry. Serialization is unaffected, as CssText concatenates the operands in order without adding parentheses. Expected values are taken from Chrome via getComputedStyle. --- src/AngleSharp.Css.Tests/Values/Calc.cs | 32 ++++++++++ src/AngleSharp.Css/Parser/Micro/CalcParser.cs | 58 ++++--------------- 2 files changed, 44 insertions(+), 46 deletions(-) diff --git a/src/AngleSharp.Css.Tests/Values/Calc.cs b/src/AngleSharp.Css.Tests/Values/Calc.cs index 85f01b14..8bc56e17 100644 --- a/src/AngleSharp.Css.Tests/Values/Calc.cs +++ b/src/AngleSharp.Css.Tests/Values/Calc.cs @@ -140,6 +140,38 @@ public void CalcLengthWithUnitlessOperandIsComputed(String expression, String ex 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(typeof(CssAngleValue))] [TestCase(typeof(CssFrequencyValue))] [TestCase(typeof(CssIntegerValue))] 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; From 8449e1b82f3f017d9b1bf0024bfc107845cffd26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Barr=C3=A9?= Date: Fri, 4 Sep 2026 20:58:31 -0400 Subject: [PATCH 18/26] Fixed the unit of calc() division results Two independent defects made a division in calc() report a value with the wrong unit. Dividing two values that share a unit cancels the unit out and yields a plain number, but CssCalcDivExpression kept the unit of the left operand. calc(10px / 20px) computed to 0.5px instead of 0.5, so declarations such as opacity, flex-grow, z-index or line-height ended up with a length where a number was expected. CssMetricValueExtensions.WithValue creates the result through Activator.CreateInstance(type, value), and the single argument constructor of CssLengthValue defaults to pixels. Any unitless length was therefore turned into a length in pixels: calc(1 / 4) computed to 0.25px rather than 0.25. Preserve the unit of the template instead; this covers multiplication too, where calc(2 * 3) computed to 6px. Expected values are taken from Chrome via getComputedStyle, which reports 0.5 for opacity: calc(10px / 20px), 2 for flex-grow: calc(100px / 50px) and 150px for width: calc(100px / 2px * 3px). --- src/AngleSharp.Css.Tests/Values/Calc.cs | 33 +++++++++++++++++++ .../Extensions/CssMetricValueExtensions.cs | 6 +++- .../Expressions/CssCalcDivExpression.cs | 21 +++++++----- 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/src/AngleSharp.Css.Tests/Values/Calc.cs b/src/AngleSharp.Css.Tests/Values/Calc.cs index 8bc56e17..1b3cae70 100644 --- a/src/AngleSharp.Css.Tests/Values/Calc.cs +++ b/src/AngleSharp.Css.Tests/Values/Calc.cs @@ -172,6 +172,39 @@ public void CalcMixedPrecedenceIsComputed(String expression, String expected) 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))] diff --git a/src/AngleSharp.Css/Extensions/CssMetricValueExtensions.cs b/src/AngleSharp.Css/Extensions/CssMetricValueExtensions.cs index caf9d9ff..828229f2 100644 --- a/src/AngleSharp.Css/Extensions/CssMetricValueExtensions.cs +++ b/src/AngleSharp.Css/Extensions/CssMetricValueExtensions.cs @@ -34,6 +34,10 @@ static class CssMetricValueExtensions "public constructor taking a single Double themselves, e.g., via DynamicDependency.")] #endif public static ICssValue WithValue(this ICssMetricValue template, Double value) => - (ICssValue)Activator.CreateInstance(template.GetType(), 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/Values/Expressions/CssCalcDivExpression.cs b/src/AngleSharp.Css/Values/Expressions/CssCalcDivExpression.cs index 01ef8508..68245cc6 100644 --- a/src/AngleSharp.Css/Values/Expressions/CssCalcDivExpression.cs +++ b/src/AngleSharp.Css/Values/Expressions/CssCalcDivExpression.cs @@ -57,15 +57,20 @@ ICssValue ICssValue.Compute(ICssComputeContext 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 x.WithValue(result); - } - - if (left is ICssMetricValue unitLeft && right is ICssMetricValue unitlessRight && unitlessRight.UnitString.Length == 0) - { - return unitLeft.WithValue(unitLeft.Value / unitlessRight.Value); + // 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; From afbb9cdf756c250d8629f3d28ff05b84b18594bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Barr=C3=A9?= Date: Fri, 4 Sep 2026 21:00:38 -0400 Subject: [PATCH 19/26] Updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a1b4e6a..9167d24c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Released on Saturday, September 5 2026 - 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) - 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 From e53aba1267b3fc5fc3113075846f354db154f70e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Barr=C3=A9?= Date: Fri, 4 Sep 2026 21:07:08 -0400 Subject: [PATCH 20/26] Make bad url() handling match the spec and browsers My previous commit fixed where a url token *ends*. This fixes what happens when one is invalid, which was a separate defect with the same symptom class: values that browsers reject were being accepted as garbage. Three divergences from Chrome, all rooted in the fact that a bad url had no way to be reported as a failure: CssUriParser.Bad() returned a CssUrlValue built from whatever characters it had scanned past, so an invalid url produced a plausible-looking but wrong value instead of failing. url(a b) became url("ab") and url(a(b) became url("a(b)"); browsers drop the declaration in both cases. Bad() now returns null and ParseUri rewinds the source, so the url() is seen as unparsed rather than as absent - rewinding matters, because merely consuming the bad url let "background: url(a b) red" silently re-parse as "background: red" instead of being dropped. ParseUri did not consume the ')' of an empty url(), leaving the source mid-value so the declaration was rejected. url() is valid and means the empty URL, so it now parses as url(""). CssTokenizer.NewUrl accepted a "bad" parameter and ignored it, so no bad-url token could ever exist at the sheet level and UrlBad's scanned-over characters became the url's content. "@import url(a b)" imported the garbage href "a b)". A BadUrl token type now carries the distinction, and UrlBad discards the remnants it consumes. Per the spec, EOF ends a url token rather than invalidating it, so the two EOF paths that flagged bad no longer do - "@import url(abc" still imports "abc". Consequences at the rule level, matching Chrome: - @import with a bad url is dropped instead of importing a garbage href. - @namespace with a bad url is dropped. Fixing this forced a decision on the string form, since one condition governs both: @namespace accepted only a url token, so "@namespace x "http://foo"" silently produced an empty namespace URI. It now accepts a string as the spec requires. Behaviour was verified against Chrome's CSSOM over the full 30-case matrix (both fixes together); no divergence remains. ParseInlineStyleWithToleratedInvalidValueShouldReturnThatValue asserted the old lenient recovery for url(javascript:alert(1)) - an unquoted url with a '(' in it, i.e. exactly the url(a(b) case. Chrome drops that declaration, so the test now documents that, and a companion test covers the quoted form which is valid and still round-trips. The tolerance it relied on came from Bad(), not from IsIncludingUnknownDeclarations, which only governs unknown property names. --- src/AngleSharp.Css.Tests/Styling/CssSheet.cs | 112 ++++++++++++++++++ .../Values/ErrorHandling.cs | 17 ++- src/AngleSharp.Css/Parser/CssBuilder.cs | 8 +- src/AngleSharp.Css/Parser/CssTokenType.cs | 4 + src/AngleSharp.Css/Parser/CssTokenizer.cs | 44 ++----- .../Parser/Micro/CssUriParser.cs | 74 ++++-------- 6 files changed, 174 insertions(+), 85 deletions(-) diff --git a/src/AngleSharp.Css.Tests/Styling/CssSheet.cs b/src/AngleSharp.Css.Tests/Styling/CssSheet.cs index 2a621a6b..eb3a6f6f 100644 --- a/src/AngleSharp.Css.Tests/Styling/CssSheet.cs +++ b/src/AngleSharp.Css.Tests/Styling/CssSheet.cs @@ -874,6 +874,118 @@ public void CssSheetWithFunctionEndingInUrlIsNotAUrlToken() 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/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/Parser/CssBuilder.cs b/src/AngleSharp.Css/Parser/CssBuilder.cs index 344369cd..52ad785e 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; } 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 963f8e27..0d5a182e 100644 --- a/src/AngleSharp.Css/Parser/CssTokenizer.cs +++ b/src/AngleSharp.Css/Parser/CssTokenizer.cs @@ -1087,7 +1087,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(); @@ -1217,7 +1217,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()) { @@ -1272,50 +1272,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); } /// @@ -1487,7 +1463,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/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; } } } From af66a2fab161877349c0a2e73e121691da8b2356 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= <1165805+sebastienros@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:34:21 -0700 Subject: [PATCH 21/26] Fix cyclic custom properties during computed style resolution Resolve per-element custom-property dependency components iteratively before inheritance, including unused fallback edges. Preserve computed token values, substitute complete consumer and shorthand values, and apply computed-value defaults without recursive variable evaluation. Add regression coverage for cycles, inheritance, shared rules, CSSOM mutation, token boundaries, long chains, nested fallbacks, and bounded expansion. Document the substitution limit. Fixes #241 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/general/02-Values.md | 20 + .../Styling/CustomPropertyCycles.cs | 415 ++++++++++++++++++ .../Dom/Internal/CssProperty.cs | 4 +- .../Dom/Internal/CssStyleDeclaration.cs | 7 +- .../Extensions/CssOmExtensions.cs | 33 +- .../Extensions/DeclarationInfoExtensions.cs | 2 +- .../Extensions/StyleCollectionExtensions.cs | 56 ++- src/AngleSharp.Css/Parser/CssTokenizer.cs | 6 +- .../Parser/Micro/FunctionParser.cs | 63 +-- .../RenderTree/RenderTreeBuilder.cs | 22 +- src/AngleSharp.Css/Values/CssChildValue.cs | 39 +- .../Values/CssComputeContext.cs | 19 +- .../Values/CssCustomPropertyResolver.cs | 161 +++++++ src/AngleSharp.Css/Values/CssVariableValue.cs | 279 ++++++++++++ .../Values/Functions/CssVarValue.cs | 12 +- src/AngleSharp.Css/Values/Raws/CssAnyValue.cs | 17 +- .../Values/Raws/CssInvalidValue.cs | 22 + .../Values/Raws/CssReferenceValue.cs | 24 +- 18 files changed, 1060 insertions(+), 141 deletions(-) create mode 100644 src/AngleSharp.Css.Tests/Styling/CustomPropertyCycles.cs create mode 100644 src/AngleSharp.Css/Values/CssCustomPropertyResolver.cs create mode 100644 src/AngleSharp.Css/Values/CssVariableValue.cs create mode 100644 src/AngleSharp.Css/Values/Raws/CssInvalidValue.cs diff --git a/docs/general/02-Values.md b/docs/general/02-Values.md index c208cd27..d4fa19db 100644 --- a/docs/general/02-Values.md +++ b/docs/general/02-Values.md @@ -62,3 +62,23 @@ 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 for each element before they are inherited. 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. Expanded values 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/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/Dom/Internal/CssProperty.cs b/src/AngleSharp.Css/Dom/Internal/CssProperty.cs index e199e267..c9128d86 100644 --- a/src/AngleSharp.Css/Dom/Internal/CssProperty.cs +++ b/src/AngleSharp.Css/Dom/Internal/CssProperty.cs @@ -111,7 +111,9 @@ 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?.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/CssOmExtensions.cs b/src/AngleSharp.Css/Extensions/CssOmExtensions.cs index c2f05b58..e4882f60 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 Cascade(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/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/StyleCollectionExtensions.cs b/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs index de94d76f..408ff3d5 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); - } + => GetElementDeclarations(styles, element, pseudoSelector, true); /// /// Gets the declarations for the given element in the context of @@ -61,29 +55,40 @@ public static ICssStyleDeclaration ComputeDeclarations(this IStyleCollection sty /// The optional pseudo selector to use. /// The style declaration containing all the declarations. public static ICssStyleDeclaration GetDeclarations(this IStyleCollection styles, IElement element, String? pseudoSelector = null) + => GetElementDeclarations(styles, element, pseudoSelector, false); + + private static ICssStyleDeclaration GetElementDeclarations(IStyleCollection styles, IElement element, String? pseudoSelector, Boolean compute) { var ctx = element.Owner?.Context; - var computedStyle = new CssStyleDeclaration(ctx); - var nodes = element.GetAncestors().OfType(); + ICssStyleDeclaration? parent = null; + var nodes = new Stack(); if (!String.IsNullOrEmpty(pseudoSelector)) { - var pseudoElement = element?.Pseudo(pseudoSelector!.TrimStart(':')); + 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); + var declarations = explicitStyle.Cascade(parent!, context); + parent = compute ? declarations.Compute(context) : declarations; + } + + return parent!; } /// @@ -96,9 +101,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 explicitStyle = styles.ComputeExplicitStyle(element); + var context = new CssComputeContext(styles.Device, element.Owner?.Context, explicitStyle, parent); + return explicitStyle.Cascade(parent, context); } /// @@ -140,18 +145,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.Cascade(parentComputedStyle, context).Compute(context); } #endregion diff --git a/src/AngleSharp.Css/Parser/CssTokenizer.cs b/src/AngleSharp.Css/Parser/CssTokenizer.cs index 47cd672c..88b8d7b8 100644 --- a/src/AngleSharp.Css/Parser/CssTokenizer.cs +++ b/src/AngleSharp.Css/Parser/CssTokenizer.cs @@ -260,6 +260,10 @@ private CssToken Data(Char current) Advance(2); return NewCloseComment(); } + else if (c1 == Symbols.Minus) + { + return IdentStart(current); + } } else { @@ -706,7 +710,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); diff --git a/src/AngleSharp.Css/Parser/Micro/FunctionParser.cs b/src/AngleSharp.Css/Parser/Micro/FunctionParser.cs index 72d269f6..ffd9cd63 100644 --- a/src/AngleSharp.Css/Parser/Micro/FunctionParser.cs +++ b/src/AngleSharp.Css/Parser/Micro/FunctionParser.cs @@ -5,7 +5,6 @@ namespace AngleSharp.Css.Parser using AngleSharp.Css.Values; using AngleSharp.Text; using System; - using System.Collections.Generic; /// /// Represents extensions to for general CSS functions. @@ -37,54 +36,14 @@ public static CssAttrValue ParseAttr(this StringSource source) /// public static CssReferenceValue ParseVars(this StringSource source) { - var index = source.Index; - var start = index; - var length = FunctionNames.Var.Length; - var refs = default(List>); - - while (!source.IsDone) + if (source.Content.IndexOf(FunctionNames.Var, StringComparison.OrdinalIgnoreCase) < 0 && + source.Content.IndexOf('\\') < 0) { - index = source.Content.IndexOf(FunctionNames.Var, index, StringComparison.OrdinalIgnoreCase) + length; - - if (index >= length) - { - source.NextTo(index); - var c = source.SkipSpacesAndComments(); - - if (c == Symbols.RoundBracketOpen) - { - source.SkipCurrentAndSpaces(); - var s = new TextPosition(0, 0, source.Index); - var reference = ParseVar(source); - - if (reference == null) - { - refs = null; - break; - } - - if (refs == null) - { - refs = new List>(); - } - - var e = new TextPosition(0, 0, source.Index); - refs.Add(Tuple.Create(new TextRange(s, e), reference)); - continue; - } - } - - break; + return null; } - source.BackTo(start); - - if (refs != null) - { - return new CssReferenceValue(source.Content, refs); - } - - return null; + var value = new CssVariableValue(source.Content); + return value.IsValid && value.HasReferences ? new CssReferenceValue(value) : null; } /// @@ -116,15 +75,9 @@ public static CssVarValue ParseVar(this StringSource source) /// public static ICssValue ParseVarFallback(this StringSource source) { - if (!source.IsFunction(FunctionNames.Var)) - { - var content = source.TakeUntilClosed(); - source.SkipCurrentAndSpaces(); - return new CssAnyValue(content); - } - - return source.ParseVar(); - + var content = source.TakeUntilClosed(); + source.SkipCurrentAndSpaces(); + return new CssAnyValue(content); } /// diff --git a/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs b/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs index cda0ef31..75e93e3a 100644 --- a/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs +++ b/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs @@ -84,24 +84,10 @@ private ElementRenderNode RenderElement( { var explicitStyle = collection.ComputeExplicitStyle(element); - var specifiedStyle = new CssStyleDeclaration(_context); - specifiedStyle.SetDeclarations(explicitStyle); - - if (parentSpecifiedStyle is not null) - { - 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 specifiedContext = new CssComputeContext(collection.Device, _context, explicitStyle, parentSpecifiedStyle); + var specifiedStyle = explicitStyle.Cascade(parentSpecifiedStyle!, specifiedContext); + var computeContext = new CssComputeContext(collection.Device, _context, explicitStyle, parentComputedStyle); + var computedStyle = explicitStyle.Cascade(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..3919fca8 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,38 @@ 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 text = new CssVariableValue(parent.CssText).Substitute(context.Resolve); + + 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..56ff1102 --- /dev/null +++ b/src/AngleSharp.Css/Values/CssCustomPropertyResolver.cs @@ -0,0 +1,161 @@ +namespace AngleSharp.Css.Values +{ + using AngleSharp.Css.Dom; + using System; + using System.Collections.Generic; + + 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 tokens = value is null || value is CssInvalidValue ? null : new CssVariableValue(value.CssText); + var keyword = tokens?.Keyword; + + if (String.Equals(keyword, CssKeywords.Inherit, StringComparison.OrdinalIgnoreCase) || + String.Equals(keyword, CssKeywords.Unset, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + _values[property.Name] = null; + + if (tokens is not null && tokens.IsValid && !String.Equals(keyword, CssKeywords.Initial, StringComparison.OrdinalIgnoreCase)) + { + nodes[property.Name] = new Node(property.Name, tokens); + } + } + } + + foreach (var node in nodes.Values) + { + foreach (var name in node.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)) + { + var text = node.Value.Substitute(Resolve); + _values[node.Name] = text is null ? null : new CssAnyValue(text, isResolved: true); + } + } + } + } + + 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 value) + { + Name = name; + Value = value; + } + + public String Name { get; } + public CssVariableValue Value { 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..3cd2eba0 --- /dev/null +++ b/src/AngleSharp.Css/Values/CssVariableValue.cs @@ -0,0 +1,279 @@ +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, name, 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 IEnumerable> GetReferences() + { + for (var i = 0; i < _tokens.Count; i++) + { + if (_references.TryGetValue(i, out var reference)) + { + var fallback = reference.Fallback >= 0 ? + new CssAnyValue(Text.Substring(Offset(reference.Fallback - 1) + 1, + Offset(reference.End) - Offset(reference.Fallback - 1) - 1).Trim()) : null; + var start = new TextPosition(0, 0, Offset(reference.NameIndex)); + var end = new TextPosition(0, 0, EndOffset(reference.End)); + yield return Tuple.Create(new TextRange(start, end), new CssVarValue(reference.Name, fallback)); + i = reference.End; + } + } + } + + 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 nameIndex, Int32 end, Int32 fallback) + { + Name = name; + NameIndex = nameIndex; + End = end; + Fallback = fallback; + } + + public String Name { get; } + public Int32 NameIndex { get; } + public Int32 End { get; } + public Int32 Fallback { get; } + } + } +} diff --git a/src/AngleSharp.Css/Values/Functions/CssVarValue.cs b/src/AngleSharp.Css/Values/Functions/CssVarValue.cs index 957fedf5..d9d4167d 100644 --- a/src/AngleSharp.Css/Values/Functions/CssVarValue.cs +++ b/src/AngleSharp.Css/Values/Functions/CssVarValue.cs @@ -121,14 +121,20 @@ public Boolean Equals(CssVarValue other) /// The resolved value or null. public ICssValue Compute(ICssComputeContext context) { - var value = context.Resolve(_variableName)?.Compute(context); + var value = context.Resolve(_variableName); if (value is not null) { - return value; + return value.Compute(context); } - return _defaultValue?.Compute(context); + if (_defaultValue is null) + { + return null; + } + + var text = new CssVariableValue(_defaultValue.CssText).Substitute(context.Resolve); + return text is null ? null : ((ICssValue)new CssAnyValue(text)).Compute(context); } Boolean IEquatable.Equals(ICssValue other) => other is CssVarValue value && Equals(value); diff --git a/src/AngleSharp.Css/Values/Raws/CssAnyValue.cs b/src/AngleSharp.Css/Values/Raws/CssAnyValue.cs index f942f97c..5b3fa40c 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 this; } 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..128bd9b3 100644 --- a/src/AngleSharp.Css/Values/Raws/CssReferenceValue.cs +++ b/src/AngleSharp.Css/Values/Raws/CssReferenceValue.cs @@ -17,6 +17,7 @@ public sealed class CssReferenceValue : ICssRawValue private readonly String _value; private readonly TextRange[] _ranges; private readonly CssVarValue[] _references; + private readonly CssVariableValue _tokens; #endregion @@ -32,6 +33,16 @@ public CssReferenceValue(String value, IEnumerable _value = value; _ranges = references.Select(m => m.Item1).ToArray(); _references = references.Select(m => m.Item2).ToArray(); + _tokens = new CssVariableValue(value); + } + + internal CssReferenceValue(CssVariableValue value) + { + var references = value.GetReferences().ToArray(); + _value = value.Text; + _ranges = references.Select(m => m.Item1).ToArray(); + _references = references.Select(m => m.Item2).ToArray(); + _tokens = value; } #endregion @@ -64,17 +75,8 @@ public CssReferenceValue(String value, IEnumerable ICssValue ICssValue.Compute(ICssComputeContext context) { - foreach (var reference in _references) - { - var result = reference.Compute(context); - - if (result is not null) - { - return result; - } - } - - return null; + var text = _tokens.Substitute(context.Resolve); + return text is null ? null : ((ICssValue)new CssAnyValue(text)).Compute(context); } Boolean IEquatable.Equals(ICssValue other) => Object.ReferenceEquals(this, other); From ee38e1a51ca5e1c64886c3fc5cba66f55fc96304 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sat, 5 Sep 2026 14:37:04 +0200 Subject: [PATCH 22/26] Updated changelog --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9167d24c..9778e23e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,9 @@ Released on Saturday, September 5 2026 - 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) +- 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 - 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 From e35d336d1e4f5d46db453f96f740660cb48fa468 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sat, 5 Sep 2026 14:41:21 +0200 Subject: [PATCH 23/26] Fixed handling of invalid keyframe selectors --- CHANGELOG.md | 1 + .../Rules/CssKeyframeRule.cs | 19 +++++++++++++++++++ src/AngleSharp.Css/Parser/CssBuilder.cs | 18 +++++++++++++++--- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9778e23e..495a8356 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Released on Saturday, September 5 2026 - 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 - 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 diff --git a/src/AngleSharp.Css.Tests/Rules/CssKeyframeRule.cs b/src/AngleSharp.Css.Tests/Rules/CssKeyframeRule.cs index 9fc71c79..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; @@ -117,5 +118,23 @@ public void KeyframeRuleWithMixedCaseFromAndTo() 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/Parser/CssBuilder.cs b/src/AngleSharp.Css/Parser/CssBuilder.cs index 52ad785e..a798a690 100644 --- a/src/AngleSharp.Css/Parser/CssBuilder.cs +++ b/src/AngleSharp.Css/Parser/CssBuilder.cs @@ -539,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; } @@ -551,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; From 99c535a82bc15fdf9a354b156ae867f5c155ffc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= <1165805+sebastienros@users.noreply.github.com> Date: Sat, 5 Sep 2026 08:27:25 -0700 Subject: [PATCH 24/26] Preserve public declaration and variable value behavior Keep variable resolution inside computed-style preparation; leave raw cascades and render-tree specified styles unchanged. Restore nested CssVarValue fallback trees and direct References-based computation, retaining iterative parsing, serialization, and fallback traversal. Honor caller-modified references in custom-property and shorthand resolution. Add compatibility regressions for raw declarations, public parser cursor and value-tree behavior, mutable references, and deep public fallbacks. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/general/02-Values.md | 13 +- .../Styling/CustomPropertyCompatibility.cs | 244 ++++++++++++++++++ .../Dom/Internal/CssProperty.cs | 3 +- .../Extensions/CssOmExtensions.cs | 2 +- .../Extensions/StyleCollectionExtensions.cs | 35 ++- .../Parser/Micro/FunctionParser.cs | 93 ++++++- .../RenderTree/RenderTreeBuilder.cs | 12 +- src/AngleSharp.Css/Values/CssChildValue.cs | 14 +- .../Values/CssCustomPropertyResolver.cs | 43 ++- src/AngleSharp.Css/Values/CssVariableValue.cs | 23 +- .../Values/Functions/CssVarValue.cs | 56 ++-- src/AngleSharp.Css/Values/Raws/CssAnyValue.cs | 2 +- .../Values/Raws/CssReferenceValue.cs | 69 ++++- 13 files changed, 525 insertions(+), 84 deletions(-) create mode 100644 src/AngleSharp.Css.Tests/Styling/CustomPropertyCompatibility.cs diff --git a/docs/general/02-Values.md b/docs/general/02-Values.md index d4fa19db..9be72233 100644 --- a/docs/general/02-Values.md +++ b/docs/general/02-Values.md @@ -65,7 +65,12 @@ Console.WriteLine($"Computed font-size: {computedFontSize}"); ## Custom Properties At Computed-Value Time -Custom properties are resolved for each element before they are inherited. An inherited +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. @@ -78,7 +83,11 @@ or initial value, not an earlier declaration from the cascade. A valid custom-pr 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. Expanded values are limited to 1,048,576 UTF-16 code units (including token +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/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/Dom/Internal/CssProperty.cs b/src/AngleSharp.Css/Dom/Internal/CssProperty.cs index c9128d86..3a1413a4 100644 --- a/src/AngleSharp.Css/Dom/Internal/CssProperty.cs +++ b/src/AngleSharp.Css/Dom/Internal/CssProperty.cs @@ -113,7 +113,8 @@ public ICssProperty Compute(ICssComputeContext context) var propertyContext = new PropertyComputeContext(context, _converter); var computedValue = _name.StartsWith("--", StringComparison.Ordinal) ? context.Resolve(_name) ?? CssInvalidValue.Instance : - _value is CssChildValue child ? child.Compute(propertyContext, _name) : _value?.Compute(propertyContext); + _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/Extensions/CssOmExtensions.cs b/src/AngleSharp.Css/Extensions/CssOmExtensions.cs index e4882f60..c57b2564 100644 --- a/src/AngleSharp.Css/Extensions/CssOmExtensions.cs +++ b/src/AngleSharp.Css/Extensions/CssOmExtensions.cs @@ -114,7 +114,7 @@ public static ICssStyleDeclaration Compute(this ICssStyleDeclaration style, ICss return computedStyle; } - internal static CssStyleDeclaration Cascade(this ICssStyleDeclaration style, ICssStyleDeclaration parent, ICssComputeContext context) + internal static CssStyleDeclaration PrepareComputedDeclarations(this ICssStyleDeclaration style, ICssStyleDeclaration parent, ICssComputeContext context) { var declarations = new CssStyleDeclaration(context.Context); diff --git a/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs b/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs index 408ff3d5..50606462 100644 --- a/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs +++ b/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs @@ -44,7 +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) - => GetElementDeclarations(styles, element, pseudoSelector, true); + => GetComputedDeclarations(styles, element, pseudoSelector); /// /// Gets the declarations for the given element in the context of @@ -55,9 +55,27 @@ public static ICssStyleDeclaration ComputeDeclarations(this IStyleCollection sty /// The optional pseudo selector to use. /// The style declaration containing all the declarations. public static ICssStyleDeclaration GetDeclarations(this IStyleCollection styles, IElement element, String? pseudoSelector = null) - => GetElementDeclarations(styles, element, pseudoSelector, false); + { + var ctx = element.Owner?.Context; + var declarations = new CssStyleDeclaration(ctx); + var ancestors = element.GetAncestors().OfType(); + + if (!String.IsNullOrEmpty(pseudoSelector)) + { + element = element.Pseudo(pseudoSelector!.TrimStart(':')) ?? element; + } + + declarations.SetDeclarations(styles.ComputeExplicitStyle(element)); - private static ICssStyleDeclaration GetElementDeclarations(IStyleCollection styles, IElement element, String? pseudoSelector, Boolean compute) + 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; @@ -84,8 +102,7 @@ private static ICssStyleDeclaration GetElementDeclarations(IStyleCollection styl { var explicitStyle = styles.ComputeExplicitStyle(nodes.Pop()); var context = new CssComputeContext(styles.Device, ctx, explicitStyle, parent); - var declarations = explicitStyle.Cascade(parent!, context); - parent = compute ? declarations.Compute(context) : declarations; + parent = explicitStyle.PrepareComputedDeclarations(parent!, context).Compute(context); } return parent!; @@ -101,9 +118,9 @@ private static ICssStyleDeclaration GetElementDeclarations(IStyleCollection styl /// Returns the cascaded read-only style declaration. public static ICssStyleDeclaration ComputeCascadedStyle(this IStyleCollection styles, IElement element, ICssStyleDeclaration parent) { - var explicitStyle = styles.ComputeExplicitStyle(element); - var context = new CssComputeContext(styles.Device, element.Owner?.Context, explicitStyle, parent); - return explicitStyle.Cascade(parent, context); + var declarations = (CssStyleDeclaration)styles.ComputeExplicitStyle(element); + declarations.UpdateDeclarations(parent); + return declarations; } /// @@ -147,7 +164,7 @@ internal static ICssStyleDeclaration ComputeDeclarationsWithParent(this IStyleCo var ctx = element.Owner?.Context; var explicitStyle = styles.ComputeExplicitStyle(element); var context = new CssComputeContext(styles.Device, ctx, explicitStyle, parentComputedStyle); - return explicitStyle.Cascade(parentComputedStyle, context).Compute(context); + return explicitStyle.PrepareComputedDeclarations(parentComputedStyle, context).Compute(context); } #endregion diff --git a/src/AngleSharp.Css/Parser/Micro/FunctionParser.cs b/src/AngleSharp.Css/Parser/Micro/FunctionParser.cs index ffd9cd63..dbc20746 100644 --- a/src/AngleSharp.Css/Parser/Micro/FunctionParser.cs +++ b/src/AngleSharp.Css/Parser/Micro/FunctionParser.cs @@ -5,6 +5,7 @@ namespace AngleSharp.Css.Parser using AngleSharp.Css.Values; using AngleSharp.Text; using System; + using System.Collections.Generic; /// /// Represents extensions to for general CSS functions. @@ -36,14 +37,54 @@ public static CssAttrValue ParseAttr(this StringSource source) /// public static CssReferenceValue ParseVars(this StringSource source) { - if (source.Content.IndexOf(FunctionNames.Var, StringComparison.OrdinalIgnoreCase) < 0 && - source.Content.IndexOf('\\') < 0) + var index = source.Index; + var start = index; + var length = FunctionNames.Var.Length; + var refs = default(List>); + + while (!source.IsDone) { - return null; + index = source.Content.IndexOf(FunctionNames.Var, index, StringComparison.OrdinalIgnoreCase) + length; + + if (index >= length) + { + source.NextTo(index); + var c = source.SkipSpacesAndComments(); + + if (c == Symbols.RoundBracketOpen) + { + source.SkipCurrentAndSpaces(); + var s = new TextPosition(0, 0, source.Index); + var reference = ParseVar(source); + + if (reference == null) + { + refs = null; + break; + } + + if (refs == null) + { + refs = new List>(); + } + + var e = new TextPosition(0, 0, source.Index); + refs.Add(Tuple.Create(new TextRange(s, e), reference)); + continue; + } + } + + break; } - var value = new CssVariableValue(source.Content); - return value.IsValid && value.HasReferences ? new CssReferenceValue(value) : null; + source.BackTo(start); + + if (refs != null) + { + return new CssReferenceValue(new CssVariableValue(source.Content), refs); + } + + return null; } /// @@ -75,9 +116,45 @@ public static CssVarValue ParseVar(this StringSource source) /// public static ICssValue ParseVarFallback(this StringSource source) { - var content = source.TakeUntilClosed(); - source.SkipCurrentAndSpaces(); - return new CssAnyValue(content); + 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(); + fallback = new CssAnyValue(content); + } + + while (names.Count > 0) + { + fallback = new CssVarValue(names.Pop(), fallback); + } + + return fallback; } /// diff --git a/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs b/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs index 75e93e3a..bdbc9070 100644 --- a/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs +++ b/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs @@ -84,10 +84,16 @@ private ElementRenderNode RenderElement( { var explicitStyle = collection.ComputeExplicitStyle(element); - var specifiedContext = new CssComputeContext(collection.Device, _context, explicitStyle, parentSpecifiedStyle); - var specifiedStyle = explicitStyle.Cascade(parentSpecifiedStyle!, specifiedContext); + var specifiedStyle = new CssStyleDeclaration(_context); + specifiedStyle.SetDeclarations(explicitStyle); + + if (parentSpecifiedStyle is not null) + { + specifiedStyle.UpdateDeclarations(parentSpecifiedStyle); + } + var computeContext = new CssComputeContext(collection.Device, _context, explicitStyle, parentComputedStyle); - var computedStyle = explicitStyle.Cascade(parentComputedStyle!, computeContext).Compute(computeContext); + 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 3919fca8..6ba38a1b 100644 --- a/src/AngleSharp.Css/Values/CssChildValue.cs +++ b/src/AngleSharp.Css/Values/CssChildValue.cs @@ -93,7 +93,19 @@ internal ICssValue Compute(ICssComputeContext context, String longhandName) if (shorthandName is not null && parent is ICssRawValue) { - var text = new CssVariableValue(parent.CssText).Substitute(context.Resolve); + 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) { diff --git a/src/AngleSharp.Css/Values/CssCustomPropertyResolver.cs b/src/AngleSharp.Css/Values/CssCustomPropertyResolver.cs index 56ff1102..926b4eb3 100644 --- a/src/AngleSharp.Css/Values/CssCustomPropertyResolver.cs +++ b/src/AngleSharp.Css/Values/CssCustomPropertyResolver.cs @@ -3,6 +3,7 @@ namespace AngleSharp.Css.Values using AngleSharp.Css.Dom; using System; using System.Collections.Generic; + using System.Linq; sealed class CssCustomPropertyResolver { @@ -35,8 +36,10 @@ public CssCustomPropertyResolver(IEnumerable properties, ICssPrope continue; } - var tokens = value is null || value is CssInvalidValue ? null : new CssVariableValue(value.CssText); - var keyword = tokens?.Keyword; + 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)) @@ -46,20 +49,28 @@ public CssCustomPropertyResolver(IEnumerable properties, ICssPrope _values[property.Name] = null; - if (tokens is not null && tokens.IsValid && !String.Equals(keyword, CssKeywords.Initial, StringComparison.OrdinalIgnoreCase)) + if (values.Length > 0 && !String.Equals(keyword, CssKeywords.Initial, StringComparison.OrdinalIgnoreCase)) { - nodes[property.Name] = new Node(property.Name, tokens); + nodes[property.Name] = new Node(property.Name, values); } } } foreach (var node in nodes.Values) { - foreach (var name in node.Value.Dependencies) + foreach (var value in node.Values) { - if (nodes.TryGetValue(name, out var dependency)) + if (!value.IsValid) { - node.Dependencies.Add(dependency); + continue; + } + + foreach (var name in value.Dependencies) + { + if (nodes.TryGetValue(name, out var dependency)) + { + node.Dependencies.Add(dependency); + } } } } @@ -123,8 +134,16 @@ public CssCustomPropertyResolver(IEnumerable properties, ICssPrope if (component.Count == 1 && !node.Dependencies.Contains(node)) { - var text = node.Value.Substitute(Resolve); - _values[node.Name] = text is null ? null : new CssAnyValue(text, isResolved: true); + 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; + } + } } } } @@ -143,14 +162,14 @@ void Enter(Node node) private sealed class Node { - public Node(String name, CssVariableValue value) + public Node(String name, CssVariableValue[] values) { Name = name; - Value = value; + Values = values; } public String Name { get; } - public CssVariableValue Value { get; } + public CssVariableValue[] Values { get; } public List Dependencies { get; } = new(); public Int32 Index { get; set; } = -1; public Int32 LowLink { get; set; } diff --git a/src/AngleSharp.Css/Values/CssVariableValue.cs b/src/AngleSharp.Css/Values/CssVariableValue.cs index 3cd2eba0..85d403b7 100644 --- a/src/AngleSharp.Css/Values/CssVariableValue.cs +++ b/src/AngleSharp.Css/Values/CssVariableValue.cs @@ -79,7 +79,7 @@ public CssVariableValue(String text) if (valid) { - _references.Add(i, new Reference(_tokens[name].Data, name, end, separator < end ? separator + 1 : -1)); + _references.Add(i, new Reference(_tokens[name].Data, end, separator < end ? separator + 1 : -1)); } } } @@ -112,23 +112,6 @@ public String? Keyword } } - public IEnumerable> GetReferences() - { - for (var i = 0; i < _tokens.Count; i++) - { - if (_references.TryGetValue(i, out var reference)) - { - var fallback = reference.Fallback >= 0 ? - new CssAnyValue(Text.Substring(Offset(reference.Fallback - 1) + 1, - Offset(reference.End) - Offset(reference.Fallback - 1) - 1).Trim()) : null; - var start = new TextPosition(0, 0, Offset(reference.NameIndex)); - var end = new TextPosition(0, 0, EndOffset(reference.End)); - yield return Tuple.Create(new TextRange(start, end), new CssVarValue(reference.Name, fallback)); - i = reference.End; - } - } - } - public String? Substitute(Func resolve) { if (!IsValid) @@ -262,16 +245,14 @@ private static CssTokenType CloseType(CssTokenType type) => private readonly struct Reference { - public Reference(String name, Int32 nameIndex, Int32 end, Int32 fallback) + public Reference(String name, Int32 end, Int32 fallback) { Name = name; - NameIndex = nameIndex; End = end; Fallback = fallback; } public String Name { get; } - public Int32 NameIndex { get; } public Int32 End { get; } public Int32 Fallback { get; } } diff --git a/src/AngleSharp.Css/Values/Functions/CssVarValue.cs b/src/AngleSharp.Css/Values/Functions/CssVarValue.cs index d9d4167d..29a70689 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,30 @@ public String CssText { get { - var fn = FunctionNames.Var; - var args = new List - { - _variableName, - }; + var text = new StringBuilder(); + var value = this; + var depth = 0; - if (_defaultValue is not null) + while (true) { - args.Add(_defaultValue.CssText); - } + text.Append(FunctionNames.Var).Append('(').Append(value._variableName); + depth++; + + if (value._defaultValue is not null) + { + text.Append(", "); - return fn.CssFunction(String.Join(", ", args)); + if (value._defaultValue is CssVarValue nested) + { + value = nested; + continue; + } + + text.Append(value._defaultValue.CssText); + } + + return text.Append(')', depth).ToString(); + } } } @@ -121,20 +134,25 @@ public Boolean Equals(CssVarValue other) /// The resolved value or null. public ICssValue Compute(ICssComputeContext context) { - var value = context.Resolve(_variableName); + var reference = this; - if (value is not null) + while (true) { - return value.Compute(context); - } + var value = context.Resolve(reference._variableName)?.Compute(context); - if (_defaultValue is null) - { - return null; - } + if (value is not null) + { + return value; + } - var text = new CssVariableValue(_defaultValue.CssText).Substitute(context.Resolve); - return text is null ? null : ((ICssValue)new CssAnyValue(text)).Compute(context); + 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/Raws/CssAnyValue.cs b/src/AngleSharp.Css/Values/Raws/CssAnyValue.cs index 5b3fa40c..33418082 100644 --- a/src/AngleSharp.Css/Values/Raws/CssAnyValue.cs +++ b/src/AngleSharp.Css/Values/Raws/CssAnyValue.cs @@ -64,7 +64,7 @@ ICssValue ICssValue.Compute(ICssComputeContext context) return source.IsDone ? value?.Compute(context) : null; } - return this; + return IsResolved ? this : null; } Boolean IEquatable.Equals(ICssValue other) => other is CssAnyValue o && _text == o.CssText; diff --git a/src/AngleSharp.Css/Values/Raws/CssReferenceValue.cs b/src/AngleSharp.Css/Values/Raws/CssReferenceValue.cs index 128bd9b3..b1844e46 100644 --- a/src/AngleSharp.Css/Values/Raws/CssReferenceValue.cs +++ b/src/AngleSharp.Css/Values/Raws/CssReferenceValue.cs @@ -18,6 +18,7 @@ public sealed class CssReferenceValue : ICssRawValue private readonly TextRange[] _ranges; private readonly CssVarValue[] _references; private readonly CssVariableValue _tokens; + private readonly CssVarValue[] _parsedReferences; #endregion @@ -33,15 +34,12 @@ public CssReferenceValue(String value, IEnumerable _value = value; _ranges = references.Select(m => m.Item1).ToArray(); _references = references.Select(m => m.Item2).ToArray(); - _tokens = new CssVariableValue(value); } - internal CssReferenceValue(CssVariableValue value) + internal CssReferenceValue(CssVariableValue value, IEnumerable> references) + : this(value.Text, references) { - var references = value.GetReferences().ToArray(); - _value = value.Text; - _ranges = references.Select(m => m.Item1).ToArray(); - _references = references.Select(m => m.Item2).ToArray(); + _parsedReferences = (CssVarValue[])_references.Clone(); _tokens = value; } @@ -75,10 +73,69 @@ internal CssReferenceValue(CssVariableValue value) ICssValue ICssValue.Compute(ICssComputeContext context) { + foreach (var reference in _references) + { + var result = reference.Compute(context); + + if (result is not null) + { + return result; + } + } + + 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 From b3972e39b2ec54963d42859dbe5f59a9a04de986 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sat, 5 Sep 2026 17:37:13 +0200 Subject: [PATCH 25/26] Keep cyclic solution in history --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 495a8356..e009b69b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Released on Saturday, September 5 2026 - 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 From 1999a58f090191914032a75474d373abdb344694 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sat, 5 Sep 2026 21:44:45 +0200 Subject: [PATCH 26/26] PR review improvements --- src/AngleSharp.Css/Values/Functions/CssVarValue.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/AngleSharp.Css/Values/Functions/CssVarValue.cs b/src/AngleSharp.Css/Values/Functions/CssVarValue.cs index 29a70689..58a79680 100644 --- a/src/AngleSharp.Css/Values/Functions/CssVarValue.cs +++ b/src/AngleSharp.Css/Values/Functions/CssVarValue.cs @@ -79,11 +79,12 @@ public String CssText { get { - var text = new StringBuilder(); + var text = StringBuilderPool.Obtain(); var value = this; var depth = 0; - while (true) + // use a max-depth of 16384 to avoid stack overflows in case of circular references + while (depth < 16384) { text.Append(FunctionNames.Var).Append('(').Append(value._variableName); depth++; @@ -101,8 +102,10 @@ public String CssText text.Append(value._defaultValue.CssText); } - return text.Append(')', depth).ToString(); + break; } + + return text.Append(')', depth).ToPool(); } }