From b329b2b61cc082fb32753bf883f34a3419de3445 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sun, 25 Oct 2026 14:41:50 +0100 Subject: [PATCH 1/2] Prepare for 1.2.2 --- CHANGELOG.md | 27 ++ src/AngleSharp.Css.Docs/package.json | 2 +- .../Declarations/CssBackgroundProperty.cs | 11 + .../Declarations/CssBoxModelProperty.cs | 23 ++ .../Declarations/CssGridProperty.cs | 13 + .../Extensions/MatchMedia.cs | 120 +++++++ .../Extensions/PseudoElementRenderTests.cs | 21 +- .../Extensions/ShadowTreeRenderTests.cs | 153 +++++++++ .../Rules/CssMediaFeatures.cs | 2 +- .../Styling/CascadeLayerTests.cs | 48 +++ .../CustomPropertySubstitutionTests.cs | 45 +++ .../Styling/LogicalPropertyTests.cs | 62 ++++ .../Styling/PresentationalHintTests.cs | 71 ++++ .../RelativeValueComputedStyleTests.cs | 25 ++ .../Styling/StyleRuleIndexTests.cs | 99 ++++++ .../Values/UnitConversion.cs | 34 ++ .../BrowsingContextExtensions.cs | 13 +- .../Declarations/BackgroundDeclaration.cs | 2 +- .../Declarations/GapDeclaration.cs | 8 +- .../Declarations/GridAreaDeclaration.cs | 2 +- .../Declarations/GridColumnDeclaration.cs | 2 +- .../Declarations/GridRowDeclaration.cs | 2 +- .../Dom/Internal/CssProperty.cs | 4 +- .../Dom/Internal/CssStyleDeclaration.cs | 6 +- .../Internal/ForcingPseudoClassSelector.cs | 12 +- .../Dom/Internal/LogicalProperties.cs | 117 +++++++ .../Dom/Internal/MediaCondition.cs | 52 +++ .../Dom/Internal/MediaFeature.cs | 67 +++- .../Dom/Internal/PresentationalHints.cs | 130 ++++++++ .../Dom/Internal/PseudoElement.cs | 5 + .../Internal/PseudoElementSubjectVisitor.cs | 15 + .../Dom/Internal/Rules/CssStyleRule.cs | 35 ++ .../Dom/Internal/StyleCollection.cs | 134 +++++++- .../Dom/Internal/StyleRuleIndex.cs | 309 ++++++++++++++++++ .../Extensions/CssOmExtensions.cs | 3 +- .../Extensions/CssValueExtensions.cs | 26 ++ .../Extensions/MediaListExtensions.cs | 64 +++- .../Extensions/StyleCollectionExtensions.cs | 156 ++++++++- .../Extensions/StyleSheetExtensions.cs | 7 + .../Extensions/WindowExtensions.cs | 4 +- .../Factories/DefaultDeclarationFactory.cs | 2 +- .../OrientationFeatureValidator.cs | 3 +- .../Parser/Micro/MediumParser.cs | 143 ++++++-- .../Parser/Micro/NumberParser.cs | 24 ++ .../RenderTree/RenderTreeBuilder.cs | 141 ++++++-- src/AngleSharp.Css/ValueConverters.cs | 22 +- .../Values/CssComputeContext.cs | 3 + src/AngleSharp.Css/Values/CssVariableValue.cs | 30 +- .../Values/IFontComputeContext.cs | 5 + .../Values/Primitives/CssLengthValue.cs | 10 +- src/Directory.Build.props | 2 +- 51 files changed, 2170 insertions(+), 146 deletions(-) create mode 100644 src/AngleSharp.Css.Tests/Extensions/ShadowTreeRenderTests.cs create mode 100644 src/AngleSharp.Css.Tests/Styling/CascadeLayerTests.cs create mode 100644 src/AngleSharp.Css.Tests/Styling/CustomPropertySubstitutionTests.cs create mode 100644 src/AngleSharp.Css.Tests/Styling/LogicalPropertyTests.cs create mode 100644 src/AngleSharp.Css.Tests/Styling/PresentationalHintTests.cs create mode 100644 src/AngleSharp.Css.Tests/Styling/StyleRuleIndexTests.cs create mode 100644 src/AngleSharp.Css/Dom/Internal/LogicalProperties.cs create mode 100644 src/AngleSharp.Css/Dom/Internal/MediaCondition.cs create mode 100644 src/AngleSharp.Css/Dom/Internal/PresentationalHints.cs create mode 100644 src/AngleSharp.Css/Dom/Internal/StyleRuleIndex.cs 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($"" + + "
  • A
  • B
"); + 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("PanelButton", "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 } }", "

A

")] + [TestCase(".parent { & .child { color: green } }", "

A

")] + [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 }", "

A

")] + 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..2e0e1b47 --- /dev/null +++ b/src/AngleSharp.Css/Dom/Internal/LogicalProperties.cs @@ -0,0 +1,117 @@ +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, ("", true) }, + { PropertyNames.BlockSize, ("", false) }, + { PropertyNames.MinInlineSize, ("min-", true) }, + { PropertyNames.MinBlockSize, ("min-", false) }, + { PropertyNames.MaxInlineSize, ("max-", true) }, + { PropertyNames.MaxBlockSize, ("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); + var groups = new[] + { + ("margin-", "margin-", ""), + ("padding-", "padding-", ""), + ("inset-", "", ""), + ("scroll-margin-", "scroll-margin-", ""), + ("scroll-padding-", "scroll-padding-", ""), + ("border-", "border-", "-width"), + ("border-", "border-", "-style"), + ("border-", "border-", "-color"), + }; + + foreach (var (logicalPrefix, physicalPrefix, suffix) in groups) + { + foreach (var side in new[] { "block-start", "block-end", "inline-start", "inline-end" }) + { + sides[logicalPrefix + side + suffix] = (physicalPrefix, side, suffix); + } + } + + return sides; + } + } +} 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..198dc1e5 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,26 +118,18 @@ 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, childCollection, childSlottedCollection) in GetFlatChildren(element, collection, shadowCollection, slottedCollection)) { if (child is IElement childElement) { - children.Add(RenderElement(childElement, collection, node, specifiedStyle, computedStyle)); + children.Add(RenderElement(childElement, childCollection, node, specifiedStyle, computedStyle, slottedCollection: childSlottedCollection)); } else if (child is IText childText) { @@ -138,19 +137,119 @@ private ElementRenderNode RenderElement( } } - 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<(INode Node, StyleCollection Collection, StyleCollection? SlottedCollection)> 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); + } - if (after is not null) + /// + /// 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) { - var afterNode = RenderElement(after, collection, node, specifiedStyle, computedStyle); + return null; + } + + var shadowSheets = shadowRoot.StyleSheets.OfType().ToList(); + return new StyleCollection(_defaultSheets, shadowSheets, collection.Device, element); + } - if (ContentDeclaration.HasContent(afterNode.ComputedStyle)) + private static IEnumerable<(INode Node, StyleCollection Collection, StyleCollection? SlottedCollection)> ExpandSlots( + INodeList nodes, + StyleCollection collection, + StyleCollection? slottedCollection) + { + foreach (var node in nodes) + { + 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 (assigned.Count > 0) + { + foreach (var assignedNode in assigned) + { + yield return (assignedNode, slottedCollection, null); + } + } + else + { + foreach (var fallback in ExpandSlots(slot.ChildNodes, collection, slottedCollection)) + { + yield return fallback; + } + } + } + else { - children.Add(afterNode); + yield return (node, collection, slottedCollection); } } + } - return node; + 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 From 5eefc9000e15a07763d0118641165616f272f8bb Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sun, 25 Oct 2026 15:22:04 +0100 Subject: [PATCH 2/2] Removed ValueTuple --- .../Dom/Internal/LogicalProperties.cs | 74 ++++++++++++------- .../RenderTree/RenderTreeBuilder.cs | 30 ++++++-- 2 files changed, 71 insertions(+), 33 deletions(-) diff --git a/src/AngleSharp.Css/Dom/Internal/LogicalProperties.cs b/src/AngleSharp.Css/Dom/Internal/LogicalProperties.cs index 2e0e1b47..b150023d 100644 --- a/src/AngleSharp.Css/Dom/Internal/LogicalProperties.cs +++ b/src/AngleSharp.Css/Dom/Internal/LogicalProperties.cs @@ -13,16 +13,16 @@ namespace AngleSharp.Css.Dom /// static class LogicalProperties { - private static readonly Dictionary Sides = CreateSides(); + private static readonly Dictionary Sides = CreateSides(); - private static readonly Dictionary Sizes = new(StringComparer.OrdinalIgnoreCase) + private static readonly Dictionary Sizes = new(StringComparer.OrdinalIgnoreCase) { - { PropertyNames.InlineSize, ("", true) }, - { PropertyNames.BlockSize, ("", false) }, - { PropertyNames.MinInlineSize, ("min-", true) }, - { PropertyNames.MinBlockSize, ("min-", false) }, - { PropertyNames.MaxInlineSize, ("max-", true) }, - { PropertyNames.MaxBlockSize, ("max-", false) }, + { 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) }, }; /// @@ -88,30 +88,54 @@ public static void Apply(CssStyleDeclaration declarations, ICssComputeContext co _ => vertical ? (inlineReversed ? "top" : "bottom") : (inlineReversed ? "left" : "right"), }; - private static Dictionary CreateSides() + private static Dictionary CreateSides() { - var sides = new Dictionary(StringComparer.OrdinalIgnoreCase); - var groups = new[] - { - ("margin-", "margin-", ""), - ("padding-", "padding-", ""), - ("inset-", "", ""), - ("scroll-margin-", "scroll-margin-", ""), - ("scroll-padding-", "scroll-padding-", ""), - ("border-", "border-", "-width"), - ("border-", "border-", "-style"), - ("border-", "border-", "-color"), - }; - - foreach (var (logicalPrefix, physicalPrefix, suffix) in groups) + 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] = (physicalPrefix, side, suffix); + sides[logicalPrefix + side + suffix] = new SideMapping(physicalPrefix, side, suffix); } } + } - return sides; + 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/RenderTree/RenderTreeBuilder.cs b/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs index 198dc1e5..678d13ce 100644 --- a/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs +++ b/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs @@ -125,13 +125,13 @@ private ElementRenderNode RenderElement( children.Add(beforeNode); } - foreach (var (child, childCollection, childSlottedCollection) in GetFlatChildren(element, collection, shadowCollection, slottedCollection)) + foreach (var child in GetFlatChildren(element, collection, shadowCollection, slottedCollection)) { - if (child is IElement childElement) + if (child.Node is IElement childElement) { - children.Add(RenderElement(childElement, childCollection, node, specifiedStyle, computedStyle, slottedCollection: childSlottedCollection)); + 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)); } @@ -152,7 +152,7 @@ private ElementRenderNode RenderElement( /// 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<(INode Node, StyleCollection Collection, StyleCollection? SlottedCollection)> GetFlatChildren( + private static IEnumerable GetFlatChildren( IElement element, StyleCollection collection, StyleCollection? shadowCollection, @@ -181,7 +181,7 @@ private ElementRenderNode RenderElement( return new StyleCollection(_defaultSheets, shadowSheets, collection.Device, element); } - private static IEnumerable<(INode Node, StyleCollection Collection, StyleCollection? SlottedCollection)> ExpandSlots( + private static IEnumerable ExpandSlots( INodeList nodes, StyleCollection collection, StyleCollection? slottedCollection) @@ -202,7 +202,7 @@ private ElementRenderNode RenderElement( { foreach (var assignedNode in assigned) { - yield return (assignedNode, slottedCollection, null); + yield return new FlatChild(assignedNode, slottedCollection, null); } } else @@ -215,11 +215,25 @@ private ElementRenderNode RenderElement( } else { - yield return (node, collection, slottedCollection); + yield return new FlatChild(node, collection, slottedCollection); } } } + 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,