diff --git a/CHANGELOG.md b/CHANGELOG.md
index 912c852b..99b3950a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,30 @@
+# 1.2.2
+
+Released on Sunday, October 25 2026.
+
+- Improved the cascade to only match the rules that can apply to an element (grouped by the id, class, or tag name of their subject)
+- Improved the render tree to skip computing `::before` and `::after` without a `content` declaration
+- Improved the lookup of property declarations
+- Fixed stack overflow for `rem` in the `font-size` of the root element (now refers to the initial font size)
+- Fixed media queries in the range syntax of Media Queries 4 (`(width >= 600px)`, `(400px < width <= 700px)`), which never matched
+- Fixed media queries combined with `or`, nested conditions (`(a) and ((b) or (c))`), and `not` applying to the whole query
+- Fixed media queries with math functions as values (`(width < calc(50rem + 2rem))`), which were invalid
+- Fixed `var()` substitution separating tokens with comments where they could not merge (e.g. `minmax(/**/15rem/**/,1fr)`), which made grid track lists invalid
+- Fixed `gap` with a single value (e.g. `gap: 8px` or `gap: var(--gap)`) not setting `column-gap`
+- Fixed flow-relative properties (e.g. `padding-inline`, `margin-inline-end`, `inset-inline-start`, `inline-size`) not applying to the physical sides; they are now mapped according to `writing-mode` and `direction`, sharing one cascade with the physical properties
+- Fixed `orientation` media feature to follow the viewport instead of the device
+- Fixed `matchMedia` throwing for an invalid query instead of evaluating it as `not all`
+- Fixed the pseudo-elements of CSS 2 written with a single colon (e.g. `li:after`) not applying to the pseudo-element
+- Fixed pseudo-classes before a pseudo-element (e.g. `li:first-child::after`) being matched against the pseudo-element instead of its originating element
+- Fixed the `background` shorthand not accepting `currentcolor` as its color
+- Fixed `aspect-ratio` not accepting a single number (e.g. `aspect-ratio: 1.8`, meaning `1.8 / 1`)
+- Fixed grid area and line names (e.g. `grid-area: leadingVisual`) and other custom identifiers (`view-transition-name`, `anchor-name`, `position-anchor`, `animation-timeline`, `font-palette`) being lowercased, so they no longer matched case-sensitive names like the ones of `grid-template-areas`
+- Fixed `em` and percentage lengths throwing for an element with `font-size: 0` or an empty box (e.g. in `translate()`)
+- Added cascade layers (`@layer`) to the cascade, whose rules were ignored before
+- Added shadow trees to the render tree: a shadow host renders its shadow tree (styled by the shadow root's style sheets, with the host as scope for `:host`) with slots showing their assigned nodes, unless hidden
+- Added `:host` rules of a shadow tree styling the shadow host in the render tree; they lose against the author rules of the host's own tree unless important
+- Added the presentational hints of HTML elements to the cascade: dimension attributes (e.g. ` `, `
`) and `bgcolor`/`text`/`color` attributes map to their CSS properties, before all author rules
+
# 1.2.1
Released on Friday, October 23 2026.
diff --git a/src/AngleSharp.Css.Docs/package.json b/src/AngleSharp.Css.Docs/package.json
index 05b6fdd6..a2b06a86 100644
--- a/src/AngleSharp.Css.Docs/package.json
+++ b/src/AngleSharp.Css.Docs/package.json
@@ -1,6 +1,6 @@
{
"name": "@anglesharp/css",
- "version": "1.2.1",
+ "version": "1.2.2",
"preview": true,
"description": "The doclet for the AngleSharp.Css documentation.",
"keywords": [
diff --git a/src/AngleSharp.Css.Tests/Declarations/CssBackgroundProperty.cs b/src/AngleSharp.Css.Tests/Declarations/CssBackgroundProperty.cs
index f16f692b..655392f8 100644
--- a/src/AngleSharp.Css.Tests/Declarations/CssBackgroundProperty.cs
+++ b/src/AngleSharp.Css.Tests/Declarations/CssBackgroundProperty.cs
@@ -6,6 +6,17 @@ namespace AngleSharp.Css.Tests.Declarations
[TestFixture]
public class CssBackgroundPropertyTests
{
+ [TestCase("background: currentcolor", "currentColor")]
+ [TestCase("background: url(i.svg) currentColor", "url(\"i.svg\") currentColor")]
+ [TestCase("background: CURRENTCOLOR no-repeat", "no-repeat currentColor")]
+ public void CssBackgroundWithCurrentColorLegal(string snippet, string expected)
+ {
+ var property = ParseDeclaration(snippet);
+ Assert.AreEqual("background", property.Name);
+ Assert.IsTrue(property.HasValue);
+ Assert.AreEqual(expected, property.Value);
+ }
+
[Test]
public void CssBackgroundSizeCoverTest()
{
diff --git a/src/AngleSharp.Css.Tests/Declarations/CssBoxModelProperty.cs b/src/AngleSharp.Css.Tests/Declarations/CssBoxModelProperty.cs
index acf69eac..ea1e25f0 100644
--- a/src/AngleSharp.Css.Tests/Declarations/CssBoxModelProperty.cs
+++ b/src/AngleSharp.Css.Tests/Declarations/CssBoxModelProperty.cs
@@ -53,6 +53,29 @@ public void AspectRatioNegativeLegal()
Assert.IsTrue(property.HasValue);
}
+ // a single number n is the ratio n/1 (found on MDN: aspect-ratio: 1.8)
+ [TestCase("aspect-ratio: 2", "2/1")]
+ [TestCase("aspect-ratio: 1.8", "1.8/1")]
+ [TestCase("aspect-ratio: auto 2", "auto 2/1")]
+ [TestCase("aspect-ratio: 2 auto", "auto 2/1")]
+ public void AspectRatioNumberLegal(string snippet, string expected)
+ {
+ var property = ParseDeclaration(snippet);
+ Assert.AreEqual("aspect-ratio", property.Name);
+ Assert.IsTrue(property.HasValue);
+ Assert.AreEqual(expected, property.Value);
+ }
+
+ [TestCase("aspect-ratio: 2px")]
+ [TestCase("aspect-ratio: -2")]
+ [TestCase("aspect-ratio: 2 3")]
+ public void AspectRatioNumberIllegal(string snippet)
+ {
+ var property = ParseDeclaration(snippet);
+ Assert.AreEqual("aspect-ratio", property.Name);
+ Assert.IsFalse(property.HasValue);
+ }
+
[Test]
public void AspectRatioStringIllegal()
{
diff --git a/src/AngleSharp.Css.Tests/Declarations/CssGridProperty.cs b/src/AngleSharp.Css.Tests/Declarations/CssGridProperty.cs
index e75811f9..65bf7f2d 100644
--- a/src/AngleSharp.Css.Tests/Declarations/CssGridProperty.cs
+++ b/src/AngleSharp.Css.Tests/Declarations/CssGridProperty.cs
@@ -517,6 +517,19 @@ public void CssGridAreaThreeValuesLegal()
Assert.AreEqual("2 / foobar / auto / foobar", property.Value);
}
+ // area and line names are case-sensitive, like the ones of grid-template-areas (found on GitHub)
+ [TestCase("grid-area: leadingVisual", "leadingVisual")]
+ [TestCase("grid-row-start: Header", "Header")]
+ [TestCase("grid-column: span Main", "span Main")]
+ [TestCase("view-transition-name: Card", "Card")]
+ [TestCase("animation-timeline: --Scroller", "--Scroller")]
+ public void CssGridLineNamesKeepTheirCase(string snippet, string expected)
+ {
+ var property = ParseDeclaration(snippet);
+ Assert.IsTrue(property.HasValue);
+ StringAssert.StartsWith(expected, property.Value);
+ }
+
[Test]
public void CssGridAreaSingleValueLegal()
{
diff --git a/src/AngleSharp.Css.Tests/Extensions/MatchMedia.cs b/src/AngleSharp.Css.Tests/Extensions/MatchMedia.cs
index 081f9bdb..4e5b98e8 100644
--- a/src/AngleSharp.Css.Tests/Extensions/MatchMedia.cs
+++ b/src/AngleSharp.Css.Tests/Extensions/MatchMedia.cs
@@ -1,9 +1,11 @@
#nullable disable
namespace AngleSharp.Css.Tests.Extensions
{
+ using AngleSharp.Css.Dom;
using AngleSharp.Dom;
using AngleSharp.Html.Parser;
using NUnit.Framework;
+ using System.Linq;
[TestFixture]
public class MatchMediaTests
@@ -171,6 +173,124 @@ public void MatchMediaKeepsTheProvidedMediaText()
Assert.AreEqual("(min-width: 600px)", window.MatchMedia("(min-width: 600px)").MediaText);
}
+ // Media Queries 4 range syntax, e.g. used by GitHub for all of its breakpoints
+ [TestCase("(width >= 1000px)", true)]
+ [TestCase("(width >= 1001px)", false)]
+ [TestCase("(width <= 1000px)", true)]
+ [TestCase("(width <= 999.98px)", false)]
+ [TestCase("(width > 1000px)", false)]
+ [TestCase("(width > 999px)", true)]
+ [TestCase("(width < 1000px)", false)]
+ [TestCase("(width < 1001px)", true)]
+ [TestCase("(width = 1000px)", true)]
+ [TestCase("(width = 999px)", false)]
+ [TestCase("(width>=1000px)", true)]
+ [TestCase("(1000px <= width)", true)]
+ [TestCase("(1001px <= width)", false)]
+ [TestCase("(1001px > width)", true)]
+ [TestCase("(1000px > width)", false)]
+ [TestCase("(400px < width < 1200px)", true)]
+ [TestCase("(400px < width <= 1000px)", true)]
+ [TestCase("(400px < width < 1000px)", false)]
+ [TestCase("(1200px > width > 400px)", true)]
+ [TestCase("(1200px > width >= 1000px)", true)]
+ [TestCase("(1200px > width > 1000px)", false)]
+ [TestCase("(height > 600px)", true)]
+ [TestCase("(height >= 801px)", false)]
+ [TestCase("screen and (width >= 1000px)", true)]
+ [TestCase("(width >= 768px) and (width < 1012px)", true)]
+ [TestCase("(width >= 1012px) and (height > 600px)", false)]
+ [TestCase("not (width >= 1012px)", true)]
+ [TestCase("not (width < 1012px)", false)]
+ // calc() in a range, e.g. used by MDN for its breakpoints (1rem = 16px here)
+ [TestCase("(width < calc(1rem * 2 + 15rem + 2rem + 31rem))", false)]
+ [TestCase("(width > calc(1rem * 2 + 15rem + 2rem + 31rem))", true)]
+ [TestCase("(min-width: calc(500px + 1rem))", true)]
+ [TestCase("(min-width: calc(1100px + 1rem))", false)]
+ [TestCase("(max-width: min(800px, 50rem))", false)]
+ [TestCase("(max-width: min(1200px, 100rem))", true)]
+ public void MatchMediaEvaluatesRanges(string query, bool expected)
+ {
+ var window = CreateWindow(new DefaultRenderDevice { ViewPortWidth = 1000, ViewPortHeight = 800, FontSize = 16 });
+ Assert.AreEqual(expected, window.MatchMedia(query).IsMatched);
+ }
+
+ // the viewport is 1000x800 here
+ [TestCase("(min-width: 2000px) or (min-height: 600px)", true)]
+ [TestCase("(min-width: 2000px) or (min-height: 900px)", false)]
+ [TestCase("(width >= 2000px) or (width < 1012px)", true)]
+ [TestCase("(min-width: 500px) and ((min-height: 900px) or (orientation: landscape))", true)]
+ [TestCase("(min-width: 500px) and ((min-height: 900px) or (orientation: portrait))", false)]
+ [TestCase("((min-width: 500px) and (min-height: 900px)) or (orientation: landscape)", true)]
+ [TestCase("(min-width: 500px) and (not (min-height: 900px))", true)]
+ [TestCase("not ((min-width: 500px) and (min-height: 900px))", true)]
+ [TestCase("not ((min-width: 500px) and (min-height: 600px))", false)]
+ [TestCase("not ((min-width: 2000px) or (min-height: 600px))", false)]
+ [TestCase("not screen and (min-width: 2000px)", true)]
+ [TestCase("not screen and (min-width: 500px)", false)]
+ [TestCase("not print and (min-width: 500px)", true)]
+ [TestCase("only screen and (min-width: 500px) and (max-width: 1200px)", true)]
+ [TestCase("tv", false)]
+ [TestCase("not tv", true)]
+ [TestCase("not all", false)]
+ public void MatchMediaCombinesConditions(string query, bool expected)
+ {
+ var window = CreateWindow(new DefaultRenderDevice { Category = DeviceCategory.Screen, ViewPortWidth = 1000, ViewPortHeight = 800 });
+ Assert.AreEqual(expected, window.MatchMedia(query).IsMatched);
+ }
+
+ [TestCase(1000, 800, "landscape")]
+ [TestCase(800, 1000, "portrait")]
+ [TestCase(800, 800, "portrait")]
+ public void MatchMediaOrientationFollowsTheViewport(int width, int height, string orientation)
+ {
+ // a landscape device may well show a portrait viewport
+ var window = CreateWindow(new DefaultRenderDevice { ViewPortWidth = width, ViewPortHeight = height, DeviceWidth = 1920, DeviceHeight = 1080 });
+ Assert.IsTrue(window.MatchMedia($"(orientation: {orientation})").IsMatched);
+ }
+
+ [TestCase("(min-width: 500px) and ((min-height: 900px) or (orientation: landscape))")]
+ [TestCase("(min-width: 500px) and (not (min-height: 900px))")]
+ [TestCase("not ((min-width: 500px) and (min-height: 900px))")]
+ public void MatchMediaKeepsNestedConditions(string query)
+ {
+ var window = CreateWindow(new DefaultRenderDevice { ViewPortWidth = 1000, ViewPortHeight = 800 });
+ Assert.AreEqual(query, window.MatchMedia(query).Media.MediaText);
+ }
+
+ [TestCase("(400px < width > 1200px)")]
+ [TestCase("(min-width: )")]
+ [TestCase("screen and")]
+ public void MatchMediaOfAnInvalidQueryIsNotAll(string query)
+ {
+ var window = CreateWindow(new DefaultRenderDevice { ViewPortWidth = 1000, ViewPortHeight = 800 });
+ var list = window.MatchMedia(query);
+
+ Assert.IsFalse(list.IsMatched);
+ Assert.AreEqual("not all", list.Media.MediaText);
+ }
+
+ [Test]
+ public void RangesPointingInDifferentDirectionsAreInvalid()
+ {
+ var context = BrowsingContext.New(Configuration.Default.WithCss());
+ var sheet = (ICssStyleSheet)context.GetService()
+ .ParseDocument("")
+ .StyleSheets[0];
+
+ Assert.AreEqual("not all", sheet.Rules.OfType().Single().ConditionText);
+ }
+
+ [TestCase("(width >= 1012px)")]
+ [TestCase("(1012px <= width)")]
+ [TestCase("(400px < width <= 1200px)")]
+ [TestCase("(1200px > width > 400px)")]
+ public void MatchMediaKeepsTheRangeText(string query)
+ {
+ var window = CreateWindow(new DefaultRenderDevice { ViewPortWidth = 1000, ViewPortHeight = 800 });
+ Assert.AreEqual(query, window.MatchMedia(query).Media.MediaText);
+ }
+
private static IWindow CreateWindow(IRenderDevice device)
{
var config = Configuration.Default.WithCss().WithRenderDevice(device);
diff --git a/src/AngleSharp.Css.Tests/Extensions/PseudoElementRenderTests.cs b/src/AngleSharp.Css.Tests/Extensions/PseudoElementRenderTests.cs
index 22ca4e95..88461b22 100644
--- a/src/AngleSharp.Css.Tests/Extensions/PseudoElementRenderTests.cs
+++ b/src/AngleSharp.Css.Tests/Extensions/PseudoElementRenderTests.cs
@@ -178,5 +178,24 @@ public void PseudoElementDefaultsToInlineDisplay()
Assert.AreEqual("inline", before.ComputedStyle.GetDisplay());
}
- }
+
+ // the pseudo-elements of CSS 2 can be written with a single colon, and the pseudo-classes
+ // before the pseudo-element refer to the originating element (e.g. MDN's breadcrumbs)
+ [TestCase("#target:after", true)]
+ [TestCase("li:after", true)]
+ [TestCase("#target:not(.x):after", true)]
+ [TestCase("li:first-child::after", true)]
+ [TestCase("li:first-child:after", true)]
+ [TestCase(":is(ul li):not(:last-child):after", true)]
+ [TestCase("li:last-child::after", false)]
+ [TestCase("li:not(:last-child)::after", true)]
+ public void PseudoClassesBeforeAPseudoElementReferToTheOriginatingElement(string selector, bool expected)
+ {
+ var root = Render($"" +
+ "");
+ var target = FindElement(root, "target");
+
+ Assert.AreEqual(expected, target.Children.Any(m => m.Ref is IPseudoElement));
+ }
+}
}
diff --git a/src/AngleSharp.Css.Tests/Extensions/ShadowTreeRenderTests.cs b/src/AngleSharp.Css.Tests/Extensions/ShadowTreeRenderTests.cs
new file mode 100644
index 00000000..5658d5bf
--- /dev/null
+++ b/src/AngleSharp.Css.Tests/Extensions/ShadowTreeRenderTests.cs
@@ -0,0 +1,153 @@
+#nullable disable
+namespace AngleSharp.Css.Tests.Extensions
+{
+ using System.Collections.Generic;
+ using System.Linq;
+ using AngleSharp.Css.Dom;
+ using AngleSharp.Css.Parser;
+ using AngleSharp.Css.RenderTree;
+ using AngleSharp.Css.Tests.Mocks;
+ using AngleSharp.Dom;
+ using NUnit.Framework;
+ using static CssConstructionFunctions;
+
+ ///
+ /// The render tree only walked the light DOM, so the content of a shadow root (e.g. MDN's
+ /// search button) never showed. A shadow host renders its shadow tree - styled by the shadow
+ /// root's own style sheets - and a slot in there the light nodes assigned to it.
+ ///
+ [TestFixture]
+ public class ShadowTreeRenderTests
+ {
+ private static IDocument CreateHost(string light, string shadow)
+ {
+ var document = ParseDocument($"{light}
");
+ document.GetElementById("host").AttachShadow().InnerHtml = shadow;
+ return document;
+ }
+
+ // :host needs AngleSharp 1.8 or later, while the minimum version is 1.5
+ private static void AssumeHostIsSupported()
+ {
+ var parser = BrowsingContext.New(Configuration.Default.WithCss()).GetService();
+ Assume.That(parser.ParseSelector(":host"), Is.Not.Null, "The AngleSharp version does not support :host.");
+ }
+
+ private static ElementRenderNode Render(IDocument document) =>
+ (ElementRenderNode)document.DefaultView.Render(new PlainRenderDevice());
+
+ private static IEnumerable Flatten(ElementRenderNode node) =>
+ new[] { node }.Concat(node.Children.OfType().SelectMany(Flatten));
+
+ // the text that shows, i.e. without the one of hidden elements (like style)
+ private static string TextOf(ElementRenderNode node) =>
+ node.ComputedStyle.GetDisplay() == "none"
+ ? string.Empty
+ : string.Concat(node.Children.Select(m => m is TextRenderNode text ? text.Ref.TextContent : m is ElementRenderNode element ? TextOf(element) : string.Empty));
+
+ private static ElementRenderNode Host(ElementRenderNode root) => Flatten(root).First(m => m.Ref.Id == "host");
+
+ [Test]
+ public void HostRendersItsShadowTreeInsteadOfItsChildren()
+ {
+ var document = CreateHost("Light
", "Shadow ");
+
+ var host = Host(Render(document));
+
+ Assert.AreEqual("Shadow", TextOf(host));
+ }
+
+ [Test]
+ public void SlotsShowTheLightNodesAssignedToThem()
+ {
+ var document = CreateHost("Title Body
", "[ | ]");
+
+ var host = Host(Render(document));
+
+ Assert.AreEqual("[Title|Body]", TextOf(host));
+ }
+
+ [Test]
+ public void SlotsWithoutAssignedNodesShowTheirFallback()
+ {
+ var document = CreateHost("", "Fallback ");
+
+ var host = Host(Render(document));
+
+ Assert.AreEqual("Fallback", TextOf(host));
+ }
+
+ [Test]
+ public void HiddenSlotsHideTheirAssignedNodes()
+ {
+ // e.g. a closed dropdown: :host(:not([open])) slot[name=dropdown] { display: none }
+ var document = CreateHost("Panel Button ", "Fallback ");
+
+ var host = Host(Render(document));
+
+ Assert.AreEqual("Button", TextOf(host));
+ }
+
+ [Test]
+ public void HostRulesStyleTheHost()
+ {
+ AssumeHostIsSupported();
+
+ // e.g. MDN's custom elements: :host { display: contents }
+ var document = CreateHost("", "Shadow");
+
+ var host = Host(Render(document));
+
+ Assert.AreEqual("inline-block", host.ComputedStyle.GetDisplay());
+ Assert.AreEqual("rgba(0, 128, 0, 1)", host.ComputedStyle.GetColor());
+ }
+
+ [TestCase(":host { color: green }", "")]
+ [TestCase(":host { color: red }", "#host { color: green }")]
+ [TestCase(":host { color: red }", "div { color: green }")]
+ [TestCase(":host { color: green !important }", "#host { color: red }")]
+ [TestCase(":host { color: green !important }", "#host { color: red !important }")]
+ [TestCase(":host { color: red } :host { color: green }", "")]
+ [TestCase("div, :host { color: green }", "")]
+ public void HostRulesLoseAgainstTheHostsTreeUnlessImportant(string shadowCss, string outerCss)
+ {
+ AssumeHostIsSupported();
+
+ var document = ParseDocument($"
");
+ document.GetElementById("host").AttachShadow().InnerHtml = $"";
+
+ var host = Host(Render(document));
+
+ Assert.AreEqual("rgba(0, 128, 0, 1)", host.ComputedStyle.GetColor());
+ }
+
+ [TestCase("div { color: red }")]
+ [TestCase("#host { color: red }")]
+ [TestCase(":host div { color: red }")]
+ [TestCase(":host > * { color: red }")]
+ public void OtherShadowRulesDoNotStyleTheHost(string shadowCss)
+ {
+ AssumeHostIsSupported();
+
+ var document = ParseDocument("
");
+ document.GetElementById("host").AttachShadow().InnerHtml = $"";
+
+ var host = Host(Render(document));
+
+ Assert.AreEqual("rgba(0, 128, 0, 1)", host.ComputedStyle.GetColor());
+ }
+
+ [Test]
+ public void ShadowTreesAreStyledByTheirOwnStyleSheets()
+ {
+ var document = CreateHost("Light ", "Shadow ");
+
+ var nodes = Flatten(Render(document)).ToList();
+ var shadow = nodes.First(m => m.Ref.Id == "shadow");
+ var light = nodes.First(m => m.Ref.Id == "light");
+
+ Assert.AreEqual("rgba(0, 128, 0, 1)", shadow.ComputedStyle.GetColor());
+ Assert.AreEqual("rgba(255, 0, 0, 1)", light.ComputedStyle.GetColor());
+ }
+ }
+}
diff --git a/src/AngleSharp.Css.Tests/Rules/CssMediaFeatures.cs b/src/AngleSharp.Css.Tests/Rules/CssMediaFeatures.cs
index 2ca820a4..376a18fe 100644
--- a/src/AngleSharp.Css.Tests/Rules/CssMediaFeatures.cs
+++ b/src/AngleSharp.Css.Tests/Rules/CssMediaFeatures.cs
@@ -75,7 +75,7 @@ public void CssMediaOrientationAndScanValidation()
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 landscapeDevice = new DefaultRenderDevice { DeviceWidth = 1024, DeviceHeight = 768, ViewPortWidth = 1024, ViewPortHeight = 768 };
var interlacedDevice = new DefaultRenderDevice { IsInterlaced = true };
Assert.IsFalse(portrait(landscapeDevice));
diff --git a/src/AngleSharp.Css.Tests/Styling/CascadeLayerTests.cs b/src/AngleSharp.Css.Tests/Styling/CascadeLayerTests.cs
new file mode 100644
index 00000000..091187c2
--- /dev/null
+++ b/src/AngleSharp.Css.Tests/Styling/CascadeLayerTests.cs
@@ -0,0 +1,48 @@
+#nullable disable
+namespace AngleSharp.Css.Tests.Styling
+{
+ using AngleSharp.Css.Dom;
+ using AngleSharp.Dom;
+ using NUnit.Framework;
+ using System.Threading.Tasks;
+
+ ///
+ /// Rules inside `@layer` blocks were left out of the cascade entirely. Found on GitHub, which
+ /// puts its design systems into layers (e.g. a tooltip's `display: none` never applied).
+ /// The cascade orders layers by their first appearance, with unlayered rules last, a layer's
+ /// own rules after its sub-layers, and the order reversed for important declarations
+ /// (CSS Cascade 5, 6.4).
+ ///
+ [TestFixture]
+ public class CascadeLayerTests
+ {
+ private const string Green = "rgba(0, 128, 0, 1)";
+
+ private static async Task ColorOfAsync(string css)
+ {
+ var context = BrowsingContext.New(Configuration.Default.WithCss());
+ var document = await context.OpenAsync(req => req.Content($"A
"));
+ return document.DefaultView.GetComputedStyle(document.GetElementById("t")).GetColor();
+ }
+
+ [TestCase("@layer x { p { color: green } }")]
+ [TestCase("@media screen { @layer x { p { color: green } } }")]
+ [TestCase("p { color: green } @layer x { #t { color: red } }")]
+ [TestCase("@layer x { #t { color: red } } p { color: green }")]
+ [TestCase("@layer a { #t { color: red } } @layer b { p { color: green } }")]
+ [TestCase("@layer b, a; @layer a { p { color: green } } @layer b { #t { color: red } }")]
+ [TestCase("@layer a { #t { color: red } } @layer b { p { color: red } } @layer a { p.x, p { color: blue } } @layer b { p { color: green } }")]
+ [TestCase("@layer a { p { color: green } @layer b { #t { color: red } } }")]
+ [TestCase("@layer a.b { #t { color: red } } @layer a { p { color: green } }")]
+ [TestCase("@layer { #t { color: red } } @layer { p { color: green } }")]
+ [TestCase("@layer { #t { color: red } } p { color: green }")]
+ [TestCase("@layer a { p { color: green !important } } @layer b { p { color: red !important } }")]
+ [TestCase("p { color: red !important } @layer a { p { color: green !important } }")]
+ [TestCase("@layer a { p { color: green !important } } p { color: red }")]
+ [TestCase("@layer a { #t { color: red } p { color: green !important } } @layer b { #t { color: blue } }")]
+ public async Task LayersTakePartInTheCascade(string css)
+ {
+ Assert.AreEqual(Green, await ColorOfAsync(css));
+ }
+ }
+}
diff --git a/src/AngleSharp.Css.Tests/Styling/CustomPropertySubstitutionTests.cs b/src/AngleSharp.Css.Tests/Styling/CustomPropertySubstitutionTests.cs
new file mode 100644
index 00000000..6a0f894c
--- /dev/null
+++ b/src/AngleSharp.Css.Tests/Styling/CustomPropertySubstitutionTests.cs
@@ -0,0 +1,45 @@
+#nullable disable
+namespace AngleSharp.Css.Tests.Styling
+{
+ using AngleSharp.Css.Dom;
+ using AngleSharp.Dom;
+ using NUnit.Framework;
+ using System.Threading.Tasks;
+
+ ///
+ /// Substituting `var()` separated the substituted tokens with empty comments whenever no
+ /// whitespace followed, e.g. `minmax(/**/15rem/**/,1fr)`, which some value parsers (like the
+ /// one for grid track lists) do not accept. Found on MDN, whose page grid fell back to `none`.
+ /// A separator is only needed where two tokens would otherwise merge.
+ ///
+ [TestFixture]
+ public class CustomPropertySubstitutionTests
+ {
+ private static async Task ComputeAsync(string declarations)
+ {
+ var context = BrowsingContext.New(Configuration.Default.WithCss());
+ var document = await context.OpenAsync(req => req.Content($"A
"));
+ return document.DefaultView.GetComputedStyle(document.GetElementById("t"));
+ }
+
+ [Test]
+ public async Task NestedReferencesInATrackListAreValid()
+ {
+ var style = await ComputeAsync("display: grid; --x: 15rem; --cols: minmax(var(--x),1fr) 2rem; grid-template-columns: var(--cols)");
+
+ var columns = style.GetPropertyValue("grid-template-columns");
+
+ Assert.AreNotEqual("none", columns);
+ StringAssert.DoesNotContain("/**/", columns);
+ }
+
+ [Test]
+ public async Task SubstitutedTokensDoNotMergeWithTheirNeighbors()
+ {
+ // var(--n)px must stay the number 5 followed by px, not become the length 5px
+ var style = await ComputeAsync("--n: 5; margin-left: var(--n)px");
+
+ Assert.AreEqual("0px", style.GetPropertyValue("margin-left"));
+ }
+ }
+}
diff --git a/src/AngleSharp.Css.Tests/Styling/LogicalPropertyTests.cs b/src/AngleSharp.Css.Tests/Styling/LogicalPropertyTests.cs
new file mode 100644
index 00000000..4a0afd34
--- /dev/null
+++ b/src/AngleSharp.Css.Tests/Styling/LogicalPropertyTests.cs
@@ -0,0 +1,62 @@
+#nullable disable
+namespace AngleSharp.Css.Tests.Styling
+{
+ using AngleSharp.Css.Dom;
+ using AngleSharp.Dom;
+ using NUnit.Framework;
+ using System.Threading.Tasks;
+
+ ///
+ /// Flow-relative properties like padding-inline were not mapped to the physical sides, so
+ /// GitHub's repository tabs had no padding. They share one cascade with the physical ones
+ /// (CSS Logical Properties 1 §4).
+ ///
+ [TestFixture]
+ public class LogicalPropertyTests
+ {
+ private static async Task StyleOfAsync(string css)
+ {
+ var context = BrowsingContext.New(Configuration.Default.WithCss());
+ var document = await context.OpenAsync(req => req.Content($"
"));
+ return document.DefaultView.GetComputedStyle(document.GetElementById("t"));
+ }
+
+ // top right bottom left
+ [TestCase("padding-inline: 8px 2px", " 2px 8px")]
+ [TestCase("padding-inline: var(--p, 8px)", " 8px 8px")]
+ [TestCase("padding-block-start: 3px; padding-inline-end: 4px", "3px 4px ")]
+ [TestCase("padding: 1px; padding-inline-start: 8px", "1px 1px 1px 8px")]
+ [TestCase("padding-inline-start: 8px; padding: 1px", "1px 1px 1px 1px")]
+ [TestCase("padding-left: 3px !important; padding-inline-start: 8px", " 3px")]
+ [TestCase("direction: rtl; padding-inline-start: 8px", " 8px ")]
+ [TestCase("writing-mode: vertical-rl; padding-block-start: 8px; padding-inline-start: 2px", "2px 8px ")]
+ [TestCase("writing-mode: vertical-lr; padding-block-start: 8px", " 8px")]
+ public async Task LogicalPaddingMapsToThePhysicalSides(string css, string expected)
+ {
+ var style = await StyleOfAsync(css);
+
+ Assert.AreEqual(expected, $"{style.GetPaddingTop()} {style.GetPaddingRight()} {style.GetPaddingBottom()} {style.GetPaddingLeft()}");
+ }
+
+ [Test]
+ public async Task LogicalMarginsInsetsAndSizesMapToPhysicalOnes()
+ {
+ var style = await StyleOfAsync("margin-inline-end: var(--m, 4px); inset-inline-start: 5px; border-block-end-width: 2px; inline-size: 50px; max-block-size: 20px");
+
+ Assert.AreEqual("4px", style.GetMarginRight());
+ Assert.AreEqual("5px", style.GetLeft());
+ Assert.AreEqual("2px", style.GetPropertyValue("border-bottom-width"));
+ Assert.AreEqual("50px", style.GetWidth());
+ Assert.AreEqual("20px", style.GetMaxHeight());
+ }
+
+ [Test]
+ public async Task SingleGapSetsBothGaps()
+ {
+ var style = await StyleOfAsync("gap: var(--g, 8px)");
+
+ Assert.AreEqual("8px", style.GetPropertyValue("row-gap"));
+ Assert.AreEqual("8px", style.GetPropertyValue("column-gap"));
+ }
+ }
+}
diff --git a/src/AngleSharp.Css.Tests/Styling/PresentationalHintTests.cs b/src/AngleSharp.Css.Tests/Styling/PresentationalHintTests.cs
new file mode 100644
index 00000000..2986aa03
--- /dev/null
+++ b/src/AngleSharp.Css.Tests/Styling/PresentationalHintTests.cs
@@ -0,0 +1,71 @@
+#nullable disable
+namespace AngleSharp.Css.Tests.Styling
+{
+ using AngleSharp.Css.Dom;
+ using AngleSharp.Dom;
+ using NUnit.Framework;
+ using System.Threading.Tasks;
+
+ ///
+ /// Attributes like the width of an img were ignored, so images showed in their natural size.
+ /// Found on MDN. They map to CSS (HTML 15.3 - 15.4) like author rules of no specificity,
+ /// which precede all other author rules.
+ ///
+ [TestFixture]
+ public class PresentationalHintTests
+ {
+ private static async Task StyleOfAsync(string css, string html)
+ {
+ var context = BrowsingContext.New(Configuration.Default.WithCss());
+ var document = await context.OpenAsync(req => req.Content($"{html}"));
+ return document.DefaultView.GetComputedStyle(document.GetElementById("t"));
+ }
+
+ // an empty value means none was set
+ [TestCase(" ", "40px", "30px")]
+ [TestCase(" ", "40.5px", "30px")]
+ [TestCase(" ", "50%", "")]
+ [TestCase(" ", "", "")]
+ [TestCase(" ", "40px", "30px")]
+ [TestCase("", "40px", "30px")]
+ [TestCase(" ", "40px", "30px")]
+ [TestCase(" ", "", "")]
+ [TestCase("
", "", "")]
+ [TestCase("", "", "30px")]
+ public async Task DimensionAttributesMapToTheirProperties(string html, string width, string height)
+ {
+ var style = await StyleOfAsync("", html);
+
+ Assert.AreEqual(width, style.GetWidth());
+ Assert.AreEqual(height, style.GetHeight());
+ }
+
+ [TestCase("img { width: 20px }", "20px")]
+ [TestCase("* { width: 20px }", "20px")]
+ [TestCase("", "40px")]
+ public async Task AuthorRulesOverrideTheHints(string css, string width)
+ {
+ var style = await StyleOfAsync(css, " ");
+
+ Assert.AreEqual(width, style.GetWidth());
+ }
+
+ [Test]
+ public async Task InlineStylesOverrideTheHints()
+ {
+ var style = await StyleOfAsync("", " ");
+
+ Assert.AreEqual("20px", style.GetWidth());
+ }
+
+ [TestCase("")]
+ [TestCase("")]
+ [TestCase("")]
+ public async Task BackgroundColorAttributesMapToTheBackgroundColor(string html)
+ {
+ var style = await StyleOfAsync("", html);
+
+ Assert.AreEqual("rgba(255, 0, 0, 1)", style.GetBackgroundColor());
+ }
+ }
+}
diff --git a/src/AngleSharp.Css.Tests/Styling/RelativeValueComputedStyleTests.cs b/src/AngleSharp.Css.Tests/Styling/RelativeValueComputedStyleTests.cs
index c251a3c0..add6992e 100644
--- a/src/AngleSharp.Css.Tests/Styling/RelativeValueComputedStyleTests.cs
+++ b/src/AngleSharp.Css.Tests/Styling/RelativeValueComputedStyleTests.cs
@@ -52,6 +52,31 @@ public void RemResolvesAgainstTheRootFontSize()
Assert.AreEqual("20px", Computed("x
", "width"));
}
+ // rem in the font-size of the root refers to the initial font size (CSS Values 4, 6.1.1),
+ // instead of recursing into the font size being computed - found on developer.mozilla.org
+ [TestCase("font-size: 2rem", "32px")]
+ [TestCase("--size: 2rem; font-size: var(--size)", "32px")]
+ [TestCase("--size: 1.5rem; font-size: calc(var(--size) + 2px)", "26px")]
+ public void RemInTheFontSizeOfTheRootRefersToTheInitialFontSize(string declarations, string expected)
+ {
+ var html = $"x
";
+
+ Assert.AreEqual(expected, Computed(html, "font-size"));
+ }
+
+ [Test]
+ public void RemInTheFontSizeOfTheRootCanBeRendered()
+ {
+ var config = Configuration.Default.WithCss().WithRenderDevice(new DefaultRenderDevice { FontSize = 16 });
+ var document = BrowsingContext.New(config).GetService().ParseDocument(
+ "x
");
+
+ var tree = document.DefaultView.Render();
+
+ Assert.IsNotNull(tree);
+ Assert.AreEqual("20px", document.GetElementById("target").ComputeCurrentStyle().GetPropertyValue("width"));
+ }
+
[TestCase("width: 50%", "width", "50%", "100px")]
[TestCase("margin-left: 10%", "margin-left", "10%", "20px")]
[TestCase("padding-top: 5%", "padding-top", "5%", "10px")]
diff --git a/src/AngleSharp.Css.Tests/Styling/StyleRuleIndexTests.cs b/src/AngleSharp.Css.Tests/Styling/StyleRuleIndexTests.cs
new file mode 100644
index 00000000..5186e7e6
--- /dev/null
+++ b/src/AngleSharp.Css.Tests/Styling/StyleRuleIndexTests.cs
@@ -0,0 +1,99 @@
+#nullable disable
+namespace AngleSharp.Css.Tests.Styling
+{
+ using AngleSharp.Css.Dom;
+ using AngleSharp.Css.RenderTree;
+ using AngleSharp.Css.Tests.Mocks;
+ using AngleSharp.Dom;
+ using NUnit.Framework;
+ using System.Linq;
+ using System.Threading.Tasks;
+
+ ///
+ /// The cascade only matches an element against the rules grouped under its
+ /// id, classes, and tag name (plus the rules that cannot be grouped), and a
+ /// pseudo-element only against the rules targeting pseudo-elements. Matching
+ /// every rule against every element made pages with large style sheets (e.g.,
+ /// GitHub) take minutes to render in a downstream browser.
+ ///
+ [TestFixture]
+ public class StyleRuleIndexTests
+ {
+ private const string Green = "rgba(0, 128, 0, 1)";
+
+ private static async Task ColorOfAsync(string css, string body, string id = "t")
+ {
+ var context = BrowsingContext.New(Configuration.Default.WithCss());
+ var document = await context.OpenAsync(req => req.Content($"{body}"));
+ return document.DefaultView.GetComputedStyle(document.GetElementById(id)).GetColor();
+ }
+
+ [TestCase("[data-x] { color: red } .a { color: green }")]
+ [TestCase(".a { color: red } [data-x] { color: green }")]
+ [TestCase("[data-x][data-x] { color: red } .a.b { color: red } div[data-x] { color: blue } .b[data-x] { color: green }")]
+ [TestCase("#t { color: red } * { color: blue } div#t { color: green }")]
+ public async Task GroupedAndUngroupedRulesKeepTheCascadeOrder(string css)
+ {
+ var color = await ColorOfAsync(css, "A
");
+
+ Assert.AreEqual(Green, color);
+ }
+
+ [TestCase(".a, .b { color: green }", "A
")]
+ [TestCase(".x, #t { color: green }", "A
")]
+ [TestCase(".x, p { color: green }", "A
")]
+ [TestCase(".x, [data-y] { color: green }", "A
")]
+ [TestCase("#t.y { color: green }", "A
")]
+ [TestCase("p.y { color: green }", "A
")]
+ [TestCase(".a span { color: green }", "A
")]
+ [TestCase(".a > .b + .c { color: green }", "A
")]
+ [TestCase("P { color: green }", "A
")]
+ [TestCase("div:not(.a) { color: green }", "A
")]
+ [TestCase(":is(.a, .b) { color: green }", "A
")]
+ [TestCase(":where(#x) .a, :has(.c) { color: green }", "
")]
+ [TestCase("foreignObject { color: green }", "A ")]
+ [TestCase("@namespace svg url(http://www.w3.org/2000/svg); svg|text { color: green }", "A ")]
+ [TestCase(".parent { .child { color: green } }", "")]
+ [TestCase(".parent { & .child { color: green } }", "")]
+ [TestCase("@media screen { .a { color: green } }", "A
")]
+ public async Task RulesAreFoundViaTheirSubject(string css, string body)
+ {
+ var color = await ColorOfAsync(css, body);
+
+ Assert.AreEqual(Green, color);
+ }
+
+ [TestCase("#x.y { color: red }", "A
")]
+ [TestCase(".a span { color: red }", "A
")]
+ [TestCase(".a .b { color: red }", "")]
+ public async Task RulesForOtherSubjectsDoNotApply(string css, string body)
+ {
+ var color = await ColorOfAsync(css, body);
+
+ Assert.AreNotEqual("rgba(255, 0, 0, 1)", color);
+ }
+
+ [Test]
+ public void PseudoElementsAreOnlyGeneratedWithContent()
+ {
+ var document = CssConstructionFunctions.ParseDocument("1
2
3
");
+ var root = (ElementRenderNode)document.DefaultView.Render(new PlainRenderDevice());
+ var nodes = Flatten(root).ToArray();
+
+ var a = nodes.First(m => m.Ref.Id == "a").Children.OfType().ToArray();
+ var b = nodes.First(m => m.Ref.Id == "b").Children.OfType().ToArray();
+ var c = nodes.First(m => m.Ref.Id == "c").Children.OfType().ToArray();
+
+ Assert.AreEqual(1, a.Length);
+ Assert.IsInstanceOf(a[0].Ref);
+ Assert.AreEqual(1, b.Length);
+ Assert.AreEqual(Green, b[0].ComputedStyle.GetPropertyValue("color"));
+ Assert.AreEqual(0, c.Length);
+ }
+
+ private static System.Collections.Generic.IEnumerable Flatten(ElementRenderNode node) =>
+ new[] { node }.Concat(node.Children.OfType().SelectMany(Flatten));
+ }
+}
diff --git a/src/AngleSharp.Css.Tests/Values/UnitConversion.cs b/src/AngleSharp.Css.Tests/Values/UnitConversion.cs
index 59eeeccd..3d54b5e6 100644
--- a/src/AngleSharp.Css.Tests/Values/UnitConversion.cs
+++ b/src/AngleSharp.Css.Tests/Values/UnitConversion.cs
@@ -56,6 +56,40 @@ public void LengthToPixelsEmThrowsOnInvalidRenderDimensions()
Assert.Throws(() => l.ToPixel(null, RenderMode.Undefined));
}
+ [Test]
+ public void LengthToPixelsEmIsZeroForAnElementWithoutFontSize()
+ {
+ // e.g. translate(1em) on an element with font-size: 0, found on GitHub
+ var l = new CssLengthValue(50, CssLengthValue.Unit.Em);
+ Assert.AreEqual(0.0, l.ToPixel(new ElementDimensions(), RenderMode.Undefined));
+ }
+
+ [Test]
+ public void LengthToPixelsPercentIsZeroForAnEmptyElement()
+ {
+ // e.g. translate(-50%) on an element without a width
+ var l = new CssLengthValue(-50, CssLengthValue.Unit.Percent);
+ Assert.AreEqual(0.0, l.ToPixel(new EmptyElementDimensions(), RenderMode.Horizontal));
+ }
+
+ private sealed class EmptyElementDimensions : IRenderDimensions
+ {
+ public double RenderWidth => 0;
+
+ public double RenderHeight => 0;
+
+ public double FontSize => 16;
+ }
+
+ private sealed class ElementDimensions : IRenderDimensions
+ {
+ public double RenderWidth => 100;
+
+ public double RenderHeight => 50;
+
+ public double FontSize => 0;
+ }
+
[Test]
public void LengthToPixelsCorrectPercentWidth()
{
diff --git a/src/AngleSharp.Css/BrowsingContextExtensions.cs b/src/AngleSharp.Css/BrowsingContextExtensions.cs
index 5cecbb1c..2e59013f 100644
--- a/src/AngleSharp.Css/BrowsingContextExtensions.cs
+++ b/src/AngleSharp.Css/BrowsingContextExtensions.cs
@@ -6,6 +6,7 @@ namespace AngleSharp.Css
using AngleSharp.Dom;
using AngleSharp.Io;
using System;
+ using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
@@ -14,6 +15,9 @@ namespace AngleSharp.Css
///
public static class BrowsingContextExtensions
{
+ // the services of a context are fixed; resolving them walks all services
+ private static readonly ConditionalWeakTable DeclarationFactories = new();
+
///
/// Loads a stylesheet resource via its URL.
///
@@ -52,15 +56,18 @@ public static async Task OpenStyleSheetAsync(this IBrowsingContext
return null;
}
+ internal static IDeclarationFactory GetDeclarationFactory(this IBrowsingContext context) =>
+ DeclarationFactories.GetValue(context, ctx => ctx.GetFactory());
+
internal static DeclarationInfo GetDeclarationInfo(this IBrowsingContext context, String propertyName)
{
- var factory = context.GetFactory();
+ var factory = context.GetDeclarationFactory();
return factory.Create(propertyName);
}
internal static ICssProperty CreateShorthand(this IBrowsingContext context, String name, ICssValue[] longhands, Boolean important)
{
- var factory = context.GetFactory();
+ var factory = context.GetDeclarationFactory();
var info = factory.Create(name);
var value = info.Collapse(factory, longhands);
@@ -74,7 +81,7 @@ internal static ICssProperty CreateShorthand(this IBrowsingContext context, Stri
internal static ICssProperty[] CreateLonghands(this IBrowsingContext context, ICssProperty shorthand)
{
- var factory = context.GetFactory();
+ var factory = context.GetDeclarationFactory();
var info = factory.Create(shorthand.Name);
var values = info.Expand(factory, shorthand.RawValue);
return factory.CreateProperties(info.Longhands, values, shorthand.IsImportant);
diff --git a/src/AngleSharp.Css/Declarations/BackgroundDeclaration.cs b/src/AngleSharp.Css/Declarations/BackgroundDeclaration.cs
index cd8f6d8a..81248c8d 100644
--- a/src/AngleSharp.Css/Declarations/BackgroundDeclaration.cs
+++ b/src/AngleSharp.Css/Declarations/BackgroundDeclaration.cs
@@ -160,7 +160,7 @@ public ICssValue Convert(StringSource source)
if (color == null)
{
- var parsedColor = ColorParser.ParseColor(source);
+ var parsedColor = ColorParser.ParseCurrentColor(source);
if (parsedColor.HasValue)
{
diff --git a/src/AngleSharp.Css/Declarations/GapDeclaration.cs b/src/AngleSharp.Css/Declarations/GapDeclaration.cs
index 8f5cc9f9..ee409f49 100644
--- a/src/AngleSharp.Css/Declarations/GapDeclaration.cs
+++ b/src/AngleSharp.Css/Declarations/GapDeclaration.cs
@@ -38,6 +38,11 @@ public ICssValue Merge(ICssValue[] values)
var row = values[0];
var col = values[1];
+ if (row != null && col != null && row.CssText.Is(col.CssText))
+ {
+ return new CssTupleValue(new[] { row });
+ }
+
if (row != null || col != null)
{
return new CssTupleValue(new[] { row, col });
@@ -50,10 +55,11 @@ public ICssValue[] Split(ICssValue value)
{
if (value is CssTupleValue list)
{
+ // `gap: 8px` sets both gaps
return new[]
{
list.Items[0],
- list.Items[1],
+ list.Items.Length > 1 && list.Items[1] != null ? list.Items[1] : list.Items[0],
};
}
diff --git a/src/AngleSharp.Css/Declarations/GridAreaDeclaration.cs b/src/AngleSharp.Css/Declarations/GridAreaDeclaration.cs
index 81d6ff27..b705c181 100644
--- a/src/AngleSharp.Css/Declarations/GridAreaDeclaration.cs
+++ b/src/AngleSharp.Css/Declarations/GridAreaDeclaration.cs
@@ -35,7 +35,7 @@ sealed class GridAreaAggregator : IValueAggregator, IValueConverter
private static readonly IValueConverter converter = SlashSeparated(Or(
Assign(CssKeywords.Auto, null),
VarConverter,
- WithAny(Assign(CssKeywords.Span, true), IntegerConverter, IdentifierConverter))).Many(1, 4, seperator);
+ WithAny(Assign(CssKeywords.Span, true), IntegerConverter, CustomIdentConverter))).Many(1, 4, seperator);
public ICssValue Convert(StringSource source) => converter.Convert(source);
diff --git a/src/AngleSharp.Css/Declarations/GridColumnDeclaration.cs b/src/AngleSharp.Css/Declarations/GridColumnDeclaration.cs
index 26c720c2..f0f0aa8a 100644
--- a/src/AngleSharp.Css/Declarations/GridColumnDeclaration.cs
+++ b/src/AngleSharp.Css/Declarations/GridColumnDeclaration.cs
@@ -29,7 +29,7 @@ sealed class GridColumnAggregator : IValueAggregator, IValueConverter
private static readonly String seperator = " / ";
private static readonly IValueConverter converter = SlashSeparated(Or(
Assign(CssKeywords.Auto, null),
- WithAny(Assign(CssKeywords.Span, true), IntegerConverter, IdentifierConverter))).Many(1, 2, seperator);
+ WithAny(Assign(CssKeywords.Span, true), IntegerConverter, CustomIdentConverter))).Many(1, 2, seperator);
public ICssValue Convert(StringSource source) => converter.Convert(source);
diff --git a/src/AngleSharp.Css/Declarations/GridRowDeclaration.cs b/src/AngleSharp.Css/Declarations/GridRowDeclaration.cs
index 76550c87..ea769ffc 100644
--- a/src/AngleSharp.Css/Declarations/GridRowDeclaration.cs
+++ b/src/AngleSharp.Css/Declarations/GridRowDeclaration.cs
@@ -29,7 +29,7 @@ sealed class GridRowAggregator : IValueAggregator, IValueConverter
private static readonly String seperator = " / ";
private static readonly IValueConverter converter = SlashSeparated(Or(
Assign(CssKeywords.Auto, null),
- WithAny(Assign(CssKeywords.Span, true), IntegerConverter, IdentifierConverter))).Many(1, 2, seperator);
+ WithAny(Assign(CssKeywords.Span, true), IntegerConverter, CustomIdentConverter))).Many(1, 2, seperator);
public ICssValue Convert(StringSource source)
{
diff --git a/src/AngleSharp.Css/Dom/Internal/CssProperty.cs b/src/AngleSharp.Css/Dom/Internal/CssProperty.cs
index 1e229a84..98138ec2 100644
--- a/src/AngleSharp.Css/Dom/Internal/CssProperty.cs
+++ b/src/AngleSharp.Css/Dom/Internal/CssProperty.cs
@@ -30,7 +30,7 @@ internal class CssProperty : ICssProperty
internal CssProperty(String name, IValueConverter converter, PropertyFlags flags = PropertyFlags.None, ICssValue value = null, Boolean important = false)
{
- _name = name.StartsWith("--") ? name : name.ToLowerFast();
+ _name = name.StartsWith("--", StringComparison.Ordinal) ? name : name.ToLowerFast();
_converter = converter;
_flags = flags;
_value = value;
@@ -158,7 +158,7 @@ public PropertyComputeContext(ICssComputeContext parent, IValueConverter convert
// em in font-size refers to the font size of the parent, everywhere else to the one of the element.
public Double? EmBase => _name.Is(PropertyNames.FontSize) ? Fonts?.ParentFontSize : Fonts?.FontSize;
- public Double? RootFontSize => Fonts?.RootFontSize;
+ public Double? RootFontSize => _name.Is(PropertyNames.FontSize) ? Fonts?.FontSizeRemBase : Fonts?.RootFontSize;
// Except for font-size and line-height, percentages refer to a box (or a length) that is only known during layout.
public Boolean KeepsPercentages => Fonts is not null && PercentageBasis is null;
diff --git a/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs b/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs
index a079ed15..aa55d20c 100644
--- a/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs
+++ b/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs
@@ -135,7 +135,7 @@ public void Update(String value)
private ICssProperty TryCreateShorthand(String shorthandName, IEnumerable serialized, List usedProperties, Boolean force)
{
- var factory = _context.GetFactory();
+ var factory = _context.GetDeclarationFactory();
var shorthand = factory.Create(shorthandName);
var requiredProperties = shorthand.Longhands;
@@ -174,7 +174,7 @@ public String ToCssBlock(IStyleFormatter formatter)
{
var list = new List();
var serialized = new List();
- var factory = _context.GetFactory();
+ var factory = _context.GetDeclarationFactory();
foreach (var declaration in Declarations)
{
@@ -546,7 +546,7 @@ private void SetShorthand(ICssProperty shorthand)
private ICssProperty GetPropertyFromShorthand(String name)
{
var info = _context.GetDeclarationInfo(name);
- var factory = _context.GetFactory();
+ var factory = _context.GetDeclarationFactory();
foreach (var shorthandName in info.Shorthands)
{
diff --git a/src/AngleSharp.Css/Dom/Internal/ForcingPseudoClassSelector.cs b/src/AngleSharp.Css/Dom/Internal/ForcingPseudoClassSelector.cs
index 972342d7..61cc950c 100644
--- a/src/AngleSharp.Css/Dom/Internal/ForcingPseudoClassSelector.cs
+++ b/src/AngleSharp.Css/Dom/Internal/ForcingPseudoClassSelector.cs
@@ -26,7 +26,15 @@ public ForcingPseudoClassSelector(String name, ISelector inner)
public void Accept(ISelectorVisitor visitor) => _inner.Accept(visitor);
- public Boolean Match(IElement element, IElement? scope) =>
- PseudoClassStateStore.TryGet(element, _name, out var forced) ? forced : _inner.Match(element, scope);
+ public Boolean Match(IElement element, IElement? scope)
+ {
+ // in li:first-child::after the pseudo-class refers to the originating element
+ if (element is PseudoElement pseudo && !PseudoElementSubjectVisitor.IsLegacyPseudoElement(_name))
+ {
+ element = pseudo.Host;
+ }
+
+ return PseudoClassStateStore.TryGet(element, _name, out var forced) ? forced : _inner.Match(element, scope);
+ }
}
}
diff --git a/src/AngleSharp.Css/Dom/Internal/LogicalProperties.cs b/src/AngleSharp.Css/Dom/Internal/LogicalProperties.cs
new file mode 100644
index 00000000..b150023d
--- /dev/null
+++ b/src/AngleSharp.Css/Dom/Internal/LogicalProperties.cs
@@ -0,0 +1,141 @@
+namespace AngleSharp.Css.Dom
+{
+ using AngleSharp.Css.Values;
+ using AngleSharp.Text;
+ using System;
+ using System.Collections.Generic;
+
+ ///
+ /// Maps the flow-relative (logical) properties, e.g. padding-inline-start, to the physical
+ /// ones they stand for in the writing mode and direction of the element (CSS Logical
+ /// Properties 1 §4). A logical property and its physical counterpart share one cascade: the
+ /// one declared later wins, unless only the other one is important.
+ ///
+ static class LogicalProperties
+ {
+ private static readonly Dictionary Sides = CreateSides();
+
+ private static readonly Dictionary Sizes = new(StringComparer.OrdinalIgnoreCase)
+ {
+ { PropertyNames.InlineSize, new SizeMapping("", true) },
+ { PropertyNames.BlockSize, new SizeMapping("", false) },
+ { PropertyNames.MinInlineSize, new SizeMapping("min-", true) },
+ { PropertyNames.MinBlockSize, new SizeMapping("min-", false) },
+ { PropertyNames.MaxInlineSize, new SizeMapping("max-", true) },
+ { PropertyNames.MaxBlockSize, new SizeMapping("max-", false) },
+ };
+
+ ///
+ /// Applies the logical properties of the given (cascaded) declarations, which are in
+ /// cascade order, to their physical counterparts.
+ ///
+ public static void Apply(CssStyleDeclaration declarations, ICssComputeContext context)
+ {
+ List? logical = null;
+ var order = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var property in declarations.Declarations)
+ {
+ order[property.Name] = order.Count;
+
+ if (Sides.ContainsKey(property.Name) || Sizes.ContainsKey(property.Name))
+ {
+ (logical ??= new List()).Add(property);
+ }
+ }
+
+ if (logical is null)
+ {
+ return;
+ }
+
+ var writingMode = declarations.GetPropertyValue(PropertyNames.WritingMode);
+ var vertical = writingMode.StartsWith("vertical", StringComparison.OrdinalIgnoreCase) ||
+ writingMode.StartsWith("sideways", StringComparison.OrdinalIgnoreCase) ||
+ writingMode.StartsWith("tb", StringComparison.OrdinalIgnoreCase);
+ var blockFromLeft = writingMode.EndsWith("-lr", StringComparison.OrdinalIgnoreCase);
+ var inlineReversed = declarations.GetPropertyValue(PropertyNames.Direction).Isi(CssKeywords.Rtl) != writingMode.Isi("sideways-lr");
+
+ foreach (var property in logical)
+ {
+ var physical = Sides.TryGetValue(property.Name, out var side)
+ ? side.Prefix + MapSide(side.Side, vertical, blockFromLeft, inlineReversed) + side.Suffix
+ : Sizes[property.Name].Prefix + (Sizes[property.Name].IsInline != vertical ? "width" : "height");
+ var existing = order.TryGetValue(physical, out var physicalIndex) ? declarations.GetProperty(physical) : null;
+
+ if (existing is null || (property.IsImportant == existing.IsImportant ? order[property.Name] > physicalIndex : property.IsImportant))
+ {
+ var info = context.Context.GetDeclarationInfo(physical);
+ // a value from a shorthand with var() is picked from it by the logical name
+ var value = property.RawValue is CssChildValue ? property.Compute(context).RawValue : property.RawValue;
+
+ if (existing is not null)
+ {
+ declarations.RemoveProperty(existing);
+ }
+
+ declarations.AddProperty(new CssProperty(physical, info.Converter, info.Flags, value, property.IsImportant));
+ }
+ }
+ }
+
+ // the inline axis runs left to right (top to bottom when vertical) unless reversed by rtl
+ private static String MapSide(String side, Boolean vertical, Boolean blockFromLeft, Boolean inlineReversed) => side switch
+ {
+ "block-start" => !vertical ? "top" : blockFromLeft ? "left" : "right",
+ "block-end" => !vertical ? "bottom" : blockFromLeft ? "right" : "left",
+ "inline-start" => vertical ? (inlineReversed ? "bottom" : "top") : (inlineReversed ? "right" : "left"),
+ _ => vertical ? (inlineReversed ? "top" : "bottom") : (inlineReversed ? "left" : "right"),
+ };
+
+ private static Dictionary CreateSides()
+ {
+ var sides = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ AddGroup("margin-", "margin-", "");
+ AddGroup("padding-", "padding-", "");
+ AddGroup("inset-", "", "");
+ AddGroup("scroll-margin-", "scroll-margin-", "");
+ AddGroup("scroll-padding-", "scroll-padding-", "");
+ AddGroup("border-", "border-", "-width");
+ AddGroup("border-", "border-", "-style");
+ AddGroup("border-", "border-", "-color");
+
+ return sides;
+
+ void AddGroup(String logicalPrefix, String physicalPrefix, String suffix)
+ {
+ foreach (var side in new[] { "block-start", "block-end", "inline-start", "inline-end" })
+ {
+ sides[logicalPrefix + side + suffix] = new SideMapping(physicalPrefix, side, suffix);
+ }
+ }
+ }
+
+ private readonly struct SideMapping
+ {
+ public SideMapping(String prefix, String side, String suffix)
+ {
+ Prefix = prefix;
+ Side = side;
+ Suffix = suffix;
+ }
+
+ public String Prefix { get; }
+ public String Side { get; }
+ public String Suffix { get; }
+ }
+
+ private readonly struct SizeMapping
+ {
+ public SizeMapping(String prefix, Boolean isInline)
+ {
+ Prefix = prefix;
+ IsInline = isInline;
+ }
+
+ public String Prefix { get; }
+ public Boolean IsInline { get; }
+ }
+ }
+}
diff --git a/src/AngleSharp.Css/Dom/Internal/MediaCondition.cs b/src/AngleSharp.Css/Dom/Internal/MediaCondition.cs
new file mode 100644
index 00000000..8deec8d8
--- /dev/null
+++ b/src/AngleSharp.Css/Dom/Internal/MediaCondition.cs
@@ -0,0 +1,52 @@
+namespace AngleSharp.Css.Dom
+{
+ using AngleSharp.Text;
+ using System;
+ using System.IO;
+
+ ///
+ /// A parenthesized media condition inside another one, e.g. the
+ /// ((min-height: 600px) or (orientation: landscape)) of
+ /// (min-width: 500px) and ((min-height: 600px) or (orientation: landscape)).
+ /// It keeps its own connector and negation instead of being flattened into
+ /// the enclosing condition.
+ ///
+ sealed class MediaCondition : IMediaFeature
+ {
+ #region ctor
+
+ public MediaCondition(CssMedium medium)
+ {
+ Medium = medium;
+ }
+
+ #endregion
+
+ #region Properties
+
+ public CssMedium Medium { get; }
+
+ public String Name => String.Empty;
+
+ public Boolean IsMinimum => false;
+
+ public Boolean IsMaximum => false;
+
+ public String Value => String.Empty;
+
+ public Boolean HasValue => false;
+
+ #endregion
+
+ #region Methods
+
+ public void ToCss(TextWriter writer, IStyleFormatter formatter)
+ {
+ writer.Write(Symbols.RoundBracketOpen);
+ Medium.ToCss(writer, formatter);
+ writer.Write(Symbols.RoundBracketClose);
+ }
+
+ #endregion
+ }
+}
diff --git a/src/AngleSharp.Css/Dom/Internal/MediaFeature.cs b/src/AngleSharp.Css/Dom/Internal/MediaFeature.cs
index 2b376f98..7daf4fd1 100644
--- a/src/AngleSharp.Css/Dom/Internal/MediaFeature.cs
+++ b/src/AngleSharp.Css/Dom/Internal/MediaFeature.cs
@@ -4,6 +4,7 @@ namespace AngleSharp.Css.Dom
using AngleSharp.Css.Values;
using AngleSharp.Text;
using System;
+ using System.Collections.Generic;
using System.IO;
///
@@ -17,7 +18,8 @@ sealed class MediaFeature : IMediaFeature
private readonly Boolean _max;
private readonly ICssValue _name;
private readonly ICssValue _value;
- private readonly String _op;
+ private readonly IReadOnlyList _comparisons;
+ private readonly String _rangeText;
#endregion
@@ -35,15 +37,19 @@ internal MediaFeature(String name, ICssValue value)
_max = name.StartsWith("max-");
}
- internal MediaFeature(String name, ICssValue value, String op)
- : this(new CssAnyValue(name), value, op)
- {}
-
- internal MediaFeature(ICssValue name, ICssValue value, String op)
+ ///
+ /// Creates a feature in the range syntax of Media Queries 4, e.g.,
+ /// (width >= 600px) or (400px < width <= 700px).
+ ///
+ /// The name of the feature, e.g., width.
+ /// The comparisons, each reading "name op value".
+ /// The range as written, used for serialization.
+ internal MediaFeature(String name, IReadOnlyList comparisons, String rangeText)
{
- _name = name;
- _value = value;
- _op = op;
+ _name = new CssAnyValue(name);
+ _value = comparisons[0].Value;
+ _comparisons = comparisons;
+ _rangeText = rangeText;
}
#endregion
@@ -60,6 +66,11 @@ internal MediaFeature(ICssValue name, ICssValue value, String op)
public Boolean HasValue => _value != null;
+ ///
+ /// Gets the comparisons of a feature in the range syntax, otherwise null.
+ ///
+ internal IReadOnlyList Comparisons => _comparisons;
+
#endregion
#region Methods
@@ -67,16 +78,17 @@ internal MediaFeature(ICssValue name, ICssValue value, String op)
public void ToCss(TextWriter writer, IStyleFormatter formatter)
{
writer.Write(Symbols.RoundBracketOpen);
- writer.Write(Name);
- if (_op is not null)
+ if (_rangeText is not null)
{
- writer.Write(" ");
- writer.Write(_op);
- writer.Write(" ");
- writer.Write(Value);
+ writer.Write(_rangeText);
+ writer.Write(Symbols.RoundBracketClose);
+ return;
}
- else if (_value is not null)
+
+ writer.Write(Name);
+
+ if (_value is not null)
{
writer.Write(": ");
writer.Write(Value);
@@ -86,5 +98,28 @@ public void ToCss(TextWriter writer, IStyleFormatter formatter)
}
#endregion
+
+ #region Range
+
+ ///
+ /// A comparison of the feature against a value, reading "feature op value".
+ ///
+ internal readonly struct RangeComparison
+ {
+ public RangeComparison(String op, ICssValue value)
+ {
+ Operator = op;
+ Value = value;
+ }
+
+ ///
+ /// Gets the operator: <, <=, >, >=, or =.
+ ///
+ public String Operator { get; }
+
+ public ICssValue Value { get; }
+ }
+
+ #endregion
}
}
diff --git a/src/AngleSharp.Css/Dom/Internal/PresentationalHints.cs b/src/AngleSharp.Css/Dom/Internal/PresentationalHints.cs
new file mode 100644
index 00000000..9da096dd
--- /dev/null
+++ b/src/AngleSharp.Css/Dom/Internal/PresentationalHints.cs
@@ -0,0 +1,130 @@
+namespace AngleSharp.Css.Dom
+{
+ using AngleSharp.Dom;
+ using AngleSharp.Html.Dom;
+ using AngleSharp.Text;
+ using System;
+ using System.Globalization;
+ using System.Text;
+
+ ///
+ /// The presentational hints of HTML elements (HTML 15.3 - 15.4), i.e. attributes like the
+ /// width of an img, which map to CSS declarations. These take part in the cascade like
+ /// author rules with a specificity of zero, which precede all other author rules.
+ ///
+ static class PresentationalHints
+ {
+ ///
+ /// Gets the declarations the attributes of the element map to, if any.
+ ///
+ public static String? GetDeclarations(IElement element)
+ {
+ if (element is not IHtmlElement || !element.HasAttributes())
+ {
+ return null;
+ }
+
+ var hints = new StringBuilder();
+
+ switch (element.LocalName)
+ {
+ case "img":
+ case "video":
+ case "embed":
+ case "iframe":
+ case "object":
+ AddDimension(hints, element, "width", "width", ignoreZero: false);
+ AddDimension(hints, element, "height", "height", ignoreZero: false);
+ break;
+ case "input" when element.GetAttribute("type").Isi("image"):
+ AddDimension(hints, element, "width", "width", ignoreZero: false);
+ AddDimension(hints, element, "height", "height", ignoreZero: false);
+ break;
+ case "table":
+ AddDimension(hints, element, "width", "width", ignoreZero: true);
+ AddDimension(hints, element, "height", "height", ignoreZero: false);
+ AddColor(hints, element, "bgcolor", "background-color");
+ break;
+ case "td":
+ case "th":
+ AddDimension(hints, element, "width", "width", ignoreZero: true);
+ AddDimension(hints, element, "height", "height", ignoreZero: false);
+ AddColor(hints, element, "bgcolor", "background-color");
+ break;
+ case "tr":
+ AddDimension(hints, element, "height", "height", ignoreZero: false);
+ AddColor(hints, element, "bgcolor", "background-color");
+ break;
+ case "thead":
+ case "tbody":
+ case "tfoot":
+ AddColor(hints, element, "bgcolor", "background-color");
+ break;
+ case "col":
+ case "colgroup":
+ case "hr":
+ AddDimension(hints, element, "width", "width", ignoreZero: false);
+ break;
+ case "body":
+ AddColor(hints, element, "bgcolor", "background-color");
+ AddColor(hints, element, "text", "color");
+ break;
+ case "font":
+ AddColor(hints, element, "color", "color");
+ break;
+ }
+
+ return hints.Length > 0 ? hints.ToString() : null;
+ }
+
+ private static Boolean HasAttributes(this IElement element) => element.Attributes.Length > 0;
+
+ ///
+ /// Maps a dimension value (e.g. "100" or "50%") to the property (HTML 2.4.4.4).
+ ///
+ private static void AddDimension(StringBuilder hints, IElement element, String attribute, String property, Boolean ignoreZero)
+ {
+ var value = element.GetAttribute(attribute);
+
+ if (value is null)
+ {
+ return;
+ }
+
+ var index = 0;
+
+ while (index < value.Length && value[index].IsSpaceCharacter())
+ {
+ index++;
+ }
+
+ var start = index;
+
+ while (index < value.Length && (value[index].IsDigit() || value[index] == Symbols.Dot))
+ {
+ index++;
+ }
+
+ if (index == start || !Double.TryParse(value.Substring(start, index - start), NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var number) || (ignoreZero && number == 0))
+ {
+ return;
+ }
+
+ var unit = index < value.Length && value[index] == Symbols.Percent ? "%" : "px";
+ hints.Append(property).Append(": ").Append(number.ToString(CultureInfo.InvariantCulture)).Append(unit).Append(';');
+ }
+
+ ///
+ /// Maps a color (e.g. "#ffffff" or "white") to the property.
+ ///
+ private static void AddColor(StringBuilder hints, IElement element, String attribute, String property)
+ {
+ var value = element.GetAttribute(attribute)?.Trim();
+
+ if (value is { Length: > 0 } && value.IndexOfAny(new[] { ';', '{', '}', '!' }) < 0)
+ {
+ hints.Append(property).Append(": ").Append(value).Append(';');
+ }
+ }
+ }
+}
diff --git a/src/AngleSharp.Css/Dom/Internal/PseudoElement.cs b/src/AngleSharp.Css/Dom/Internal/PseudoElement.cs
index b8c284b4..45fd61df 100644
--- a/src/AngleSharp.Css/Dom/Internal/PseudoElement.cs
+++ b/src/AngleSharp.Css/Dom/Internal/PseudoElement.cs
@@ -30,6 +30,11 @@ public PseudoElement(IElement host, String name)
#region Properties
+ ///
+ /// Gets the originating element of the pseudo-element.
+ ///
+ internal IElement Host => _host;
+
public IElement AssignedSlot => _host.AssignedSlot;
public String Slot
diff --git a/src/AngleSharp.Css/Dom/Internal/PseudoElementSubjectVisitor.cs b/src/AngleSharp.Css/Dom/Internal/PseudoElementSubjectVisitor.cs
index 054136da..00628950 100644
--- a/src/AngleSharp.Css/Dom/Internal/PseudoElementSubjectVisitor.cs
+++ b/src/AngleSharp.Css/Dom/Internal/PseudoElementSubjectVisitor.cs
@@ -12,12 +12,26 @@ namespace AngleSharp.Css.Dom
///
sealed class PseudoElementSubjectVisitor : ISelectorVisitor
{
+ private static readonly HashSet LegacyPseudoElements = new(System.StringComparer.OrdinalIgnoreCase)
+ {
+ PseudoElementNames.Before,
+ PseudoElementNames.After,
+ PseudoElementNames.FirstLine,
+ PseudoElementNames.FirstLetter,
+ };
+
private bool _result;
private PseudoElementSubjectVisitor()
{
}
+ ///
+ /// Checks if the name is one of the pseudo-elements of CSS 2, which can also be
+ /// written like a pseudo-class with a single colon, e.g. li:after.
+ ///
+ public static bool IsLegacyPseudoElement(string name) => LegacyPseudoElements.Contains(name);
+
public static bool TargetsPseudoElement(ISelector selector)
{
var visitor = new PseudoElementSubjectVisitor();
@@ -47,6 +61,7 @@ void ISelectorVisitor.Class(string name)
void ISelectorVisitor.PseudoClass(string name)
{
+ _result = _result || IsLegacyPseudoElement(name);
}
void ISelectorVisitor.PseudoElement(string name)
diff --git a/src/AngleSharp.Css/Dom/Internal/Rules/CssStyleRule.cs b/src/AngleSharp.Css/Dom/Internal/Rules/CssStyleRule.cs
index 4e6acd34..116dade4 100644
--- a/src/AngleSharp.Css/Dom/Internal/Rules/CssStyleRule.cs
+++ b/src/AngleSharp.Css/Dom/Internal/Rules/CssStyleRule.cs
@@ -25,6 +25,7 @@ sealed class CssStyleRule : CssRule, ICssStyleRule, ISelectorVisitor
private Boolean[] _selectorListTargetsPseudo;
private Boolean _selectorTargetsPseudo;
private Boolean _nested;
+ private StyleRuleIndex.SelectorInfo _indexInfo;
#endregion
@@ -66,6 +67,12 @@ public String SelectorText
public ICssRuleList Rules => _rules;
+ internal StyleRuleIndex.SelectorInfo IndexInfo
+ {
+ get => _indexInfo;
+ set => _indexInfo = value;
+ }
+
#endregion
#region Methods
@@ -152,6 +159,33 @@ public Boolean TryMatch(IElement element, IElement? scope, out Priority specific
return false;
}
+ ///
+ /// Matches the rule of a shadow tree against its host. The host is featureless
+ /// there, i.e. only a selector whose subject is a :host compound matches it.
+ ///
+ internal Boolean TryMatchHost(IElement host, out Priority specificity)
+ {
+ specificity = Priority.Zero;
+
+ if (_nested || Parent is CssStyleRule)
+ {
+ return false;
+ }
+
+ var selectors = _selectorList ?? (_selector is not null ? new[] { _selector } : Array.Empty());
+
+ foreach (var selector in selectors)
+ {
+ if (selector.Text.StartsWith(":host", StringComparison.OrdinalIgnoreCase) && selector.Match(host, host))
+ {
+ specificity = selector.Specificity;
+ return true;
+ }
+ }
+
+ return false;
+ }
+
public override void ToCss(TextWriter writer, IStyleFormatter formatter)
{
var block = _style.ToCssBlock(formatter);
@@ -166,6 +200,7 @@ internal void ChangeSelector(ISelector value)
{
_selectorList = null;
_selectorListTargetsPseudo = null;
+ _indexInfo = null;
_selector = value;
_selectorTargetsPseudo = PseudoElementSubjectVisitor.TargetsPseudoElement(value);
value?.Accept(this);
diff --git a/src/AngleSharp.Css/Dom/Internal/StyleCollection.cs b/src/AngleSharp.Css/Dom/Internal/StyleCollection.cs
index 7f2da614..1be812c7 100644
--- a/src/AngleSharp.Css/Dom/Internal/StyleCollection.cs
+++ b/src/AngleSharp.Css/Dom/Internal/StyleCollection.cs
@@ -14,6 +14,8 @@ sealed class StyleCollection : IStyleCollection
private readonly IEnumerable _sheets;
private readonly IRenderDevice _device;
private List? _rules;
+ private List? _layerRanks;
+ private StyleRuleIndex? _index;
private Int32 _defaultRuleCount;
#endregion
@@ -25,11 +27,12 @@ public StyleCollection(IEnumerable sheets, IRenderDevice device)
{
}
- public StyleCollection(IEnumerable defaultSheets, IEnumerable sheets, IRenderDevice device)
+ public StyleCollection(IEnumerable defaultSheets, IEnumerable sheets, IRenderDevice device, IElement? scope = null)
{
_defaultSheets = defaultSheets;
_sheets = sheets;
_device = device;
+ Scope = scope;
}
#endregion
@@ -38,6 +41,12 @@ public StyleCollection(IEnumerable defaultSheets, IEnumerable _device;
+ ///
+ /// Gets the element the rules are scoped to, e.g. the host of a shadow tree whose style
+ /// sheets these are (matched by :host), otherwise the document element.
+ ///
+ internal IElement? Scope { get; }
+
#endregion
#region Methods
@@ -54,6 +63,23 @@ public StyleCollection(IEnumerable defaultSheets, IEnumerable
internal List GetRules() => _rules ??= CollectRules();
+ ///
+ /// Gets the index of the rules from GetRules, which narrows down the
+ /// rules that need to be matched against an element.
+ ///
+ internal StyleRuleIndex GetIndex() => _index ??= new StyleRuleIndex(GetRules());
+
+ ///
+ /// Gets the rank of the cascade layer of the rule at the given index of
+ /// GetRules: rules of a lower rank lose against rules of a higher rank
+ /// (of the same origin). Unlayered rules have the highest rank.
+ ///
+ internal Int32 GetLayerRank(Int32 index)
+ {
+ GetRules();
+ return _layerRanks![index];
+ }
+
///
/// Gets the number of rules at the beginning of the list from GetRules
/// that stem from the default (user agent) stylesheets. These rules
@@ -72,22 +98,57 @@ internal Int32 GetDefaultRuleCount()
private List CollectRules()
{
var rules = new List();
- AddRules(rules, _defaultSheets);
+ var layers = new List();
+ AddRules(rules, layers, _defaultSheets);
_defaultRuleCount = rules.Count;
- AddRules(rules, _sheets);
+ AddRules(rules, layers, _sheets);
+ _layerRanks = layers.ConvertAll(m => m.Rank);
return rules;
}
- private void AddRules(List rules, IEnumerable sheets)
+ private void AddRules(List rules, List layers, IEnumerable sheets)
{
+ // each origin has its own layers
+ var root = new Layer();
+
foreach (var sheet in sheets)
{
if (!sheet.IsDisabled && sheet.Media.Validate(_device))
{
- foreach (var rule in sheet.Rules.GetMatchingStyles(_device))
- {
- rules.Add(rule);
- }
+ AddRules(rules, layers, sheet.Rules, root);
+ }
+ }
+
+ root.AssignRanks(0);
+ }
+
+ private void AddRules(List rules, List layers, ICssRuleList list, Layer layer)
+ {
+ foreach (var rule in list)
+ {
+ switch (rule)
+ {
+ case ICssStyleRule style:
+ rules.Add(style);
+ layers.Add(layer);
+ break;
+ case ICssMediaRule media when media.IsValid(_device):
+ AddRules(rules, layers, media.Rules, layer);
+ break;
+ case ICssSupportsRule supports when supports.IsValid(_device):
+ AddRules(rules, layers, supports.Rules, layer);
+ break;
+ case ICssLayerRule { IsStatement: true } statement:
+ // only declares the order of the layers, e.g. @layer reset, base;
+ foreach (var name in statement.Name.Split(','))
+ {
+ layer.Resolve(name);
+ }
+
+ break;
+ case ICssLayerRule block:
+ AddRules(rules, layers, block.Rules, String.IsNullOrWhiteSpace(block.Name) ? layer.AddAnonymous() : layer.Resolve(block.Name));
+ break;
}
}
}
@@ -95,5 +156,62 @@ private void AddRules(List rules, IEnumerable she
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
#endregion
+
+ #region Layer
+
+ ///
+ /// A cascade layer (CSS Cascade 5, 6.4). Layers are ordered by their
+ /// first appearance; the rules of a layer itself come after the ones of
+ /// its sub-layers, and the unlayered rules after all layers.
+ ///
+ private sealed class Layer
+ {
+ private readonly Dictionary _named = new(StringComparer.Ordinal);
+ private readonly List _children = new();
+
+ public Int32 Rank { get; private set; }
+
+ public Layer Resolve(String name)
+ {
+ var layer = this;
+
+ // a.b is the sub-layer b of the layer a
+ foreach (var part in name.Split('.'))
+ {
+ var key = part.Trim();
+
+ if (!layer._named.TryGetValue(key, out var child))
+ {
+ child = new Layer();
+ layer._named.Add(key, child);
+ layer._children.Add(child);
+ }
+
+ layer = child;
+ }
+
+ return layer;
+ }
+
+ public Layer AddAnonymous()
+ {
+ var layer = new Layer();
+ _children.Add(layer);
+ return layer;
+ }
+
+ public Int32 AssignRanks(Int32 next)
+ {
+ foreach (var child in _children)
+ {
+ next = child.AssignRanks(next);
+ }
+
+ Rank = next;
+ return next + 1;
+ }
+ }
+
+ #endregion
}
}
diff --git a/src/AngleSharp.Css/Dom/Internal/StyleRuleIndex.cs b/src/AngleSharp.Css/Dom/Internal/StyleRuleIndex.cs
new file mode 100644
index 00000000..c2bd0abb
--- /dev/null
+++ b/src/AngleSharp.Css/Dom/Internal/StyleRuleIndex.cs
@@ -0,0 +1,309 @@
+namespace AngleSharp.Css.Dom
+{
+ using AngleSharp.Dom;
+ using System;
+ using System.Collections.Generic;
+ using System.Linq;
+
+ ///
+ /// Groups the style rules of a collection by the id, class, or tag name
+ /// that the subject (i.e., the rightmost compound) of their selector
+ /// requires. An element can only be matched by the rules in the groups of
+ /// its own id, classes, and tag name, plus the rules that could not be
+ /// grouped. Without the index every rule would be matched against every
+ /// element. Pseudo-elements only need the rules targeting pseudo-elements.
+ ///
+ sealed class StyleRuleIndex
+ {
+ #region Fields
+
+ // ids and classes match case-insensitively in quirks mode, tag names
+ // in HTML documents; a superset of candidates is fine
+ private readonly Dictionary> _ids = new(StringComparer.OrdinalIgnoreCase);
+ private readonly Dictionary> _classes = new(StringComparer.OrdinalIgnoreCase);
+ private readonly Dictionary> _tags = new(StringComparer.OrdinalIgnoreCase);
+ private readonly List _ungrouped = new();
+ private readonly List _pseudo = new();
+
+ #endregion
+
+ #region ctor
+
+ public StyleRuleIndex(IReadOnlyList rules)
+ {
+ for (var i = 0; i < rules.Count; i++)
+ {
+ Add(rules[i], i);
+ }
+ }
+
+ #endregion
+
+ #region Methods
+
+ ///
+ /// Collects the (ascending) indices of the rules that may match the
+ /// given element.
+ ///
+ public IReadOnlyList GetCandidates(IElement element)
+ {
+ if (element is IPseudoElement)
+ {
+ return _pseudo;
+ }
+
+ var candidates = new List(_ungrouped);
+ var id = element.Id;
+
+ if (!String.IsNullOrEmpty(id) && _ids.TryGetValue(id!, out var rules))
+ {
+ candidates.AddRange(rules);
+ }
+
+ foreach (var name in element.ClassList)
+ {
+ if (_classes.TryGetValue(name, out rules))
+ {
+ candidates.AddRange(rules);
+ }
+ }
+
+ if (_tags.TryGetValue(element.LocalName, out rules))
+ {
+ candidates.AddRange(rules);
+ }
+
+ if (candidates.Count > _ungrouped.Count)
+ {
+ // a rule may be found via several keys, e.g., ".a, .b" for class="a b"
+ candidates.Sort();
+ var count = 0;
+
+ for (var i = 0; i < candidates.Count; i++)
+ {
+ if (count == 0 || candidates[count - 1] != candidates[i])
+ {
+ candidates[count++] = candidates[i];
+ }
+ }
+
+ candidates.RemoveRange(count, candidates.Count - count);
+ }
+
+ return candidates;
+ }
+
+ #endregion
+
+ #region Helpers
+
+ private void Add(ICssStyleRule rule, Int32 index)
+ {
+ // nested rules are matched independently of their parent's selector
+ var hasNested = rule.Rules.Length > 0;
+ var info = SelectorInfo.Get(rule);
+ var keys = hasNested ? null : info.Keys;
+
+ if (hasNested || info.TargetsPseudo)
+ {
+ _pseudo.Add(index);
+ }
+
+ if (keys is null)
+ {
+ _ungrouped.Add(index);
+ return;
+ }
+
+ foreach (var key in keys)
+ {
+ var map = key.Kind switch
+ {
+ SubjectKeyKind.Id => _ids,
+ SubjectKeyKind.Class => _classes,
+ _ => _tags,
+ };
+
+ if (!map.TryGetValue(key.Name, out var rules))
+ {
+ rules = new List();
+ map.Add(key.Name, rules);
+ }
+
+ // the same key may appear more than once, e.g., ".a:hover, .a:focus"
+ if (rules.Count == 0 || rules[rules.Count - 1] != index)
+ {
+ rules.Add(index);
+ }
+ }
+ }
+
+ #endregion
+
+ #region Key
+
+ ///
+ /// What the index needs to know about the selector of a rule. Collections
+ /// (and hence indices) are created for every cascade, e.g., when computing
+ /// the style of a single element, so this is kept with the rule.
+ ///
+ internal sealed class SelectorInfo
+ {
+ private SelectorInfo(ISelector? selector)
+ {
+ Keys = SubjectKeyVisitor.GetKeys(selector);
+ TargetsPseudo = selector is not null && PseudoElementSubjectVisitor.TargetsPseudoElement(selector);
+ }
+
+ public SubjectKey[]? Keys { get; }
+
+ public Boolean TargetsPseudo { get; }
+
+ public static SelectorInfo Get(ICssStyleRule rule)
+ {
+ if (rule is CssStyleRule styleRule)
+ {
+ return styleRule.IndexInfo ??= new SelectorInfo(rule.Selector);
+ }
+
+ return new SelectorInfo(rule.Selector);
+ }
+ }
+
+ ///
+ /// The kinds of keys, ordered from the most to the least selective.
+ ///
+ internal enum SubjectKeyKind
+ {
+ Id,
+ Class,
+ Tag,
+ }
+
+ internal readonly struct SubjectKey
+ {
+ public SubjectKey(SubjectKeyKind kind, String name)
+ {
+ Kind = kind;
+ Name = name;
+ }
+
+ public SubjectKeyKind Kind { get; }
+
+ public String Name { get; }
+ }
+
+ #endregion
+
+ #region Visitor
+
+ ///
+ /// Determines the keys of which a matching element needs to have at
+ /// least one. No keys (null) means that any element could match.
+ ///
+ private sealed class SubjectKeyVisitor : ISelectorVisitor
+ {
+ private readonly ISelector _selector;
+ private SubjectKey[]? _keys;
+
+ private SubjectKeyVisitor(ISelector selector)
+ {
+ _selector = selector;
+ }
+
+ public static SubjectKey[]? GetKeys(ISelector? selector)
+ {
+ // a forced pseudo-class matches independent of its own selector
+ if (selector is null || selector is ForcingPseudoClassSelector)
+ {
+ return null;
+ }
+
+ var visitor = new SubjectKeyVisitor(selector);
+ selector.Accept(visitor);
+ return visitor._keys;
+ }
+
+ void ISelectorVisitor.Id(String value) => Set(SubjectKeyKind.Id, value);
+
+ void ISelectorVisitor.Class(String name) => Set(SubjectKeyKind.Class, name);
+
+ void ISelectorVisitor.Type(String name)
+ {
+ // namespace prefixes, nesting (&), and the universal selector are reported as types, too
+ if (_selector.Specificity == Priority.OneTag && name != "*" && name != "&" && name.IndexOf('|') == -1)
+ {
+ Set(SubjectKeyKind.Tag, name);
+ }
+ }
+
+ void ISelectorVisitor.Attribute(String name, String op, String? value)
+ {
+ }
+
+ void ISelectorVisitor.Child(String name, Int32 step, Int32 offset, ISelector selector)
+ {
+ }
+
+ void ISelectorVisitor.PseudoClass(String name)
+ {
+ }
+
+ void ISelectorVisitor.PseudoElement(String name)
+ {
+ }
+
+ void ISelectorVisitor.List(IEnumerable selectors)
+ {
+ var keys = new List();
+
+ foreach (var selector in selectors)
+ {
+ var alternative = GetKeys(selector);
+
+ if (alternative is null)
+ {
+ return;
+ }
+
+ keys.AddRange(alternative);
+ }
+
+ _keys = keys.Count > 0 ? keys.ToArray() : null;
+ }
+
+ void ISelectorVisitor.Combinator(IEnumerable selectors, IEnumerable symbols)
+ {
+ _keys = GetKeys(selectors.LastOrDefault());
+ }
+
+ void ISelectorVisitor.Many(IEnumerable selectors)
+ {
+ // all parts of a compound must match, so any key of a part is required
+ foreach (var selector in selectors)
+ {
+ var keys = GetKeys(selector);
+
+ if (keys is not null && (_keys is null || IsBetter(keys, _keys)))
+ {
+ _keys = keys;
+ }
+ }
+ }
+
+ private void Set(SubjectKeyKind kind, String name)
+ {
+ if (!String.IsNullOrEmpty(name))
+ {
+ _keys = new[] { new SubjectKey(kind, name) };
+ }
+ }
+
+ // a single key is more selective than a list of keys
+ private static Boolean IsBetter(SubjectKey[] keys, SubjectKey[] current) =>
+ keys.Length < current.Length || (keys.Length == 1 && current.Length == 1 && keys[0].Kind < current[0].Kind);
+ }
+
+ #endregion
+ }
+}
diff --git a/src/AngleSharp.Css/Extensions/CssOmExtensions.cs b/src/AngleSharp.Css/Extensions/CssOmExtensions.cs
index 13815279..f967fbf1 100644
--- a/src/AngleSharp.Css/Extensions/CssOmExtensions.cs
+++ b/src/AngleSharp.Css/Extensions/CssOmExtensions.cs
@@ -119,7 +119,7 @@ public static ICssStyleDeclaration Compute(this ICssStyleDeclaration style, ICss
computedStyle.AddProperty(computed);
}
- var factory = context.Context.GetFactory();
+ var factory = context.Context.GetDeclarationFactory();
var preservedShorthands = style
.Where(property => (factory.Create(property.Name).Flags & PropertyFlags.PreserveShorthand) != 0)
.ToArray();
@@ -160,6 +160,7 @@ internal static CssStyleDeclaration PrepareComputedDeclarations(this ICssStyleDe
}
declarations.UpdateDeclarations(parent);
+ LogicalProperties.Apply(declarations, context);
return declarations;
}
diff --git a/src/AngleSharp.Css/Extensions/CssValueExtensions.cs b/src/AngleSharp.Css/Extensions/CssValueExtensions.cs
index 5f936ae6..c9eb568d 100644
--- a/src/AngleSharp.Css/Extensions/CssValueExtensions.cs
+++ b/src/AngleSharp.Css/Extensions/CssValueExtensions.cs
@@ -90,10 +90,36 @@ public static Double AsPx(this ICssValue? value, IRenderDimensions renderDimensi
{
return special.Value.AsPx(renderDimensions, mode);
}
+ else if (value is ICssFunctionValue && renderDimensions is IRenderDevice device)
+ {
+ // math functions, e.g. calc(50rem + 2px) in a media query, resolve against the device
+ var computed = value.Compute(new DeviceComputeContext(device));
+
+ if (computed is CssLengthValue)
+ {
+ return computed.AsPx(renderDimensions, mode);
+ }
+ }
return 0.0;
}
+ private sealed class DeviceComputeContext : ICssComputeContext
+ {
+ public DeviceComputeContext(IRenderDevice device)
+ {
+ Device = device;
+ }
+
+ public IRenderDevice Device { get; }
+
+ public IBrowsingContext? Context => null;
+
+ public IValueConverter? Converter => null;
+
+ public ICssValue? Resolve(String name) => null;
+ }
+
///
/// Tries to convert the value to a number of milliseconds.
///
diff --git a/src/AngleSharp.Css/Extensions/MediaListExtensions.cs b/src/AngleSharp.Css/Extensions/MediaListExtensions.cs
index 88d26a6f..39057eb4 100644
--- a/src/AngleSharp.Css/Extensions/MediaListExtensions.cs
+++ b/src/AngleSharp.Css/Extensions/MediaListExtensions.cs
@@ -27,29 +27,73 @@ public static void AssociateValidator(this IMediaFeature feature, IFeatureValida
public static Boolean Validate(this IMediaFeature feature, IRenderDevice device)
{
+ if (feature is MediaCondition condition)
+ {
+ return condition.Medium.Validate(device);
+ }
+
AssociatedValidators.TryGetValue(feature, out var validator);
+
+ if (validator is not null && feature is MediaFeature { Comparisons: { } comparisons })
+ {
+ return comparisons.All(m => validator.Validate(feature.Name, m, device));
+ }
+
return validator?.Validate(feature, device) ?? false;
}
+ ///
+ /// Evaluates a comparison of the range syntax with what the validators know: the min- and
+ /// max- prefixed (inclusive) forms of the feature and the plain one (equality).
+ ///
+ private static Boolean Validate(this IFeatureValidator validator, String name, MediaFeature.RangeComparison comparison, IRenderDevice device)
+ {
+ Boolean AtLeast() => validator.Validate(new MediaFeature($"min-{name}", comparison.Value), device);
+ Boolean AtMost() => validator.Validate(new MediaFeature($"max-{name}", comparison.Value), device);
+ Boolean Equal() => validator.Validate(new MediaFeature(name, comparison.Value), device);
+
+ return comparison.Operator switch
+ {
+ ">=" => AtLeast(),
+ "<=" => AtMost(),
+ ">" => AtLeast() && !Equal(),
+ "<" => AtMost() && !Equal(),
+ _ => Equal(),
+ };
+ }
+
public static Boolean Validate(this IMediaList list, IRenderDevice device) => !list.Any() || list.Any(m => m.Validate(device));
public static Boolean Validate(this ICssMedium medium, IRenderDevice device)
{
- if (!String.IsNullOrEmpty(medium.Type) &&
- ((medium.Type.Is(CssKeywords.All) && medium.IsInverse) || (!KnownTypes.Contains(medium.Type) && !medium.IsInverse)))
+ // `not` negates the whole query, i.e. the media type and all features together
+ var matches = medium.MatchesType(device) && medium.MatchesFeatures(device);
+ return matches != medium.IsInverse;
+ }
+
+ private static Boolean MatchesType(this ICssMedium medium, IRenderDevice device)
+ {
+ var type = medium.Type;
+
+ if (String.IsNullOrEmpty(type) || type.Is(CssKeywords.All))
+ {
+ return true;
+ }
+
+ if (!KnownTypes.Any(m => m.Is(type)))
{
return false;
}
- return !medium.IsInvalid(device) && !medium.Features.Any(m => m.Validate(device) == medium.IsInverse);
+ return device is null ||
+ (type.Is(CssKeywords.Screen) && device.Category == DeviceCategory.Screen) ||
+ (type.Is(CssKeywords.Speech) && device.Category == DeviceCategory.Speech) ||
+ (type.Is(CssKeywords.Print) && device.Category == DeviceCategory.Printer);
}
- private static Boolean IsInvalid(this ICssMedium medium, IRenderDevice device) =>
- medium.IsInvalid(device, CssKeywords.Screen, DeviceCategory.Screen) ||
- medium.IsInvalid(device, CssKeywords.Speech, DeviceCategory.Speech) ||
- medium.IsInvalid(device, CssKeywords.Print, DeviceCategory.Printer);
-
- private static Boolean IsInvalid(this ICssMedium medium, IRenderDevice device, String keyword, DeviceCategory category) =>
- device != null && keyword.Is(medium.Type) && device.Category == category == medium.IsInverse;
+ private static Boolean MatchesFeatures(this ICssMedium medium, IRenderDevice device) =>
+ medium is CssMedium { Connector: var connector } && connector.Is(CssKeywords.Or)
+ ? medium.Features.Any(m => m.Validate(device))
+ : medium.Features.All(m => m.Validate(device));
}
}
diff --git a/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs b/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs
index 90b4c4d7..1cc17c52 100644
--- a/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs
+++ b/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs
@@ -136,13 +136,22 @@ public static ICssStyleDeclaration ComputeExplicitStyle(this IStyleCollection st
var ctx = element.Owner?.Context ?? throw new InvalidOperationException("The element must be associated with a browsing context.");
var computedStyle = new CssStyleDeclaration(ctx);
var matches = styles.SortBySpecificity(element);
+ var i = 0;
- for (var i = 0; i < matches.Count; i++)
+ for (; i < matches.Count && matches[i].IsDefault; i++)
{
- var inlineStyle = matches[i].Rule.Style;
- computedStyle.SetDeclarations(inlineStyle);
+ computedStyle.SetDeclarations(matches[i].Rule.Style);
}
+ ApplyPresentationalHints(computedStyle, element);
+
+ for (; i < matches.Count; i++)
+ {
+ computedStyle.SetDeclarations(matches[i].Rule.Style);
+ }
+
+ ApplyImportantLayerOrder(computedStyle, matches);
+
if (element is IHtmlElement || element is ISvgElement)
{
computedStyle.SetDeclarations(element.GetStyle());
@@ -167,15 +176,130 @@ internal static ICssStyleDeclaration ComputeDeclarationsWithParent(this IStyleCo
return explicitStyle.PrepareComputedDeclarations(parentComputedStyle, context).Compute(context).Blockify(element);
}
+ ///
+ /// Computes the explicit style of a shadow host: the :host rules of its shadow tree come
+ /// after the user agent's rules, but lose against the author rules of the host's own tree,
+ /// unless they are important (CSS Cascade 5, 6.1).
+ ///
+ /// The styles of the host's own tree.
+ /// The styles of the shadow tree, scoped to the host.
+ /// The shadow host.
+ /// Returns the explicit read-only style declaration.
+ internal static ICssStyleDeclaration ComputeHostStyle(this IStyleCollection styles, StyleCollection shadowStyles, IElement host)
+ {
+ var shadowMatches = MatchHost(shadowStyles, host);
+
+ if (shadowMatches.Count == 0)
+ {
+ return styles.ComputeExplicitStyle(host);
+ }
+
+ var ctx = host.Owner?.Context ?? throw new InvalidOperationException("The element must be associated with a browsing context.");
+ var shadowStyle = new CssStyleDeclaration(ctx);
+ var computedStyle = new CssStyleDeclaration(ctx);
+ var matches = styles.SortBySpecificity(host);
+ var i = 0;
+
+ foreach (var match in shadowMatches)
+ {
+ shadowStyle.SetDeclarations(match.Rule.Style);
+ }
+
+ ApplyImportantLayerOrder(shadowStyle, shadowMatches);
+
+ for (; i < matches.Count && matches[i].IsDefault; i++)
+ {
+ computedStyle.SetDeclarations(matches[i].Rule.Style);
+ }
+
+ ApplyPresentationalHints(computedStyle, host);
+ computedStyle.SetDeclarations(shadowStyle);
+
+ for (; i < matches.Count; i++)
+ {
+ computedStyle.SetDeclarations(matches[i].Rule.Style);
+ }
+
+ ApplyImportantLayerOrder(computedStyle, matches);
+ computedStyle.SetDeclarations(host.GetStyle());
+ computedStyle.SetDeclarations(shadowStyle.Where(m => m.IsImportant).ToList());
+ return computedStyle;
+ }
+
#endregion
#region Helpers
+ ///
+ /// Applies the presentational hints of the element (e.g. the width attribute of an img),
+ /// which come after the user agent rules and before all other author rules (CSS Cascade 5, 6.1).
+ ///
+ private static void ApplyPresentationalHints(CssStyleDeclaration computedStyle, IElement element)
+ {
+ var hints = PresentationalHints.GetDeclarations(element);
+
+ if (hints is not null)
+ {
+ var declarations = new CssStyleDeclaration(element.Owner?.Context);
+ declarations.Update(hints);
+ computedStyle.SetDeclarations(declarations);
+ }
+ }
+
+ ///
+ /// For important declarations the order of the cascade layers is reversed:
+ /// an earlier layer wins against a later one and against unlayered rules
+ /// (CSS Cascade 5, 6.4). The matches are sorted for normal declarations, so
+ /// the important ones of the author are applied once more in that order.
+ ///
+ private static void ApplyImportantLayerOrder(CssStyleDeclaration computedStyle, List matches)
+ {
+ List? important = null;
+ var layered = false;
+
+ foreach (var match in matches)
+ {
+ if (!match.IsDefault && match.Rule.Style.Any(m => m.IsImportant))
+ {
+ important ??= new List();
+ layered = layered || (important.Count > 0 && important[0].LayerRank != match.LayerRank);
+ important.Add(match);
+ }
+ }
+
+ if (layered)
+ {
+ important!.Sort((x, y) => x.LayerRank != y.LayerRank ? y.LayerRank.CompareTo(x.LayerRank) : RuleMatchComparer.Instance.Compare(x, y));
+
+ foreach (var match in important)
+ {
+ computedStyle.SetDeclarations(match.Rule.Style.Where(m => m.IsImportant));
+ }
+ }
+ }
+
+ private static List MatchHost(StyleCollection shadowStyles, IElement host)
+ {
+ var rules = shadowStyles.GetRules();
+ var matches = new List();
+
+ for (var i = shadowStyles.GetDefaultRuleCount(); i < rules.Count; i++)
+ {
+ if (rules[i] is CssStyleRule rule && rule.TryMatchHost(host, out var specificity))
+ {
+ matches.Add(new RuleMatch(rule, false, shadowStyles.GetLayerRank(i), specificity, matches.Count));
+ }
+ }
+
+ matches.Sort(RuleMatchComparer.Instance);
+ return matches;
+ }
+
private static List SortBySpecificity(this IStyleCollection styles, IElement element)
{
// Resolving the scope once is equivalent to letting every TryMatch call
// fall back to it, but avoids re-reading DocumentElement per rule.
- var scope = element.Owner?.DocumentElement;
+ var scope = (styles as StyleCollection)?.Scope ?? element.Owner?.DocumentElement;
var matches = new List();
if (styles is StyleCollection collection)
@@ -183,16 +307,17 @@ private static List SortBySpecificity(this IStyleCollection styles, I
var rules = collection.GetRules();
var defaultRuleCount = collection.GetDefaultRuleCount();
- for (var i = 0; i < rules.Count; i++)
+ // the candidates keep the order of the rules, as required by the cascade
+ foreach (var i in collection.GetIndex().GetCandidates(element))
{
- MapPriority(rules[i], element, scope, i < defaultRuleCount, matches);
+ MapPriority(rules[i], element, scope, i < defaultRuleCount, collection.GetLayerRank(i), matches);
}
}
else
{
foreach (var rule in styles)
{
- MapPriority(rule, element, scope, false, matches);
+ MapPriority(rule, element, scope, false, 0, matches);
}
}
@@ -202,11 +327,11 @@ private static List SortBySpecificity(this IStyleCollection styles, I
return matches;
}
- private static void MapPriority(ICssStyleRule rule, IElement element, IElement? scope, Boolean isDefault, List matches)
+ private static void MapPriority(ICssStyleRule rule, IElement element, IElement? scope, Boolean isDefault, Int32 layerRank, List matches)
{
if (rule.TryMatch(element, scope, out var specificity))
{
- matches.Add(new RuleMatch(rule, isDefault, specificity, matches.Count));
+ matches.Add(new RuleMatch(rule, isDefault, layerRank, specificity, matches.Count));
}
var subRules = rule.Rules;
@@ -215,17 +340,18 @@ private static void MapPriority(ICssStyleRule rule, IElement element, IElement?
{
if (subRules[i] is ICssStyleRule style)
{
- MapPriority(style, element, scope, isDefault, matches);
+ MapPriority(style, element, scope, isDefault, layerRank, matches);
}
}
}
private readonly struct RuleMatch
{
- public RuleMatch(ICssStyleRule rule, Boolean isDefault, Priority priority, Int32 index)
+ public RuleMatch(ICssStyleRule rule, Boolean isDefault, Int32 layerRank, Priority priority, Int32 index)
{
Rule = rule;
IsDefault = isDefault;
+ LayerRank = layerRank;
Priority = priority;
Index = index;
}
@@ -234,6 +360,8 @@ public RuleMatch(ICssStyleRule rule, Boolean isDefault, Priority priority, Int32
public Boolean IsDefault { get; }
+ public Int32 LayerRank { get; }
+
public Priority Priority { get; }
public Int32 Index { get; }
@@ -252,6 +380,12 @@ public Int32 Compare(RuleMatch x, RuleMatch y)
return x.IsDefault ? -1 : 1;
}
+ // Cascade layers precede specificity: a later layer wins (CSS Cascade 5, 6.4).
+ if (x.LayerRank != y.LayerRank)
+ {
+ return x.LayerRank.CompareTo(y.LayerRank);
+ }
+
var result = Comparer.Default.Compare(x.Priority, y.Priority);
return result != 0 ? result : x.Index.CompareTo(y.Index);
}
diff --git a/src/AngleSharp.Css/Extensions/StyleSheetExtensions.cs b/src/AngleSharp.Css/Extensions/StyleSheetExtensions.cs
index 579ccdd1..63ad9ea1 100644
--- a/src/AngleSharp.Css/Extensions/StyleSheetExtensions.cs
+++ b/src/AngleSharp.Css/Extensions/StyleSheetExtensions.cs
@@ -63,6 +63,13 @@ public static IEnumerable GetMatchingStyles(this ICssRuleList rul
}
}
}
+ else if (rule is ICssLayerRule { IsStatement: false } layer)
+ {
+ foreach (var subrule in layer.Rules.GetMatchingStyles(device))
+ {
+ yield return subrule;
+ }
+ }
else if (rule.Type == CssRuleType.Style)
{
yield return (ICssStyleRule)rule;
diff --git a/src/AngleSharp.Css/Extensions/WindowExtensions.cs b/src/AngleSharp.Css/Extensions/WindowExtensions.cs
index 51ef9acf..9316c39f 100644
--- a/src/AngleSharp.Css/Extensions/WindowExtensions.cs
+++ b/src/AngleSharp.Css/Extensions/WindowExtensions.cs
@@ -25,7 +25,9 @@ public static class WindowExtensions
public static IMediaQueryList MatchMedia(this IWindow window, String mediaText)
{
var document = window.Document;
- var media = new MediaList(document.Context) { MediaText = mediaText };
+ var media = new MediaList(document.Context);
+ // an invalid query does not throw, but becomes "not all" (CSSOM View, 4.2)
+ media.SetMediaText(mediaText, throwOnError: false);
return new CssMediaQueryList(window, media);
}
diff --git a/src/AngleSharp.Css/Factories/DefaultDeclarationFactory.cs b/src/AngleSharp.Css/Factories/DefaultDeclarationFactory.cs
index 07bfaa98..79136b76 100644
--- a/src/AngleSharp.Css/Factories/DefaultDeclarationFactory.cs
+++ b/src/AngleSharp.Css/Factories/DefaultDeclarationFactory.cs
@@ -3373,7 +3373,7 @@ public DeclarationInfo Unregister(String propertyName)
/// The default (any) declaration.
protected virtual DeclarationInfo CreateDefault(String propertyName)
{
- if (propertyName.StartsWith("--"))
+ if (propertyName.StartsWith("--", StringComparison.Ordinal))
{
return new DeclarationInfo(propertyName, ValueConverters.Any, flags: PropertyFlags.Inherited);
}
diff --git a/src/AngleSharp.Css/FeatureValidators/OrientationFeatureValidator.cs b/src/AngleSharp.Css/FeatureValidators/OrientationFeatureValidator.cs
index 1aee45dd..d4f61fd6 100644
--- a/src/AngleSharp.Css/FeatureValidators/OrientationFeatureValidator.cs
+++ b/src/AngleSharp.Css/FeatureValidators/OrientationFeatureValidator.cs
@@ -14,7 +14,8 @@ public Boolean Validate(IMediaFeature feature, IRenderDevice renderDevice)
if (portrait != null)
{
var desired = portrait.Is(CssKeywords.Portrait);
- var available = renderDevice.DeviceHeight >= renderDevice.DeviceWidth;
+ // the orientation of the viewport, not the device (Media Queries 4, 4.4)
+ var available = renderDevice.ViewPortHeight >= renderDevice.ViewPortWidth;
return desired == available;
}
diff --git a/src/AngleSharp.Css/Parser/Micro/MediumParser.cs b/src/AngleSharp.Css/Parser/Micro/MediumParser.cs
index 7d399cc9..939b25fb 100644
--- a/src/AngleSharp.Css/Parser/Micro/MediumParser.cs
+++ b/src/AngleSharp.Css/Parser/Micro/MediumParser.cs
@@ -33,14 +33,26 @@ public static CssMedium ParseMedium(this StringSource source, IFeatureValidatorF
if (medium is not null)
{
- foreach (var feature in medium.Features)
+ AssociateValidators(medium, factory);
+ }
+
+ return medium;
+ }
+
+ private static void AssociateValidators(CssMedium medium, IFeatureValidatorFactory factory)
+ {
+ foreach (var feature in medium.Features)
+ {
+ if (feature is MediaCondition condition)
+ {
+ AssociateValidators(condition.Medium, factory);
+ }
+ else
{
var validator = factory?.Create(feature.Name);
feature.AssociateValidator(validator);
}
}
-
- return medium;
}
private static CssMedium ParseMediaCondition(this StringSource source)
@@ -138,6 +150,13 @@ private static CssMedium ParseMediaInParens(this StringSource source)
if (medium is not null && source.Current == Symbols.RoundBracketClose)
{
source.SkipCurrentAndSpaces();
+
+ // a nested condition keeps its own connector and negation
+ if (medium.IsInverse || medium.Features.Count() > 1)
+ {
+ return new CssMedium(String.Empty, false, false, new IMediaFeature[] { new MediaCondition(medium) }, CssKeywords.And);
+ }
+
return medium;
}
@@ -310,68 +329,120 @@ private static MediaFeature ParseMediaFeatureRange(this StringSource source)
// | '>' '='? '>' '='?
var pos = source.Index;
- var name = source.ParseIdentAsValue() ?? source.ParseMediaFeatureValue();
+ var name = source.ParseIdent();
if (name is not null)
{
source.SkipSpacesAndComments();
- var op = "";
+ var op = source.ParseRangeOperator();
- if (source.Current == Symbols.LessThan || source.Current == Symbols.GreaterThan)
+ if (op is not null)
{
- var c = source.Current;
+ var value = source.ParseMediaFeatureValue();
- if (source.Next() == Symbols.Equality)
- {
- op = $"{c}=";
- source.SkipCurrentAndSpaces();
- }
- else
+ if (value is not null)
{
- op = $"{c}";
source.SkipSpacesAndComments();
+ var comparisons = new[] { new MediaFeature.RangeComparison(op, value) };
+ return new MediaFeature(name, comparisons, $"{name} {op} {value.CssText}");
}
}
- else if (source.Current == Symbols.Equality)
- {
- op = "=";
- source.SkipCurrentAndSpaces();
- }
- else
- {
- source.BackTo(pos);
- return null;
- }
- if (name is CssIdentifierValue)
+ source.BackTo(pos);
+ }
+
+ var lower = source.ParseMediaFeatureValue();
+
+ if (lower is not null)
+ {
+ source.SkipSpacesAndComments();
+ var lowerOp = source.ParseRangeOperator();
+ name = lowerOp is not null ? source.ParseIdent() : null;
+
+ if (name is not null)
{
- var value = source.ParseMediaFeatureValue();
+ source.SkipSpacesAndComments();
+ // "value op name" compares the other way around, e.g. 600px <= width means width >= 600px
+ var comparisons = new List { new(FlipRangeOperator(lowerOp), lower) };
+ var text = $"{lower.CssText} {lowerOp} {name}";
+ var upperPos = source.Index;
+ var upperOp = source.ParseRangeOperator();
- if (value is not null)
+ if (upperOp is not null)
{
- return new MediaFeature(name, value, op);
- }
- }
- else
- {
- var value = source.ParseIdentAsValue();
+ var upper = source.ParseMediaFeatureValue();
- if (value is not null)
+ // both comparisons of a double range must point the same way, and = is not allowed
+ if (upper is null || lowerOp[0] != upperOp[0] || lowerOp[0] == Symbols.Equality)
+ {
+ source.BackTo(pos);
+ return null;
+ }
+
+ source.SkipSpacesAndComments();
+ comparisons.Add(new MediaFeature.RangeComparison(upperOp, upper));
+ text = $"{text} {upperOp} {upper.CssText}";
+ }
+ else
{
- return new MediaFeature(name, value, op);
+ source.BackTo(upperPos);
}
+
+ return new MediaFeature(name, comparisons, text);
}
+ }
- source.BackTo(pos);
+ source.BackTo(pos);
+ return null;
+ }
+
+ private static String ParseRangeOperator(this StringSource source)
+ {
+ // '<' '='? | '>' '='? | '='
+
+ var c = source.Current;
+
+ if (c == Symbols.LessThan || c == Symbols.GreaterThan)
+ {
+ if (source.Next() == Symbols.Equality)
+ {
+ source.SkipCurrentAndSpaces();
+ return $"{c}=";
+ }
+
+ source.SkipSpacesAndComments();
+ return $"{c}";
+ }
+ else if (c == Symbols.Equality)
+ {
+ source.SkipCurrentAndSpaces();
+ return "=";
}
return null;
}
+ private static String FlipRangeOperator(String op) => op switch
+ {
+ "<" => ">",
+ "<=" => ">=",
+ ">" => "<",
+ ">=" => "<=",
+ _ => op,
+ };
+
private static ICssValue ParseMediaFeatureValue(this StringSource source)
{
// Syntax:
// = | | |
+ // where math functions (calc(), min(), ...) may stand for numbers and dimensions
+
+ var math = (ICssValue)source.ParseCalc() ?? source.ParseMath();
+
+ if (math is not null)
+ {
+ return math;
+ }
var ident = source.ParseIdent();
diff --git a/src/AngleSharp.Css/Parser/Micro/NumberParser.cs b/src/AngleSharp.Css/Parser/Micro/NumberParser.cs
index ceed84d1..c50ac66a 100644
--- a/src/AngleSharp.Css/Parser/Micro/NumberParser.cs
+++ b/src/AngleSharp.Css/Parser/Micro/NumberParser.cs
@@ -47,6 +47,30 @@ public static class NumberParser
return null;
}
+ ///
+ /// Parses a ratio, where a single number n is the ratio n/1 (e.g. aspect-ratio: 2).
+ ///
+ public static CssRatioValue? ParseRatioOrNumber(this StringSource source)
+ {
+ var ratio = source.ParseRatio();
+
+ if (ratio.HasValue)
+ {
+ return ratio;
+ }
+
+ var pos = source.Index;
+ var number = source.ParseNumber();
+
+ if (number.HasValue && number.Value.Value >= 0)
+ {
+ return new CssRatioValue(number.Value, new CssNumberValue(1));
+ }
+
+ source.BackTo(pos);
+ return null;
+ }
+
///
/// Parses the integer (double) value.
///
diff --git a/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs b/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs
index 3dc307ff..678d13ce 100644
--- a/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs
+++ b/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs
@@ -5,6 +5,8 @@ namespace AngleSharp.Css.RenderTree
using AngleSharp.Css.Dom;
using AngleSharp.Css.Values;
using AngleSharp.Dom;
+ using AngleSharp.Html.Dom;
+ using AngleSharp.Text;
using System;
using System.Collections.Generic;
using System.Linq;
@@ -80,9 +82,14 @@ private ElementRenderNode RenderElement(
StyleCollection collection,
ElementRenderNode? parent,
ICssStyleDeclaration? parentSpecifiedStyle,
- ICssStyleDeclaration? parentComputedStyle)
+ ICssStyleDeclaration? parentComputedStyle,
+ ICssStyleDeclaration? explicitStyle = null,
+ StyleCollection? slottedCollection = null)
{
- var explicitStyle = collection.ComputeExplicitStyle(element);
+ var shadowCollection = CreateShadowCollection(element, collection);
+ explicitStyle ??= shadowCollection is not null
+ ? collection.ComputeHostStyle(shadowCollection, element)
+ : collection.ComputeExplicitStyle(element);
var specifiedStyle = new CssStyleDeclaration(_context);
specifiedStyle.SetDeclarations(explicitStyle);
@@ -111,46 +118,152 @@ private ElementRenderNode RenderElement(
return node;
}
- var before = element.Pseudo("before");
+ var beforeNode = RenderPseudoElement(element, "before", collection, node, specifiedStyle, computedStyle);
- if (before is not null)
+ if (beforeNode is not null)
{
- var beforeNode = RenderElement(before, collection, node, specifiedStyle, computedStyle);
-
- // Per spec, `content: none` (and the initial/unset `normal`, equivalent for these two
- // pseudo-elements specifically) means no box is generated at all - not merely an empty
- // one - so a content-less ::before/::after is simply left out of the tree entirely.
- if (ContentDeclaration.HasContent(beforeNode.ComputedStyle))
- {
- children.Add(beforeNode);
- }
+ children.Add(beforeNode);
}
- foreach (var child in element.ChildNodes)
+ foreach (var child in GetFlatChildren(element, collection, shadowCollection, slottedCollection))
{
- if (child is IElement childElement)
+ if (child.Node is IElement childElement)
{
- children.Add(RenderElement(childElement, collection, node, specifiedStyle, computedStyle));
+ children.Add(RenderElement(childElement, child.Collection, node, specifiedStyle, computedStyle, slottedCollection: child.SlottedCollection));
}
- else if (child is IText childText)
+ else if (child.Node is IText childText)
{
children.Add(new TextRenderNode(childText, node));
}
}
- var after = element.Pseudo("after");
+ var afterNode = RenderPseudoElement(element, "after", collection, node, specifiedStyle, computedStyle);
+
+ if (afterNode is not null)
+ {
+ children.Add(afterNode);
+ }
+
+ return node;
+ }
+
+ ///
+ /// The children of the element in the flat tree: a shadow host shows its shadow tree, which
+ /// is styled by the shadow root's own style sheets, and a slot in there shows the light
+ /// nodes assigned to it (styled as part of the host's tree) or else its own children.
+ ///
+ private static IEnumerable GetFlatChildren(
+ IElement element,
+ StyleCollection collection,
+ StyleCollection? shadowCollection,
+ StyleCollection? slottedCollection)
+ {
+ if (shadowCollection is not null)
+ {
+ return ExpandSlots(element.ShadowRoot!.ChildNodes, shadowCollection, collection);
+ }
+
+ return ExpandSlots(element.ChildNodes, collection, slottedCollection);
+ }
+
+ ///
+ /// Creates the styles of the shadow tree of a shadow host, i.e. the ones from the shadow
+ /// root's own style sheets, which also style the host via :host.
+ ///
+ private StyleCollection? CreateShadowCollection(IElement element, StyleCollection collection)
+ {
+ if (element is IPseudoElement || element.ShadowRoot is not IShadowRoot shadowRoot)
+ {
+ return null;
+ }
+
+ var shadowSheets = shadowRoot.StyleSheets.OfType().ToList();
+ return new StyleCollection(_defaultSheets, shadowSheets, collection.Device, element);
+ }
- if (after is not null)
+ private static IEnumerable ExpandSlots(
+ INodeList nodes,
+ StyleCollection collection,
+ StyleCollection? slottedCollection)
+ {
+ foreach (var node in nodes)
{
- var afterNode = RenderElement(after, collection, node, specifiedStyle, computedStyle);
+ if (slottedCollection is not null && node is IHtmlSlotElement slot)
+ {
+ // a hidden slot hides what it shows, e.g. :host(:not([open])) slot { display: none }
+ if (collection.ComputeExplicitStyle(slot).GetPropertyValue(PropertyNames.Display).Is(CssKeywords.None))
+ {
+ continue;
+ }
+
+ var assigned = slot.GetDistributedNodes().ToList();
- if (ContentDeclaration.HasContent(afterNode.ComputedStyle))
+ if (assigned.Count > 0)
+ {
+ foreach (var assignedNode in assigned)
+ {
+ yield return new FlatChild(assignedNode, slottedCollection, null);
+ }
+ }
+ else
+ {
+ foreach (var fallback in ExpandSlots(slot.ChildNodes, collection, slottedCollection))
+ {
+ yield return fallback;
+ }
+ }
+ }
+ else
{
- children.Add(afterNode);
+ yield return new FlatChild(node, collection, slottedCollection);
}
}
+ }
- return node;
+ private readonly struct FlatChild
+ {
+ public FlatChild(INode node, StyleCollection collection, StyleCollection? slottedCollection)
+ {
+ Node = node;
+ Collection = collection;
+ SlottedCollection = slottedCollection;
+ }
+
+ public INode Node { get; }
+ public StyleCollection Collection { get; }
+ public StyleCollection? SlottedCollection { get; }
+ }
+
+ private ElementRenderNode? RenderPseudoElement(
+ IElement element,
+ String name,
+ StyleCollection collection,
+ ElementRenderNode parent,
+ ICssStyleDeclaration parentSpecifiedStyle,
+ ICssStyleDeclaration parentComputedStyle)
+ {
+ var pseudo = element.Pseudo(name);
+
+ if (pseudo is null)
+ {
+ return null;
+ }
+
+ var explicitStyle = collection.ComputeExplicitStyle(pseudo);
+
+ // Without a declaration `content` keeps its initial value `normal` (it is not inherited),
+ // so no box is generated and the (expensive) computation of the full style can be skipped.
+ if (!explicitStyle.Any(m => PropertyNames.Content.Equals(m.Name, StringComparison.OrdinalIgnoreCase)))
+ {
+ return null;
+ }
+
+ var node = RenderElement(pseudo, collection, parent, parentSpecifiedStyle, parentComputedStyle, explicitStyle);
+
+ // Per spec, `content: none` (and the initial/unset `normal`, equivalent for these two
+ // pseudo-elements specifically) means no box is generated at all - not merely an empty
+ // one - so a content-less ::before/::after is simply left out of the tree entirely.
+ return ContentDeclaration.HasContent(node.ComputedStyle) ? node : null;
}
}
}
diff --git a/src/AngleSharp.Css/ValueConverters.cs b/src/AngleSharp.Css/ValueConverters.cs
index 75e6cd59..3ca7c35d 100644
--- a/src/AngleSharp.Css/ValueConverters.cs
+++ b/src/AngleSharp.Css/ValueConverters.cs
@@ -392,17 +392,17 @@ static class ValueConverters
///
/// Represents a converter for the view-transition-name property.
///
- public static readonly IValueConverter ViewTransitionNameConverter = Or(None, IdentifierConverter.FromList());
+ public static readonly IValueConverter ViewTransitionNameConverter = Or(None, CustomIdentConverter.FromList());
///
/// Represents a converter for the view-transition-class property.
///
- public static readonly IValueConverter ViewTransitionClassConverter = Or(None, IdentifierConverter.FromList());
+ public static readonly IValueConverter ViewTransitionClassConverter = Or(None, CustomIdentConverter.FromList());
///
/// Represents a converter for the anchor-name property.
///
- public static readonly IValueConverter AnchorNameConverter = Or(None, IdentifierConverter.FromList());
+ public static readonly IValueConverter AnchorNameConverter = Or(None, CustomIdentConverter.FromList());
///
/// Represents a converter for the anchor-scope property.
@@ -410,14 +410,14 @@ static class ValueConverters
public static readonly IValueConverter AnchorScopeConverter = Or(
Assign(CssKeywords.All, CssKeywords.All),
Assign(CssKeywords.Own, CssKeywords.Own),
- IdentifierConverter.FromList());
+ CustomIdentConverter.FromList());
///
/// Represents a converter for the position-anchor property.
///
public static readonly IValueConverter PositionAnchorConverter = Or(
Assign(CssKeywords.Auto, CssKeywords.Auto),
- IdentifierConverter);
+ CustomIdentConverter);
///
/// Represents a converter for the position-area property.
@@ -916,7 +916,7 @@ static class ValueConverters
///
public static readonly IValueConverter AnimationTimelineConverter = Or(
Assign(CssKeywords.Auto, CssKeywords.Auto),
- IdentifierConverter).FromList();
+ CustomIdentConverter).FromList();
///
/// Represents a converter for a single animation-range-start/end value.
@@ -1238,7 +1238,7 @@ static class ValueConverters
///
public static readonly IValueConverter FontPaletteConverter = Or(
Assign(CssKeywords.Normal, CssKeywords.Normal),
- IdentifierConverter);
+ CustomIdentConverter);
///
/// Represents a converter for the font-synthesis property (shorthand).
@@ -1649,7 +1649,7 @@ static class ValueConverters
/// Represents a converter for the aspect-ratio property (auto | <ratio>).
/// https://developer.mozilla.org/en-US/docs/Web/CSS/aspect-ratio
///
- public static readonly IValueConverter AspectRatioConverter = Or(FromParser(ParseAutoRatio), Auto, RatioConverter);
+ public static readonly IValueConverter AspectRatioConverter = Or(FromParser(ParseAutoRatio), Auto, new StructValueConverter(NumberParser.ParseRatioOrNumber));
///
/// Represents a converter for the overflow-anchor property (auto | none).
@@ -1845,7 +1845,7 @@ public static IValueConverter WithBorderSide(ICssValue lineWidth, ICssValue line
public static readonly IValueConverter GridLineConverter = Or(
Assign(CssKeywords.Auto, CssKeywords.Auto),
- WithAny(Assign(CssKeywords.Span, true), IntegerConverter, IdentifierConverter));
+ WithAny(Assign(CssKeywords.Span, true), IntegerConverter, CustomIdentConverter));
public static readonly IValueConverter SrcListConverter =
WithOrder(
@@ -1926,11 +1926,11 @@ private static CssTupleValue ParseAutoRatio(this StringSource source)
if (source.IsIdentifier(CssKeywords.Auto))
{
source.SkipSpacesAndComments();
- ratio = source.ParseRatio();
+ ratio = source.ParseRatioOrNumber();
}
else
{
- ratio = source.ParseRatio();
+ ratio = source.ParseRatioOrNumber();
source.SkipSpacesAndComments();
if (!source.IsIdentifier(CssKeywords.Auto))
diff --git a/src/AngleSharp.Css/Values/CssComputeContext.cs b/src/AngleSharp.Css/Values/CssComputeContext.cs
index 88951ece..8521f534 100644
--- a/src/AngleSharp.Css/Values/CssComputeContext.cs
+++ b/src/AngleSharp.Css/Values/CssComputeContext.cs
@@ -53,6 +53,9 @@ public Double FontSize
public Double RootFontSize => _parent is null ? FontSize : (_parent as CssStyleDeclaration)?.RootFontSize ?? DefaultFontSize;
+ // rem in the font-size of the root refers to the initial font size (CSS Values 4, 6.1.1)
+ public Double FontSizeRemBase => _parent is null ? DefaultFontSize : RootFontSize;
+
private Double DefaultFontSize => _device.FontSize > 0 ? _device.FontSize : 16.0;
private static Double? ToPixels(ICssValue? value) =>
diff --git a/src/AngleSharp.Css/Values/CssVariableValue.cs b/src/AngleSharp.Css/Values/CssVariableValue.cs
index 85d403b7..d1f65e4b 100644
--- a/src/AngleSharp.Css/Values/CssVariableValue.cs
+++ b/src/AngleSharp.Css/Values/CssVariableValue.cs
@@ -132,7 +132,7 @@ public String? Keyword
{
if (fallbacks.Count > 0 && fallbacks.Peek() == i)
{
- if (!Append(result, cursor, Offset(i)) || !Separate(result, NeedsSeparator(EndOffset(i))))
+ if (!Append(result, cursor, Offset(i)) || !Separate(result, CharAt(EndOffset(i))))
{
return null;
}
@@ -142,7 +142,7 @@ public String? Keyword
}
else if (_references.TryGetValue(i, out var reference))
{
- if (!Append(result, cursor, Offset(i)) || !Separate(result, result.Length > 0))
+ if (!Append(result, cursor, Offset(i)))
{
return null;
}
@@ -153,7 +153,7 @@ public String? Keyword
{
var text = value.CssText;
- if (text.Length > MaxSubstitutionLength - result.Length)
+ if (!Separate(result, text.Length > 0 ? text[0] : Symbols.EndOfFile) || text.Length > MaxSubstitutionLength - result.Length)
{
return null;
}
@@ -162,7 +162,7 @@ public String? Keyword
cursor = EndOffset(reference.End);
i = reference.End;
- if (!Separate(result, NeedsSeparator(cursor)))
+ if (!Separate(result, CharAt(cursor)))
{
return null;
}
@@ -170,6 +170,12 @@ public String? Keyword
else if (reference.Fallback >= 0)
{
cursor = Offset(reference.Fallback - 1) + 1;
+
+ if (!Separate(result, CharAt(cursor)))
+ {
+ return null;
+ }
+
i = reference.Fallback - 1;
fallbacks.Push(reference.End);
}
@@ -196,9 +202,9 @@ private Boolean Append(StringBuilder result, Int32 start, Int32 end)
return true;
}
- private static Boolean Separate(StringBuilder result, Boolean needed)
+ private static Boolean Separate(StringBuilder result, Char next)
{
- if (needed && result.Length > 0 && !result[result.Length - 1].IsSpaceCharacter())
+ if (result.Length > 0 && WouldMerge(result[result.Length - 1], next))
{
if (result.Length > MaxSubstitutionLength - 4)
{
@@ -217,7 +223,17 @@ private static Boolean Separate(StringBuilder result, Boolean needed)
private Int32 EndOffset(Int32 index) => Math.Min(Offset(index) + 1, Text.Length);
- private Boolean NeedsSeparator(Int32 index) => index < Text.Length && !Text[index].IsSpaceCharacter();
+ private Char CharAt(Int32 index) => index < Text.Length ? Text[index] : Symbols.EndOfFile;
+
+ ///
+ /// Whether the two characters could be part of the same token (e.g. a number and a unit,
+ /// or the slash and asterisk of a comment), only then a separator is needed between them.
+ ///
+ private static Boolean WouldMerge(Char before, Char after) =>
+ IsTokenCharacter(before) && IsTokenCharacter(after) || (before == Symbols.Solidus && after == Symbols.Asterisk);
+
+ private static Boolean IsTokenCharacter(Char c) =>
+ c != Symbols.EndOfFile && (c.IsLetter() || c.IsDigit() || c >= 0x80 || c is '-' or '_' or '.' or '%' or '#' or '@' or '+' or '\\');
private Int32 SkipTrivia(Int32 index)
{
diff --git a/src/AngleSharp.Css/Values/IFontComputeContext.cs b/src/AngleSharp.Css/Values/IFontComputeContext.cs
index 89af48b8..9c79873b 100644
--- a/src/AngleSharp.Css/Values/IFontComputeContext.cs
+++ b/src/AngleSharp.Css/Values/IFontComputeContext.cs
@@ -21,4 +21,9 @@ interface IFontComputeContext
/// Gets the computed font size (in px) of the root element.
///
Double RootFontSize { get; }
+
+ ///
+ /// Gets the font size (in px) that rem refers to in the font-size of the element.
+ ///
+ Double FontSizeRemBase { get; }
}
diff --git a/src/AngleSharp.Css/Values/Primitives/CssLengthValue.cs b/src/AngleSharp.Css/Values/Primitives/CssLengthValue.cs
index fe08e1cd..8470c822 100644
--- a/src/AngleSharp.Css/Values/Primitives/CssLengthValue.cs
+++ b/src/AngleSharp.Css/Values/Primitives/CssLengthValue.cs
@@ -494,15 +494,19 @@ public Double To(Unit unit, IRenderDimensions renderDimensions, RenderMode mode)
private void CheckForValidRenderDimensions(IRenderDimensions renderDimensions, RenderMode mode)
{
- if (renderDimensions == null || mode == RenderMode.Undefined || (mode == RenderMode.Horizontal ? renderDimensions.RenderWidth : renderDimensions.RenderHeight) <= 0)
+ var size = renderDimensions is null ? 0 : mode == RenderMode.Horizontal ? renderDimensions.RenderWidth : renderDimensions.RenderHeight;
+
+ // a device without a size cannot resolve them, but an element's box may well be empty
+ if (renderDimensions == null || mode == RenderMode.Undefined || size < 0 || (size == 0 && renderDimensions is IRenderDevice))
{
- throw new ArgumentException("A non null render device with a font size is required to calculate em or rem units.");
+ throw new ArgumentException("A non null render device with a size is required to calculate relative units.");
}
}
private void CheckForValidRenderDimensionsForFont(IRenderDimensions renderDimensions)
{
- if (renderDimensions == null || renderDimensions.FontSize <= 0)
+ // a device without a font size cannot resolve them, but an element may well have font-size: 0
+ if (renderDimensions == null || renderDimensions.FontSize < 0 || (renderDimensions.FontSize == 0 && renderDimensions is IRenderDevice))
{
throw new ArgumentException("A non null render device with a font size is required to calculate em or rem units.");
}
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index c4e1a6fd..fcf0c563 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.2.1
+ 1.2.2
enable
latest
true