From 02374730ec2b11dcd39f37e806f9d13033d36a44 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sun, 25 Oct 2026 15:59:25 +0100 Subject: [PATCH 1/6] Performance improvements for render tree computations --- CHANGELOG.md | 7 + src/AngleSharp.Css.Docs/package.json | 2 +- .../Styling/InheritanceChainTests.cs | 107 +++++ .../Dom/Internal/CssProperty.cs | 57 ++- .../Dom/Internal/CssStyleDeclaration.cs | 402 ++++++++++++++++-- .../Dom/Internal/LogicalProperties.cs | 3 +- .../Extensions/CssOmExtensions.cs | 24 +- .../Extensions/StyleCollectionExtensions.cs | 6 +- .../RenderTree/RenderTreeBuilder.cs | 2 +- .../Values/CssComputeContext.cs | 8 +- .../Values/CssCustomPropertyResolver.cs | 23 +- src/Directory.Build.props | 2 +- 12 files changed, 579 insertions(+), 64 deletions(-) create mode 100644 src/AngleSharp.Css.Tests/Styling/InheritanceChainTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 99b3950a..f665481b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# 1.3.0 + +Released on Monday, October 26 2026. + +- Improved the render tree to inherit declarations from the parent's style instead of copying them to every element (e.g. thousands of custom properties on `:root`) +- Improved merging declarations in the cascade, which was quadratic in the number of declarations + # 1.2.2 Released on Sunday, October 25 2026. diff --git a/src/AngleSharp.Css.Docs/package.json b/src/AngleSharp.Css.Docs/package.json index a2b06a86..8bb0eefa 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.2", + "version": "1.3.0", "preview": true, "description": "The doclet for the AngleSharp.Css documentation.", "keywords": [ diff --git a/src/AngleSharp.Css.Tests/Styling/InheritanceChainTests.cs b/src/AngleSharp.Css.Tests/Styling/InheritanceChainTests.cs new file mode 100644 index 00000000..29dbbedc --- /dev/null +++ b/src/AngleSharp.Css.Tests/Styling/InheritanceChainTests.cs @@ -0,0 +1,107 @@ +#nullable disable +namespace AngleSharp.Css.Tests.Styling +{ + using AngleSharp.Css.Dom; + using AngleSharp.Css.RenderTree; + using AngleSharp.Dom; + using AngleSharp.Html.Parser; + using NUnit.Framework; + using System.Collections.Generic; + using System.Linq; + + [TestFixture] + public class InheritanceChainTests + { + private static IDocument Parse(string html) + { + var config = Configuration.Default.WithCss().WithRenderDevice(new DefaultRenderDevice { ViewPortWidth = 1000, ViewPortHeight = 800 }); + return BrowsingContext.New(config).GetService().ParseDocument(html); + } + + private static ElementRenderNode Find(IRenderNode node, string id) + { + if (node is ElementRenderNode element && node.Ref is IElement { Id: var elementId } && elementId == id) + { + return element; + } + + return node.Children.Select(child => Find(child, id)).FirstOrDefault(found => found is not null); + } + + [Test] + public void InheritedDeclarationsReachDeeplyNestedElements() + { + var document = Parse("" + + "
x
"); + var target = Find(document.DefaultView.Render(), "target"); + var computed = target.ComputedStyle; + + Assert.AreEqual("rgba(255, 0, 0, 1)", computed.GetPropertyValue("color")); + Assert.AreEqual("3px", computed.GetPropertyValue("--a")); + Assert.AreEqual("3px", computed.GetPropertyValue("margin-left")); + Assert.AreEqual("rgba(255, 0, 0, 1)", target.SpecifiedStyle.GetPropertyValue("color")); + + var names = new HashSet(computed.Select(m => m.Name)); + Assert.IsTrue(names.Contains("color")); + Assert.IsTrue(names.Contains("--a")); + Assert.AreEqual(computed.Length, names.Count); + } + + [Test] + public void NonInheritedDeclarationsDoNotReachChildrenUnlessRequested() + { + var document = Parse("
" + + "

x

"); + var tree = document.DefaultView.Render(); + + Assert.AreNotEqual("10px", Find(tree, "b").ComputedStyle.GetPropertyValue("width")); + Assert.AreNotEqual("10px", Find(tree, "c").ComputedStyle.GetPropertyValue("width")); + Assert.AreEqual("solid", Find(tree, "b").ComputedStyle.GetPropertyValue("border-top-style")); + Assert.AreEqual("solid", Find(tree, "c").ComputedStyle.GetPropertyValue("border-top-style")); + } + + [Test] + public void ChildOverridesWinOverInheritedDeclarations() + { + var document = Parse("

x

"); + var computed = Find(document.DefaultView.Render(), "target").ComputedStyle; + + Assert.AreEqual("rgba(0, 0, 255, 1)", computed.GetPropertyValue("color")); + Assert.AreEqual("2px", computed.GetPropertyValue("--a")); + Assert.AreEqual(1, computed.Count(m => m.Name == "color")); + } + + [Test] + public void MutatingAParentStyleDoesNotChangeItsChildren() + { + var document = Parse("

x

"); + var tree = document.DefaultView.Render(); + var parent = Find(tree, "parent"); + var child = Find(tree, "child"); + + parent.ComputedStyle.SetProperty("color", "blue"); + parent.ComputedStyle.SetProperty("--a", "5px"); + parent.SpecifiedStyle.SetProperty("color", "blue"); + + Assert.AreEqual("rgba(0, 0, 255, 1)", parent.ComputedStyle.GetPropertyValue("color")); + Assert.AreEqual("rgba(255, 0, 0, 1)", child.ComputedStyle.GetPropertyValue("color")); + Assert.AreEqual("1px", child.ComputedStyle.GetPropertyValue("--a")); + Assert.AreEqual("rgba(255, 0, 0, 1)", child.SpecifiedStyle.GetPropertyValue("color")); + } + + [Test] + public void MutatingAChildStyleDoesNotChangeItsParent() + { + var document = Parse("

x

"); + var tree = document.DefaultView.Render(); + var parent = Find(tree, "parent"); + var child = Find(tree, "child"); + + child.ComputedStyle.SetProperty("color", "blue"); + child.ComputedStyle.RemoveProperty("--missing"); + + Assert.AreEqual("rgba(0, 0, 255, 1)", child.ComputedStyle.GetPropertyValue("color")); + Assert.AreEqual("rgba(255, 0, 0, 1)", parent.ComputedStyle.GetPropertyValue("color")); + } + } +} diff --git a/src/AngleSharp.Css/Dom/Internal/CssProperty.cs b/src/AngleSharp.Css/Dom/Internal/CssProperty.cs index 98138ec2..b24d5597 100644 --- a/src/AngleSharp.Css/Dom/Internal/CssProperty.cs +++ b/src/AngleSharp.Css/Dom/Internal/CssProperty.cs @@ -23,6 +23,7 @@ internal class CssProperty : ICssProperty private Boolean _important; private ICssValue _value; + private KeywordState _keyword; #endregion @@ -44,7 +45,7 @@ internal CssProperty(String name, IValueConverter converter, PropertyFlags flags public ICssValue RawValue { get => _value; - set => _value = value; + set => SetValue(value); } public String Value @@ -61,12 +62,12 @@ public String Value if (parsed != null && source.IsDone && parsed is not CssVarValue) { - _value = parsed; + SetValue(parsed); return; } } - _value = _converter.Convert(value); + SetValue(_converter.Convert(value)); } } @@ -74,13 +75,20 @@ public String Value public PropertyFlags Flags => _flags; - public Boolean IsInherited => (((_flags & PropertyFlags.Inherited) == PropertyFlags.Inherited) && IsInitial) || (HasValue && _value.CssText.Is(CssKeywords.Inherit)); + public Boolean IsInherited + { + get + { + var keyword = GetKeyword(); + return keyword == KeywordState.Inherit || (keyword == KeywordState.Initial && CanBeInherited); + } + } public Boolean CanBeInherited => (_flags & PropertyFlags.Inherited) == PropertyFlags.Inherited; public Boolean IsAnimatable => (_flags & PropertyFlags.Animatable) == PropertyFlags.Animatable; - public Boolean IsInitial => !HasValue || _value.CssText.Is(CssKeywords.Initial); + public Boolean IsInitial => GetKeyword() == KeywordState.Initial; public Boolean IsShorthand => (_flags & PropertyFlags.Shorthand) == PropertyFlags.Shorthand; @@ -108,6 +116,33 @@ public Boolean IsImportant #region Methods + private void SetValue(ICssValue value) + { + _value = value; + _keyword = KeywordState.Unknown; + } + + private KeywordState GetKeyword() + { + if (_keyword == KeywordState.Unknown) + { + _keyword = _value switch + { + null => KeywordState.Initial, + CssInitialValue => KeywordState.Initial, + CssInheritValue => KeywordState.Inherit, + _ => _value.CssText switch + { + var text when text.Is(CssKeywords.Initial) => KeywordState.Initial, + var text when text.Is(CssKeywords.Inherit) => KeywordState.Inherit, + _ => KeywordState.Other, + }, + }; + } + + return _keyword; + } + public ICssProperty Compute(ICssComputeContext context) { var propertyContext = new PropertyComputeContext(context, _converter, _name); @@ -126,6 +161,18 @@ public ICssProperty Compute(ICssComputeContext context) #endregion + #region Keyword State + + private enum KeywordState : Byte + { + Unknown, + Initial, + Inherit, + Other, + } + + #endregion + #region Compute Context sealed class PropertyComputeContext : ICssComputeContext, ILocalComputeContext, IInheritingComputeContext, IRelativeComputeContext diff --git a/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs b/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs index aa55d20c..35399224 100644 --- a/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs +++ b/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs @@ -18,12 +18,21 @@ sealed class CssStyleDeclaration : ICssStyleDeclaration { #region Fields - private readonly List _declarations; - private readonly Dictionary _declarationIndex; + private List _declarations; + private Dictionary _declarationIndex; private readonly IBrowsingContext _context; private ICssRule _parent; private Boolean _updating; + // The declarations inherited from the parent are not copied, but looked up in + // a snapshot of the parent until the full list is requested (see Materialize). + private Snapshot _inherited; + // Names of own declarations (e.g. "color: inherit") replaced by the parent's. + private HashSet _forced; + // The snapshot of this declaration handed out to children; while it exists the + // lists above are shared with it and must be copied before they are modified. + private Snapshot _snapshot; + #endregion #region Events @@ -51,7 +60,14 @@ public CssStyleDeclaration(ICssRule parent) #region Index - public String this[Int32 index] => _declarations[index]?.Name; + public String this[Int32 index] + { + get + { + Materialize(); + return _declarations[index]?.Name; + } + } public String this[String name] => GetPropertyValue(name); @@ -59,7 +75,14 @@ public CssStyleDeclaration(ICssRule parent) #region Properties - public IEnumerable Declarations => _declarations; + public IEnumerable Declarations + { + get + { + Materialize(); + return _declarations; + } + } public String CssText { @@ -69,7 +92,14 @@ public String CssText public Boolean IsReadOnly => _context == null; - public Int32 Length => Declarations.Count(); + public Int32 Length + { + get + { + Materialize(); + return _declarations.Count; + } + } public ICssRule Parent => _parent; @@ -86,15 +116,11 @@ public String CssText public ICssProperty GetProperty(String name) { name = name.StartsWith("--", StringComparison.Ordinal) ? name : name.ToLowerFast(); + var declaration = GetDeclaration(name); - if (_declarationIndex.TryGetValue(name, out var index) && index < _declarations.Count) + if (declaration is not null) { - var declaration = _declarations[index]; - - if (declaration.Name.Is(name)) - { - return declaration; - } + return declaration; } var related = GetPropertyFromShorthand(name); @@ -116,6 +142,9 @@ public void Update(String value) if (!_updating) { + EnsureWritable(); + _inherited = null; + _forced = null; _declarations.Clear(); _declarationIndex.Clear(); @@ -313,6 +342,7 @@ public void SetProperty(String propertyName, String propertyValue, String priori if (property.RawValue is not null) { property.IsImportant = priority is not null; + Materialize(); SetProperty(property); RaiseChanged(); } @@ -327,6 +357,7 @@ public void SetProperty(String propertyName, String propertyValue, String priori public void AddProperty(ICssProperty declaration) { + EnsureWritable(); _declarationIndex[declaration.Name] = _declarations.Count; _declarations.Add(declaration); } @@ -335,8 +366,16 @@ public void RemoveProperty(ICssProperty declaration) { var index = _declarations.IndexOf(declaration); + if (index < 0 && _inherited is not null) + { + // the declaration may only be inherited + Materialize(); + index = _declarations.IndexOf(declaration); + } + if (index >= 0) { + EnsureWritable(); _declarations.RemoveAt(index); RebuildIndex(); } @@ -346,12 +385,117 @@ public void RemoveProperty(ICssProperty declaration) #region Internal Methods + internal Boolean HasImportantDeclarations() + { + Materialize(); + + foreach (var declaration in _declarations) + { + if (declaration.IsImportant) + { + return true; + } + } + + return false; + } + internal void SetDeclarations(IEnumerable decls) => ChangeDeclarations(decls, m => false, (o, n) => !o.IsImportant || n.IsImportant); internal void UpdateDeclarations(IEnumerable decls) => ChangeDeclarations(decls, m => !m.CanBeInherited, (o, n) => o.IsInherited && !IsExplicitInitial(o)); + /// + /// Gets the declaration with the given (normalized) name, if any. In contrast + /// to GetProperty no shorthand / longhand is derived from the other declarations. + /// + internal ICssProperty GetDeclaration(String name) + { + var index = IndexOf(name, _declarations.Count); + + if (index >= 0) + { + return _declarations[index]; + } + + return _inherited is not null ? Snapshot.Inherit(_inherited, _forced, name) : null; + } + + /// + /// Gets the declarations that are not just inherited from the parent, i.e., the + /// own ones and the ones that explicitly inherit (e.g., "color: inherit"). + /// + internal IEnumerable GetLocalDeclarations() + { + if (_inherited is null || _forced is null) + { + return _declarations; + } + + var local = new List(_declarations); + + foreach (var name in _forced) + { + local.Add(Snapshot.Inherit(_inherited, _forced, name)); + } + + return local; + } + + /// + /// Same as UpdateDeclarations with the declarations of the parent, but without + /// copying them: the inherited declarations are looked up in the parent instead, + /// until the full list is requested. The own declarations need to be complete. + /// + internal void InheritFrom(ICssStyleDeclaration parent) + { + if (parent is not CssStyleDeclaration style) + { + UpdateDeclarations(parent); + return; + } + + Materialize(); + EnsureWritable(); + var snapshot = style.GetSnapshot(); + var removed = false; + + for (var i = 0; i < _declarations.Count; i++) + { + var declaration = _declarations[i]; + + if (declaration.IsInherited && !IsExplicitInitial(declaration) && snapshot.Find(declaration.Name) is not null) + { + _declarations[i] = null; + (_forced ??= new HashSet(StringComparer.Ordinal)).Add(declaration.Name); + removed = true; + } + } + + if (removed) + { + _declarations.RemoveAll(m => m is null); + RebuildIndex(); + } + + _inherited = snapshot; + } + + /// + /// Makes this declaration inherit the same declarations as the given one, + /// except the local ones (see GetLocalDeclarations), which need to be added. + /// + internal void InheritLike(CssStyleDeclaration style) + { + if (style._inherited is not null) + { + Materialize(); + EnsureWritable(); + _inherited = style._inherited; + } + } + // IsInherited also holds for an explicit `initial` on an inherited property, // which must still resolve to the initial value rather than the parent's. private static Boolean IsExplicitInitial(ICssProperty property) => @@ -359,6 +503,157 @@ private static Boolean IsExplicitInitial(ICssProperty property) => #endregion + #region Inheritance + + private Snapshot GetSnapshot() => _snapshot ??= new Snapshot(_declarations, _declarationIndex, _inherited, _forced); + + /// + /// Copies the lists if they are shared with a snapshot, such that the snapshot + /// (i.e., what children inherited) is not changed by modifications. + /// + private void EnsureWritable() + { + if (_snapshot is not null) + { + _declarations = new List(_declarations); + _declarationIndex = new Dictionary(_declarationIndex, StringComparer.Ordinal); + _forced = _forced is null ? null : new HashSet(_forced, StringComparer.Ordinal); + _snapshot = null; + } + } + + /// + /// Merges the inherited declarations into the own ones, which yields the same + /// list as UpdateDeclarations with the declarations of the parent would have. + /// + private void Materialize() + { + if (_inherited is not null) + { + var declarations = new List(_declarations); + var index = new Dictionary(_declarationIndex, StringComparer.Ordinal); + Snapshot.Merge(declarations, index, _inherited.GetDeclarations(), _forced); + _declarations = declarations; + _declarationIndex = index; + _inherited = null; + _forced = null; + _snapshot = null; + } + } + + /// + /// An immutable view of the declarations of a parent. + /// + private sealed class Snapshot + { + private readonly List _declarations; + private readonly Dictionary _index; + private readonly Snapshot _parent; + private readonly HashSet _forced; + private List _merged; + + public Snapshot(List declarations, Dictionary index, Snapshot parent, HashSet forced) + { + _declarations = declarations; + _index = index; + _parent = parent; + _forced = forced; + } + + /// + /// Finds the declaration with the given name, including the inherited ones. + /// + public ICssProperty Find(String name) => FindOwn(name) ?? (_parent is not null ? Inherit(_parent, _forced, name) : null); + + /// + /// Gets the declaration that a child with the given forced names inherits + /// from the given parent. + /// + public static ICssProperty Inherit(Snapshot parent, HashSet forced, String name) + { + // a non-inheritable declaration is only taken if forced on every level + var allForced = forced is not null && forced.Contains(name); + + for (var current = parent; current is not null; current = current._parent) + { + var declaration = current.FindOwn(name); + + if (declaration is not null) + { + return allForced || declaration.CanBeInherited ? declaration : null; + } + + allForced = allForced && current._forced is not null && current._forced.Contains(name); + } + + return null; + } + + /// + /// Gets the full list of declarations, including the inherited ones. + /// + public List GetDeclarations() + { + if (_parent is null) + { + return _declarations; + } + + if (_merged is null) + { + // resolve top-down without recursion, as the chain is as deep as the DOM + var pending = new Stack(); + + for (var current = this; current._parent is not null && current._merged is null; current = current._parent) + { + pending.Push(current); + } + + while (pending.Count > 0) + { + var current = pending.Pop(); + var declarations = new List(current._declarations); + var index = new Dictionary(current._index, StringComparer.Ordinal); + Merge(declarations, index, current._parent.GetDeclarations(), current._forced); + current._merged = declarations; + } + } + + return _merged; + } + + public static void Merge(List declarations, Dictionary index, List inherited, HashSet forced) + { + foreach (var declaration in inherited) + { + var name = declaration.Name; + + if (!index.ContainsKey(name) && (declaration.CanBeInherited || (forced is not null && forced.Contains(name)))) + { + index[name] = declarations.Count; + declarations.Add(declaration); + } + } + } + + private ICssProperty FindOwn(String name) + { + if (_index.TryGetValue(name, out var i) && i < _declarations.Count) + { + var declaration = _declarations[i]; + + if (declaration is not null && declaration.Name.Is(name)) + { + return declaration; + } + } + + return null; + } + } + + #endregion + #region Helpers private ICssProperty GetPropertyShorthand(String name) => @@ -394,7 +689,7 @@ private ICssProperty FindUnserializedLonghand(String name, IEnumerable s } } - return null; + return _inherited is not null ? Snapshot.Inherit(_inherited, _forced, name) : null; } private void SetProperty(ICssProperty property) @@ -414,6 +709,8 @@ private void RemovePropertyByName(String propertyName) propertyName = propertyName.StartsWith("--", StringComparison.Ordinal) ? propertyName : propertyName.ToLowerFast(); var info = _context.GetDeclarationInfo(propertyName); var longhands = info.Longhands; + Materialize(); + EnsureWritable(); if (_declarationIndex.TryGetValue(propertyName, out var index) && index < _declarations.Count && _declarations[index].Name.Is(propertyName)) @@ -449,18 +746,27 @@ private void ChangeDeclarations(IEnumerable decls, Predicate(); + Materialize(); + EnsureWritable(); + + // Replaced declarations are moved to the end. Instead of removing them one by one + // (shifting the list and invalidating the index each time) they are cleared and + // the list is compacted once, which keeps merging linear in the number of entries. + var count = _declarations.Count; + var removed = false; foreach (var newdecl in decls) { var skip = defaultSkip(newdecl); + var idx = IndexOf(newdecl.Name, count); - if (_declarationIndex.TryGetValue(newdecl.Name, out var idx) && idx < _declarations.Count && - _declarations[idx].Name.Is(newdecl.Name)) + if (idx >= 0) { if (removeExisting.Invoke(_declarations[idx], newdecl)) { - _declarations.RemoveAt(idx); + _declarations[idx] = null; + _declarationIndex.Remove(newdecl.Name); + removed = true; skip = false; } else @@ -468,42 +774,50 @@ private void ChangeDeclarations(IEnumerable decls, Predicate m is null); + RebuildIndex(); + } + else + { + for (var i = count; i < _declarations.Count; i++) { - declarations.Add(newdecl); + _declarationIndex[_declarations[i].Name] = i; } } + } - _declarations.AddRange(declarations); + /// + /// Gets the index of the own declaration with the given name among the + /// first count entries, or -1 if there is none. + /// + private Int32 IndexOf(String name, Int32 count) + { + if (_declarationIndex.TryGetValue(name, out var index) && index < count) + { + var declaration = _declarations[index]; - RebuildIndex(); + if (declaration is not null && declaration.Name.Is(name)) + { + return index; + } + } + + return -1; } private void SetLonghand(ICssProperty property) { + EnsureWritable(); + if (_declarationIndex.TryGetValue(property.Name, out var index) && index < _declarations.Count) { var declaration = _declarations[index]; @@ -550,10 +864,10 @@ private ICssProperty GetPropertyFromShorthand(String name) foreach (var shorthandName in info.Shorthands) { - if (_declarationIndex.TryGetValue(shorthandName, out var index) && index < _declarations.Count) - { - var shorthand = _declarations[index]; + var shorthand = GetDeclaration(shorthandName); + if (shorthand is not null) + { var shorthandInfo = factory.Create(shorthandName); var rawValue = shorthand.RawValue ?? shorthandInfo.Converter.Convert(new StringSource(shorthand.Value)); diff --git a/src/AngleSharp.Css/Dom/Internal/LogicalProperties.cs b/src/AngleSharp.Css/Dom/Internal/LogicalProperties.cs index b150023d..6991c0a0 100644 --- a/src/AngleSharp.Css/Dom/Internal/LogicalProperties.cs +++ b/src/AngleSharp.Css/Dom/Internal/LogicalProperties.cs @@ -34,7 +34,8 @@ public static void Apply(CssStyleDeclaration declarations, ICssComputeContext co List? logical = null; var order = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var property in declarations.Declarations) + // logical properties are not inherited, so only the local declarations matter + foreach (var property in declarations.GetLocalDeclarations()) { order[property.Name] = order.Count; diff --git a/src/AngleSharp.Css/Extensions/CssOmExtensions.cs b/src/AngleSharp.Css/Extensions/CssOmExtensions.cs index f967fbf1..10c1f8c5 100644 --- a/src/AngleSharp.Css/Extensions/CssOmExtensions.cs +++ b/src/AngleSharp.Css/Extensions/CssOmExtensions.cs @@ -90,13 +90,18 @@ public static ICssValue GetValueOf(this ICssStyleRule rule, String propertyName) public static ICssStyleDeclaration Compute(this ICssStyleDeclaration style, ICssComputeContext context) { var computedStyle = new CssStyleDeclaration(context.Context); + var cascaded = style as CssStyleDeclaration; if (context is IFontComputeContext fonts) { computedStyle.RootFontSize = fonts.RootFontSize; } - foreach (var property in style) + // The declarations inherited from the parent's computed style are already computed, + // hence only the local ones are computed - the inherited ones are shared. + var local = cascaded?.GetLocalDeclarations() ?? style; + + foreach (var property in local) { var computed = property.Compute(context); @@ -120,7 +125,7 @@ public static ICssStyleDeclaration Compute(this ICssStyleDeclaration style, ICss } var factory = context.Context.GetDeclarationFactory(); - var preservedShorthands = style + var preservedShorthands = local .Where(property => (factory.Create(property.Name).Flags & PropertyFlags.PreserveShorthand) != 0) .ToArray(); @@ -134,17 +139,24 @@ public static ICssStyleDeclaration Compute(this ICssStyleDeclaration style, ICss { for (var i = 0; i < info.Longhands.Length; i++) { - if (style.Any(property => property.Name.Is(info.Longhands[i]))) + var name = info.Longhands[i]; + + if (cascaded is not null ? cascaded.GetDeclaration(name) is not null : style.Any(property => property.Name.Is(name))) { continue; } - var longhand = factory.Create(info.Longhands[i]); - computedStyle.AddProperty(new CssProperty(info.Longhands[i], longhand.Converter, longhand.Flags, values[i], shorthand.IsImportant)); + var longhand = factory.Create(name); + computedStyle.AddProperty(new CssProperty(name, longhand.Converter, longhand.Flags, values[i], shorthand.IsImportant)); } } } + if (cascaded is not null) + { + computedStyle.InheritLike(cascaded); + } + return computedStyle; } @@ -159,7 +171,7 @@ internal static CssStyleDeclaration PrepareComputedDeclarations(this ICssStyleDe declarations.AddProperty(property.Name.StartsWith("--", StringComparison.Ordinal) ? property.Compute(context) : property); } - declarations.UpdateDeclarations(parent); + declarations.InheritFrom(parent); LogicalProperties.Apply(declarations, context); return declarations; } diff --git a/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs b/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs index 1cc17c52..ffd08472 100644 --- a/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs +++ b/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs @@ -259,7 +259,7 @@ private static void ApplyImportantLayerOrder(CssStyleDeclaration computedStyle, foreach (var match in matches) { - if (!match.IsDefault && match.Rule.Style.Any(m => m.IsImportant)) + if (!match.IsDefault && HasImportant(match.Rule.Style)) { important ??= new List(); layered = layered || (important.Count > 0 && important[0].LayerRank != match.LayerRank); @@ -278,6 +278,10 @@ private static void ApplyImportantLayerOrder(CssStyleDeclaration computedStyle, } } + private static Boolean HasImportant(ICssStyleDeclaration style) => style is CssStyleDeclaration declaration ? + declaration.HasImportantDeclarations() : + style.Any(m => m.IsImportant); + private static List MatchHost(StyleCollection shadowStyles, IElement host) { var rules = shadowStyles.GetRules(); diff --git a/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs b/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs index 678d13ce..d4e7e550 100644 --- a/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs +++ b/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs @@ -96,7 +96,7 @@ private ElementRenderNode RenderElement( if (parentSpecifiedStyle is not null) { - specifiedStyle.UpdateDeclarations(parentSpecifiedStyle); + specifiedStyle.InheritFrom(parentSpecifiedStyle); } var computeContext = new CssComputeContext(collection.Device, _context, explicitStyle, parentComputedStyle); diff --git a/src/AngleSharp.Css/Values/CssComputeContext.cs b/src/AngleSharp.Css/Values/CssComputeContext.cs index 8521f534..fc802baf 100644 --- a/src/AngleSharp.Css/Values/CssComputeContext.cs +++ b/src/AngleSharp.Css/Values/CssComputeContext.cs @@ -31,7 +31,9 @@ public CssComputeContext(IRenderDevice device, IBrowsingContext? context, ICssPr public ICssValue? Resolve(String name) => _variables.Resolve(name); - public ICssValue? InheritedValue(String name) => _parent?.FirstOrDefault(m => m.Name == name)?.RawValue; + public ICssValue? InheritedValue(String name) => _parent is CssStyleDeclaration parent + ? parent.GetDeclaration(name)?.RawValue + : _parent?.FirstOrDefault(m => m.Name == name)?.RawValue; public Double ParentFontSize => ToPixels(InheritedValue(PropertyNames.FontSize)) ?? DefaultFontSize; @@ -42,7 +44,9 @@ public Double FontSize if (!_fontSize.HasValue) { // The font size of the element is computed against the font size of the parent. - var declared = Properties.FirstOrDefault(m => m.Name == PropertyNames.FontSize); + var declared = Properties is CssStyleDeclaration properties + ? properties.GetDeclaration(PropertyNames.FontSize) + : Properties.FirstOrDefault(m => m.Name == PropertyNames.FontSize); var computed = declared?.Compute(this).RawValue; _fontSize = computed is CssInitialValue ? DefaultFontSize : ToPixels(computed) ?? ParentFontSize; } diff --git a/src/AngleSharp.Css/Values/CssCustomPropertyResolver.cs b/src/AngleSharp.Css/Values/CssCustomPropertyResolver.cs index 926b4eb3..a9b01a10 100644 --- a/src/AngleSharp.Css/Values/CssCustomPropertyResolver.cs +++ b/src/AngleSharp.Css/Values/CssCustomPropertyResolver.cs @@ -8,10 +8,15 @@ namespace AngleSharp.Css.Values sealed class CssCustomPropertyResolver { private readonly Dictionary _values = new(StringComparer.Ordinal); + private readonly CssStyleDeclaration? _parent; public CssCustomPropertyResolver(IEnumerable properties, ICssProperties? parent = null) { - if (parent is not null) + // the custom properties of the parent are looked up on demand instead of copied, + // as there may be thousands of them (e.g., design tokens on :root) + _parent = parent as CssStyleDeclaration; + + if (parent is not null && _parent is null) { foreach (var property in parent) { @@ -158,7 +163,21 @@ void Enter(Node node) } } - public ICssValue? Resolve(String name) => _values.TryGetValue(name, out var value) ? value : null; + public ICssValue? Resolve(String name) + { + if (_values.TryGetValue(name, out var value)) + { + return value; + } + + if (_parent is not null && name.StartsWith("--", StringComparison.Ordinal)) + { + var inherited = _parent.GetDeclaration(name)?.RawValue; + return inherited is CssInvalidValue ? null : inherited; + } + + return null; + } private sealed class Node { diff --git a/src/Directory.Build.props b/src/Directory.Build.props index fcf0c563..b105dbaf 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.2 + 1.3.0 enable latest true From b0a64dbf806a5b102408733d2ecb4a62926bf519 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sun, 25 Oct 2026 19:33:49 +0100 Subject: [PATCH 2/6] Improved resource resolution --- CHANGELOG.md | 2 + .../Extensions/Nesting.cs | 15 ++++ .../Styling/HtmlCssIntegration.cs | 42 +++++++++ src/AngleSharp.Css/Parser/CssBuilder.cs | 85 ++++++++++++++++++- src/AngleSharp.Css/Parser/CssParserOptions.cs | 13 +++ 5 files changed, 155 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f665481b..12617a3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Released on Monday, October 26 2026. - Improved the render tree to inherit declarations from the parent's style instead of copying them to every element (e.g. thousands of custom properties on `:root`) - Improved merging declarations in the cascade, which was quadratic in the number of declarations +- Fixed a declaration starting with `*` (the IE7 hack `*zoom: 1`) being taken for a nested rule that swallowed the following declarations or the rest of the style sheet; it is dropped up to its semicolon +- Added `CssParserOptions.IsNotResolvingRelativeUrls` to prevent resolving the relative URLs in linked and imported style sheets # 1.2.2 diff --git a/src/AngleSharp.Css.Tests/Extensions/Nesting.cs b/src/AngleSharp.Css.Tests/Extensions/Nesting.cs index 13abb74b..6d3119e2 100644 --- a/src/AngleSharp.Css.Tests/Extensions/Nesting.cs +++ b/src/AngleSharp.Css.Tests/Extensions/Nesting.cs @@ -135,5 +135,20 @@ public void ReversedSelectorNesting() Assert.AreEqual("rgba(0, 128, 0, 1)", style.GetColor()); } + + // the IE hacks of old style sheets, found on www.florian-rappl.de: a declaration starting + // with `*` looks like a nested rule, whose prelude ends at `;` or `}` and is dropped, + // instead of taking the rest of the style sheet with it + [TestCase("a{color:red;*zoom:1}b{color:blue}", "a { color: rgba(255, 0, 0, 1) }", "b { color: rgba(0, 0, 255, 1) }")] + [TestCase("a{*zoom:1;color:red}b{color:blue}", "a { color: rgba(255, 0, 0, 1) }", "b { color: rgba(0, 0, 255, 1) }")] + [TestCase("a{*display:inline;*zoom:1;color:red;}b{color:blue}", "a { color: rgba(255, 0, 0, 1) }", "b { color: rgba(0, 0, 255, 1) }")] + public void InvalidNestedRuleIsDroppedUpToItsSemicolon(string css, string first, string second) + { + var sheet = ParseStyleSheet(css); + + Assert.AreEqual(2, sheet.Rules.Length); + Assert.AreEqual(first, sheet.Rules[0].CssText); + Assert.AreEqual(second, sheet.Rules[1].CssText); + } } } diff --git a/src/AngleSharp.Css.Tests/Styling/HtmlCssIntegration.cs b/src/AngleSharp.Css.Tests/Styling/HtmlCssIntegration.cs index 8e83a77e..664f4b31 100644 --- a/src/AngleSharp.Css.Tests/Styling/HtmlCssIntegration.cs +++ b/src/AngleSharp.Css.Tests/Styling/HtmlCssIntegration.cs @@ -523,5 +523,47 @@ public void MediaRuleIsNotCalculatedIfScreenIsNotWideEnough() var style = document.Body.ComputeCurrentStyle(); Assert.AreEqual("rgba(255, 0, 0, 1)", style.GetColor()); } + + [Test] + public async Task RelativeUrlsOfLinkedStyleSheetsResolveAgainstTheirAddress() + { + // found on www.florian-rappl.de: /Content/style refers to img/title.png in /Content + var files = new Dictionary + { + { "index.html", "

" }, + { "Content/style", "#t { background-image: url(img/title.png), url('/abs.png'), url(data:image/png;base64,AA==) } @font-face { font-family: X; src: url(\"fonts/x.ttf\") }" }, + }; + var config = Configuration.Default + .With(new TestServerRequester(files)) + .WithDefaultLoader(new LoaderOptions { IsResourceLoadingEnabled = true }) + .WithCss(); + var document = await BrowsingContext.New(config).OpenAsync("http://localhost/index.html"); + await Task.Delay(100); + var linked = document.QuerySelector("link").Sheet as ICssStyleSheet; + var inline = document.QuerySelector("style").Sheet as ICssStyleSheet; + + Assert.AreEqual("url(\"http://localhost/Content/img/title.png\"), url(\"http://localhost/abs.png\"), url(\"data:image/png;base64,AA==\")", ((ICssStyleRule)linked.Rules[0]).Style.GetPropertyValue("background-image")); + Assert.AreEqual("url(\"http://localhost/Content/fonts/x.ttf\")", ((ICssFontFaceRule)linked.Rules[1]).Source); + Assert.AreEqual("url(\"img/inline.png\")", ((ICssStyleRule)inline.Rules[0]).Style.GetPropertyValue("background-image")); + } + + [Test] + public async Task RelativeUrlsOfLinkedStyleSheetsStayAsWrittenWhenNotResolving() + { + var files = new Dictionary + { + { "index.html", "" }, + { "Content/style", "#t { background-image: url(img/title.png) }" }, + }; + var config = Configuration.Default + .With(new TestServerRequester(files)) + .WithDefaultLoader(new LoaderOptions { IsResourceLoadingEnabled = true }) + .WithCss(new CssParserOptions { IsNotResolvingRelativeUrls = true }); + var document = await BrowsingContext.New(config).OpenAsync("http://localhost/index.html"); + await Task.Delay(100); + var linked = document.QuerySelector("link").Sheet as ICssStyleSheet; + + Assert.AreEqual("url(\"img/title.png\")", ((ICssStyleRule)linked.Rules[0]).Style.GetPropertyValue("background-image")); + } } } diff --git a/src/AngleSharp.Css/Parser/CssBuilder.cs b/src/AngleSharp.Css/Parser/CssBuilder.cs index a798a690..4336d2ce 100644 --- a/src/AngleSharp.Css/Parser/CssBuilder.cs +++ b/src/AngleSharp.Css/Parser/CssBuilder.cs @@ -3,9 +3,12 @@ namespace AngleSharp.Css.Parser { using AngleSharp.Css.Dom; using AngleSharp.Css.Parser.Tokens; + using AngleSharp.Dom; + using AngleSharp.Html.Dom; using AngleSharp.Text; using System; using System.Collections.Generic; + using System.Text.RegularExpressions; /// /// See http://dev.w3.org/csswg/css-syntax/#parsing for details. @@ -536,6 +539,35 @@ public CssStyleRule CreateStyle(CssStyleRule rule, CssToken current) return rule; } + /// + /// Creates a style rule nested in the declarations of another one. Its prelude ends at a + /// semicolon or at the end of the enclosing block (CSS Syntax 3, consume a qualified rule + /// with nested set): the rule is then dropped, and the declarations continue after it, + /// e.g. after the IE hack *zoom: 1; - before, the rest of the style sheet was lost. + /// + private CssStyleRule CreateNestedStyle(CssStyleRule rule, ref CssToken current) + { + CollectTrivia(rule.Owner, ref current); + var position = current.Position; + var selectorText = GetArgument(ref current); + + if (current.Type != CssTokenType.CurlyBracketOpen) + { + RaiseErrorOccurred(CssParseError.InvalidToken, position); + return null; + } + + rule.SelectorText = selectorText; + + if (rule.Selector is null && _options.IsToleratingInvalidSelectors) + { + rule.SetInvalidSelector(selectorText); + } + + FillDeclarations(rule.Owner, rule.Style, NextToken()); + return rule; + } + public CssKeyframeRule CreateKeyframeRule(CssKeyframeRule rule, CssToken current) { CollectTrivia(rule.Owner, ref current); @@ -741,7 +773,7 @@ public void CreateDeclarationWith(ICssStyleSheet owner, ICssProperties propertie rule.IsNested = true; return new ReferencedNestedSelector(style.Selector); }); - var result = CreateStyle(rule, token); + var result = CreateNestedStyle(rule, ref token); factory.Unregister("&"); if (previous is not null) @@ -753,8 +785,13 @@ public void CreateDeclarationWith(ICssStyleSheet owner, ICssProperties propertie { style.Add(result); token = NextToken(); - return; } + else if (token.Type == CssTokenType.Semicolon) + { + token = NextToken(); + } + + return; } if (token.IsNot(CssTokenType.EndOfFile, CssTokenType.CurlyBracketClose, CssTokenType.Colon) && @@ -791,6 +828,11 @@ public void CreateDeclarationWith(ICssStyleSheet owner, ICssProperties propertie } else { + if (!_options.IsNotResolvingRelativeUrls) + { + value = ResolveRelativeUrls(owner, value); + } + properties.SetProperty(name, value, important ? CssKeywords.Important : null); } } @@ -948,6 +990,45 @@ private String CreateValue(ref CssToken token, out Boolean important) return value.Substring(0, end); } + /// + /// Resolves the relative URLs of a declaration's value against the address of a linked + /// or imported style sheet; those of a style element refer to the document anyway. + /// + private static String ResolveRelativeUrls(ICssStyleSheet owner, String value) + { + if (value.IndexOf("url(", StringComparison.OrdinalIgnoreCase) < 0 || + String.IsNullOrEmpty(owner?.Href) || + owner.OwnerNode is IHtmlStyleElement) + { + return value; + } + + var baseUrl = new Url(owner.Href); + + if (baseUrl.IsInvalid) + { + return value; + } + + return UrlPattern.Replace(value, match => + { + var path = match.Groups["path"].Value; + + if (path.Length == 0 || path.StartsWith("data:", StringComparison.OrdinalIgnoreCase) || path.StartsWith("#", StringComparison.Ordinal)) + { + return match.Value; + } + + var url = new Url(baseUrl, path); + var quote = match.Groups["quote"].Value; + return url.IsInvalid ? match.Value : String.Concat("url(", quote, url.Href, quote, ")"); + }); + } + + private static readonly Regex UrlPattern = new Regex( + @"url\(\s*(?[""']?)(?[^""')]*)\k\s*\)", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private String GetArgument(ref CssToken token) { var argument = _tokenizer.ContentFrom(token.Position.Position); diff --git a/src/AngleSharp.Css/Parser/CssParserOptions.cs b/src/AngleSharp.Css/Parser/CssParserOptions.cs index a3432e85..713b2992 100644 --- a/src/AngleSharp.Css/Parser/CssParserOptions.cs +++ b/src/AngleSharp.Css/Parser/CssParserOptions.cs @@ -49,5 +49,18 @@ public Boolean IsExcludingNesting get; set; } + + /// + /// Gets or sets if relative URLs in the declarations of a style + /// sheet with an address of its own (linked or imported) should + /// not be resolved against that address, as browsers do. Otherwise + /// they stay as written and resolve against the document. Enabled + /// by default. + /// + public Boolean IsNotResolvingRelativeUrls + { + get; + set; + } } } \ No newline at end of file From a0928ea5e9478b71f981326199f086fe064a47c2 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sun, 25 Oct 2026 21:52:48 +0100 Subject: [PATCH 3/6] Perf improvements --- CHANGELOG.md | 1 + .../Styling/PseudoClassForcing.cs | 7 +- .../Styling/StyleRuleIndexTests.cs | 18 +++ .../Dom/Internal/StyleRuleIndex.cs | 150 +++++++++++------- .../Extensions/StyleCollectionExtensions.cs | 10 +- 5 files changed, 125 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12617a3e..074bbe82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Released on Monday, October 26 2026. - Improved the render tree to inherit declarations from the parent's style instead of copying them to every element (e.g. thousands of custom properties on `:root`) - Improved merging declarations in the cascade, which was quadratic in the number of declarations +- Improved matching `::before` and `::after` by grouping the rules targeting pseudo-elements by the id, class, or tag name of the element they belong to, instead of matching all of them against every element - Fixed a declaration starting with `*` (the IE7 hack `*zoom: 1`) being taken for a nested rule that swallowed the following declarations or the rest of the style sheet; it is dropped up to its semicolon - Added `CssParserOptions.IsNotResolvingRelativeUrls` to prevent resolving the relative URLs in linked and imported style sheets diff --git a/src/AngleSharp.Css.Tests/Styling/PseudoClassForcing.cs b/src/AngleSharp.Css.Tests/Styling/PseudoClassForcing.cs index 0aa2d58e..11ba0e67 100644 --- a/src/AngleSharp.Css.Tests/Styling/PseudoClassForcing.cs +++ b/src/AngleSharp.Css.Tests/Styling/PseudoClassForcing.cs @@ -140,16 +140,15 @@ public void SettingFocusOnAnElementMakesFocusWithinMatchOnItsAncestorsNaturally( [Test] public void RemovingFocusAttemptsToBlurTheElement() { - // AngleSharp core does not currently implement DoBlur() for any element (it is a - // no-op everywhere), so this only calls into that hook - it cannot itself force focus - // to be cleared. Documented here since it is a separate, existing core limitation. + // removing the forced focus calls DoBlur(), which clears the focus since AngleSharp + // 1.8.6 (a no-op before), so :focus then follows the real focus state var document = ParseDocument(""); var target = document.GetElementById("target") as IHtmlElement; target.SetPseudoClass("focus"); target.RemovePseudoClass("focus"); - Assert.IsTrue(target.Matches(":focus"), "DoBlur() is a no-op in AngleSharp core today, so focus is not actually cleared."); + Assert.AreEqual(target.IsFocused, target.Matches(":focus")); } } } diff --git a/src/AngleSharp.Css.Tests/Styling/StyleRuleIndexTests.cs b/src/AngleSharp.Css.Tests/Styling/StyleRuleIndexTests.cs index 5186e7e6..678c7d6e 100644 --- a/src/AngleSharp.Css.Tests/Styling/StyleRuleIndexTests.cs +++ b/src/AngleSharp.Css.Tests/Styling/StyleRuleIndexTests.cs @@ -93,6 +93,24 @@ public void PseudoElementsAreOnlyGeneratedWithContent() Assert.AreEqual(0, c.Length); } + [TestCase("#t::before { content: 'x' }", "

A

", true)] + [TestCase(".a::after { content: 'x' }", "

A

", true)] + [TestCase(".a::after { content: 'x' }", "

A

", false)] + [TestCase("p::before { content: 'x' }", "

A

", true)] + [TestCase("p::before { content: 'x' }", "
A
", false)] + [TestCase(".a:before { content: 'x' }", "

A

", true)] + [TestCase(".a .b::after { content: 'x' }", "

A

", true)] + [TestCase("::before { content: 'x' }", "

A

", true)] + [TestCase(".x::before, #t::after { content: 'x' }", "

A

", true)] + public void PseudoElementRulesAreFoundViaTheirElement(string css, string body, bool generated) + { + var document = CssConstructionFunctions.ParseDocument($"{body}"); + var root = (ElementRenderNode)document.DefaultView.Render(new PlainRenderDevice()); + var target = Flatten(root).First(m => m.Ref.Id == "t"); + + Assert.AreEqual(generated, target.Children.OfType().Any(m => m.Ref is IPseudoElement)); + } + private static System.Collections.Generic.IEnumerable Flatten(ElementRenderNode node) => new[] { node }.Concat(node.Children.OfType().SelectMany(Flatten)); } diff --git a/src/AngleSharp.Css/Dom/Internal/StyleRuleIndex.cs b/src/AngleSharp.Css/Dom/Internal/StyleRuleIndex.cs index c2bd0abb..9aeb749f 100644 --- a/src/AngleSharp.Css/Dom/Internal/StyleRuleIndex.cs +++ b/src/AngleSharp.Css/Dom/Internal/StyleRuleIndex.cs @@ -17,12 +17,9 @@ 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 KeyedRules _keys = new(); + // the rules targeting pseudo-elements, again grouped by the element they belong to + private readonly KeyedRules _pseudoKeys = new(); private readonly List _pseudo = new(); #endregion @@ -49,91 +46,134 @@ public IReadOnlyList GetCandidates(IElement element) { if (element is IPseudoElement) { - return _pseudo; + // a pseudo-element is matched by the rules for the element it belongs to + return element is PseudoElement { Host: { } host } + ? Collect(host, _pseudoKeys) + : _pseudo; } - var candidates = new List(_ungrouped); + return Collect(element, _keys); + } + + #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); + _pseudoKeys.Add(keys, index); + } + + _keys.Add(keys, index); + } + + private static IReadOnlyList Collect(IElement element, KeyedRules keyed) + { + List? candidates = null; var id = element.Id; - if (!String.IsNullOrEmpty(id) && _ids.TryGetValue(id!, out var rules)) + if (!String.IsNullOrEmpty(id) && keyed.Ids.TryGetValue(id!, out var rules)) { - candidates.AddRange(rules); + AddGroup(ref candidates, keyed.Ungrouped, rules); } foreach (var name in element.ClassList) { - if (_classes.TryGetValue(name, out rules)) + if (keyed.Classes.TryGetValue(name, out rules)) { - candidates.AddRange(rules); + AddGroup(ref candidates, keyed.Ungrouped, rules); } } - if (_tags.TryGetValue(element.LocalName, out rules)) + if (keyed.Tags.TryGetValue(element.LocalName, out rules)) { - candidates.AddRange(rules); + AddGroup(ref candidates, keyed.Ungrouped, rules); } - if (candidates.Count > _ungrouped.Count) + // without a group the ungrouped rules are all there is, which need no copy + if (candidates is null) { - // a rule may be found via several keys, e.g., ".a, .b" for class="a b" - candidates.Sort(); - var count = 0; + return keyed.Ungrouped; + } + + // 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++) + for (var i = 0; i < candidates.Count; i++) + { + if (count == 0 || candidates[count - 1] != candidates[i]) { - if (count == 0 || candidates[count - 1] != candidates[i]) - { - candidates[count++] = candidates[i]; - } + candidates[count++] = candidates[i]; } - - candidates.RemoveRange(count, candidates.Count - count); } + candidates.RemoveRange(count, candidates.Count - count); return candidates; } - #endregion + private static void AddGroup(ref List? candidates, List ungrouped, List rules) + { + if (candidates is null) + { + candidates = new List(ungrouped.Count + rules.Count); + candidates.AddRange(ungrouped); + } - #region Helpers + candidates.AddRange(rules); + } - private void Add(ICssStyleRule rule, Int32 index) + /// + /// Rules grouped by the id, class, or tag name their subject requires. + /// + private sealed class KeyedRules { - // 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; + // ids and classes match case-insensitively in quirks mode, tag names + // in HTML documents; a superset of candidates is fine + public Dictionary> Ids { get; } = new(StringComparer.OrdinalIgnoreCase); - if (hasNested || info.TargetsPseudo) - { - _pseudo.Add(index); - } + public Dictionary> Classes { get; } = new(StringComparer.OrdinalIgnoreCase); - if (keys is null) - { - _ungrouped.Add(index); - return; - } + public Dictionary> Tags { get; } = new(StringComparer.OrdinalIgnoreCase); - foreach (var key in keys) - { - var map = key.Kind switch - { - SubjectKeyKind.Id => _ids, - SubjectKeyKind.Class => _classes, - _ => _tags, - }; + public List Ungrouped { get; } = new(); - if (!map.TryGetValue(key.Name, out var rules)) + public void Add(SubjectKey[]? keys, Int32 index) + { + if (keys is null) { - rules = new List(); - map.Add(key.Name, rules); + Ungrouped.Add(index); + return; } - // the same key may appear more than once, e.g., ".a:hover, .a:focus" - if (rules.Count == 0 || rules[rules.Count - 1] != index) + foreach (var key in keys) { - rules.Add(index); + 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); + } } } } diff --git a/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs b/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs index ffd08472..77833207 100644 --- a/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs +++ b/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs @@ -304,21 +304,27 @@ private static List SortBySpecificity(this IStyleCollection styles, I // Resolving the scope once is equivalent to letting every TryMatch call // fall back to it, but avoids re-reading DocumentElement per rule. var scope = (styles as StyleCollection)?.Scope ?? element.Owner?.DocumentElement; - var matches = new List(); + List matches; if (styles is StyleCollection collection) { var rules = collection.GetRules(); var defaultRuleCount = collection.GetDefaultRuleCount(); + var candidates = collection.GetIndex().GetCandidates(element); + // sized for the candidates, of which many match, instead of growing step by step + matches = new List(candidates.Count); // the candidates keep the order of the rules, as required by the cascade - foreach (var i in collection.GetIndex().GetCandidates(element)) + for (var j = 0; j < candidates.Count; j++) { + var i = candidates[j]; MapPriority(rules[i], element, scope, i < defaultRuleCount, collection.GetLayerRank(i), matches); } } else { + matches = new List(); + foreach (var rule in styles) { MapPriority(rule, element, scope, false, 0, matches); From 379c1701a4b25bf50ba7f712f263cf410044feef Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Wed, 28 Oct 2026 11:13:29 +0100 Subject: [PATCH 4/6] Added `font-variant-east-asian` --- CHANGELOG.md | 3 +- .../Declarations/CssModernValueSyntaxTests.cs | 5 +++ .../DroppedValidValuesComputedStyleTests.cs | 1 + src/AngleSharp.Css/Constants/CssKeywords.cs | 35 +++++++++++++++++++ src/AngleSharp.Css/Constants/InitialValues.cs | 1 + src/AngleSharp.Css/Constants/PropertyNames.cs | 5 +++ .../FontVariantEastAsianDeclaration.cs | 17 +++++++++ .../Factories/DefaultDeclarationFactory.cs | 7 ++++ src/AngleSharp.Css/ValueConverters.cs | 17 +++++++++ 9 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 src/AngleSharp.Css/Declarations/FontVariantEastAsianDeclaration.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 074bbe82..5070d8d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,13 @@ # 1.3.0 -Released on Monday, October 26 2026. +Released on Wednesday, October 28 2026. - Improved the render tree to inherit declarations from the parent's style instead of copying them to every element (e.g. thousands of custom properties on `:root`) - Improved merging declarations in the cascade, which was quadratic in the number of declarations - Improved matching `::before` and `::after` by grouping the rules targeting pseudo-elements by the id, class, or tag name of the element they belong to, instead of matching all of them against every element - Fixed a declaration starting with `*` (the IE7 hack `*zoom: 1`) being taken for a nested rule that swallowed the following declarations or the rest of the style sheet; it is dropped up to its semicolon - Added `CssParserOptions.IsNotResolvingRelativeUrls` to prevent resolving the relative URLs in linked and imported style sheets +- Added `font-variant-east-asian` (`normal`, `jis78`/`jis83`/`jis90`/`jis04`/`simplified`/`traditional`, `full-width`/`proportional-width`, `ruby`), which used to be dropped as an unknown property # 1.2.2 diff --git a/src/AngleSharp.Css.Tests/Declarations/CssModernValueSyntaxTests.cs b/src/AngleSharp.Css.Tests/Declarations/CssModernValueSyntaxTests.cs index bbb50f59..a43c56c6 100644 --- a/src/AngleSharp.Css.Tests/Declarations/CssModernValueSyntaxTests.cs +++ b/src/AngleSharp.Css.Tests/Declarations/CssModernValueSyntaxTests.cs @@ -83,6 +83,11 @@ private static void AssertValue(string declaration, string expected) [TestCase("font-variant-numeric: slashed-zero oldstyle-nums", "oldstyle-nums slashed-zero")] [TestCase("font-variant-numeric: lining-nums oldstyle-nums", null)] [TestCase("font-variant-ligatures: no-common-ligatures contextual", "no-common-ligatures contextual")] + [TestCase("font-variant-east-asian: ruby full-width jis04", "jis04 full-width ruby")] + [TestCase("font-variant-east-asian: simplified", "simplified")] + [TestCase("font-variant-east-asian: normal", "normal")] + [TestCase("font-variant-east-asian: jis78 traditional", null)] + [TestCase("font-variant-east-asian: full-width proportional-width", null)] [TestCase("text-indent: each-line 5% hanging", "5% hanging each-line")] [TestCase("text-indent: hanging", null)] [TestCase("text-overflow: clip ellipsis", "clip ellipsis")] diff --git a/src/AngleSharp.Css.Tests/Styling/DroppedValidValuesComputedStyleTests.cs b/src/AngleSharp.Css.Tests/Styling/DroppedValidValuesComputedStyleTests.cs index bc8ac5d2..73f16df6 100644 --- a/src/AngleSharp.Css.Tests/Styling/DroppedValidValuesComputedStyleTests.cs +++ b/src/AngleSharp.Css.Tests/Styling/DroppedValidValuesComputedStyleTests.cs @@ -146,6 +146,7 @@ public void PaintValuesAreKept(string property, string value, string expected) = [TestCase("font-style", "oblique 10deg", "oblique 10deg")] [TestCase("font-variant-numeric", "tabular-nums", "tabular-nums")] [TestCase("font-variant-ligatures", "none", "none")] + [TestCase("font-variant-east-asian", "proportional-width", "proportional-width")] [TestCase("overflow-wrap", "anywhere", "anywhere")] [TestCase("word-wrap", "anywhere", "anywhere")] [TestCase("white-space", "break-spaces", "break-spaces")] diff --git a/src/AngleSharp.Css/Constants/CssKeywords.cs b/src/AngleSharp.Css/Constants/CssKeywords.cs index 666dc22d..21270365 100644 --- a/src/AngleSharp.Css/Constants/CssKeywords.cs +++ b/src/AngleSharp.Css/Constants/CssKeywords.cs @@ -2281,6 +2281,41 @@ public static class CssKeywords ///
public static readonly String TabularNums = "tabular-nums"; + /// + /// The jis78 keyword. + /// + public static readonly String Jis78 = "jis78"; + + /// + /// The jis83 keyword. + /// + public static readonly String Jis83 = "jis83"; + + /// + /// The jis90 keyword. + /// + public static readonly String Jis90 = "jis90"; + + /// + /// The jis04 keyword. + /// + public static readonly String Jis04 = "jis04"; + + /// + /// The simplified keyword. + /// + public static readonly String Simplified = "simplified"; + + /// + /// The traditional keyword. + /// + public static readonly String Traditional = "traditional"; + + /// + /// The proportional-width keyword. + /// + public static readonly String ProportionalWidth = "proportional-width"; + /// /// The diagonal-fractions keyword. /// diff --git a/src/AngleSharp.Css/Constants/InitialValues.cs b/src/AngleSharp.Css/Constants/InitialValues.cs index d7349c8d..eaafedbc 100644 --- a/src/AngleSharp.Css/Constants/InitialValues.cs +++ b/src/AngleSharp.Css/Constants/InitialValues.cs @@ -224,6 +224,7 @@ static class InitialValues public static readonly ICssValue TransitionBehaviorDecl = new CssConstantValue(CssKeywords.Normal, CssKeywords.Normal); public static readonly ICssValue FontVariantNumericDecl = new CssConstantValue(CssKeywords.Normal, CssKeywords.Normal); public static readonly ICssValue FontVariantLigaturesDecl = new CssConstantValue(CssKeywords.Normal, CssKeywords.Normal); + public static readonly ICssValue FontVariantEastAsianDecl = new CssConstantValue(CssKeywords.Normal, CssKeywords.Normal); public static readonly ICssValue TransformOriginDecl = CssPoint2D.Center; public static readonly ICssValue RotateDecl = new CssConstantValue(CssKeywords.None, null); public static readonly ICssValue ScaleDecl = new CssConstantValue(CssKeywords.None, null); diff --git a/src/AngleSharp.Css/Constants/PropertyNames.cs b/src/AngleSharp.Css/Constants/PropertyNames.cs index 81dd0a19..8a4b1dcb 100644 --- a/src/AngleSharp.Css/Constants/PropertyNames.cs +++ b/src/AngleSharp.Css/Constants/PropertyNames.cs @@ -902,6 +902,11 @@ public static class PropertyNames /// public static readonly String FontVariantLigatures = "font-variant-ligatures"; + /// + /// The font-variant-east-asian declaration. + /// + public static readonly String FontVariantEastAsian = "font-variant-east-asian"; + /// /// The font-weight declaration. /// diff --git a/src/AngleSharp.Css/Declarations/FontVariantEastAsianDeclaration.cs b/src/AngleSharp.Css/Declarations/FontVariantEastAsianDeclaration.cs new file mode 100644 index 00000000..be28bcff --- /dev/null +++ b/src/AngleSharp.Css/Declarations/FontVariantEastAsianDeclaration.cs @@ -0,0 +1,17 @@ +namespace AngleSharp.Css.Declarations +{ + using AngleSharp.Css.Dom; + using System; + using static ValueConverters; + + static class FontVariantEastAsianDeclaration + { + public static String Name = PropertyNames.FontVariantEastAsian; + + public static IValueConverter Converter = FontVariantEastAsianConverter; + + public static ICssValue InitialValue = InitialValues.FontVariantEastAsianDecl; + + public static PropertyFlags Flags = PropertyFlags.Inherited; + } +} diff --git a/src/AngleSharp.Css/Factories/DefaultDeclarationFactory.cs b/src/AngleSharp.Css/Factories/DefaultDeclarationFactory.cs index 79136b76..a4a072f3 100644 --- a/src/AngleSharp.Css/Factories/DefaultDeclarationFactory.cs +++ b/src/AngleSharp.Css/Factories/DefaultDeclarationFactory.cs @@ -1097,6 +1097,13 @@ public class DefaultDeclarationFactory : IDeclarationFactory initialValue: FontVariantNumericDeclaration.InitialValue, flags: FontVariantNumericDeclaration.Flags) }, + { + FontVariantEastAsianDeclaration.Name, new DeclarationInfo( + name: FontVariantEastAsianDeclaration.Name, + converter: FontVariantEastAsianDeclaration.Converter, + initialValue: FontVariantEastAsianDeclaration.InitialValue, + flags: FontVariantEastAsianDeclaration.Flags) + }, { FontVariantLigaturesDeclaration.Name, new DeclarationInfo( name: FontVariantLigaturesDeclaration.Name, diff --git a/src/AngleSharp.Css/ValueConverters.cs b/src/AngleSharp.Css/ValueConverters.cs index 3ca7c35d..2eded201 100644 --- a/src/AngleSharp.Css/ValueConverters.cs +++ b/src/AngleSharp.Css/ValueConverters.cs @@ -1066,6 +1066,23 @@ static class ValueConverters Or(Assign(CssKeywords.HistoricalLigatures, CssKeywords.HistoricalLigatures), Assign(CssKeywords.NoHistoricalLigatures, CssKeywords.NoHistoricalLigatures)), Or(Assign(CssKeywords.Contextual, CssKeywords.Contextual), Assign(CssKeywords.NoContextual, CssKeywords.NoContextual)))); + /// + /// Represents a converter for the font-variant-east-asian property. + /// https://www.w3.org/TR/css-fonts-4/#font-variant-east-asian-prop + /// + public static readonly IValueConverter FontVariantEastAsianConverter = Or( + Assign(CssKeywords.Normal, CssKeywords.Normal), + WithAny( + Or( + Assign(CssKeywords.Jis78, CssKeywords.Jis78), + Assign(CssKeywords.Jis83, CssKeywords.Jis83), + Assign(CssKeywords.Jis90, CssKeywords.Jis90), + Assign(CssKeywords.Jis04, CssKeywords.Jis04), + Assign(CssKeywords.Simplified, CssKeywords.Simplified), + Assign(CssKeywords.Traditional, CssKeywords.Traditional)), + Or(Assign(CssKeywords.FullWidth, CssKeywords.FullWidth), Assign(CssKeywords.ProportionalWidth, CssKeywords.ProportionalWidth)), + Assign(CssKeywords.Ruby, CssKeywords.Ruby))); + /// /// Represents a converter for the transform-box property. /// https://www.w3.org/TR/css-transforms-1/#transform-box From d809d02825b0ea398c4db434521ceac1bfbbefc0 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Wed, 28 Oct 2026 14:08:19 +0100 Subject: [PATCH 5/6] Performance improvements --- CHANGELOG.md | 3 + .../Styling/InheritanceChainTests.cs | 40 +++ .../Styling/StyleRuleIndexTests.cs | 40 +++ .../Dom/Internal/CssStyleDeclaration.cs | 21 ++ .../Dom/Internal/StyleRuleIndex.cs | 231 +++++++++++++++--- .../Extensions/CssOmExtensions.cs | 18 +- .../Extensions/StyleCollectionExtensions.cs | 8 +- src/AngleSharp.Css/Values/CssChildValue.cs | 45 +++- .../Values/CssCustomPropertyResolver.cs | 1 + src/AngleSharp.Css/Values/Raws/CssAnyValue.cs | 8 + 10 files changed, 360 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5070d8d3..ec1afc9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ Released on Wednesday, October 28 2026. - Improved the render tree to inherit declarations from the parent's style instead of copying them to every element (e.g. thousands of custom properties on `:root`) - Improved merging declarations in the cascade, which was quadratic in the number of declarations +- Improved blockifying (e.g. `position: absolute` or `float`) to not merge all inherited declarations into the element's computed style +- Improved the cascade to also group rules by an attribute their subject requires (e.g. `[data-color-mode]`) and by the arguments of `:is()`, `:where()`, and `:matches()`, and to skip rules with an invalid selector +- Improved substituting variables by reusing the parsed shorthand (e.g. `border: var(--w) solid var(--c)`) and the tokens of custom properties across elements - Improved matching `::before` and `::after` by grouping the rules targeting pseudo-elements by the id, class, or tag name of the element they belong to, instead of matching all of them against every element - Fixed a declaration starting with `*` (the IE7 hack `*zoom: 1`) being taken for a nested rule that swallowed the following declarations or the rest of the style sheet; it is dropped up to its semicolon - Added `CssParserOptions.IsNotResolvingRelativeUrls` to prevent resolving the relative URLs in linked and imported style sheets diff --git a/src/AngleSharp.Css.Tests/Styling/InheritanceChainTests.cs b/src/AngleSharp.Css.Tests/Styling/InheritanceChainTests.cs index 29dbbedc..0fcdb8f4 100644 --- a/src/AngleSharp.Css.Tests/Styling/InheritanceChainTests.cs +++ b/src/AngleSharp.Css.Tests/Styling/InheritanceChainTests.cs @@ -89,6 +89,46 @@ public void MutatingAParentStyleDoesNotChangeItsChildren() Assert.AreEqual("rgba(255, 0, 0, 1)", child.SpecifiedStyle.GetPropertyValue("color")); } + [Test] + public void BlockifiedElementsKeepTheirInheritedDeclarations() + { + var document = Parse("" + + "
xyz
"); + var tree = document.DefaultView.Render(); + var abs = Find(tree, "abs").ComputedStyle; + var floating = Find(tree, "float").ComputedStyle; + + Assert.AreEqual("block", abs.GetPropertyValue("display")); + Assert.AreEqual("flex", floating.GetPropertyValue("display")); + Assert.AreEqual("", Find(tree, "inline").ComputedStyle.GetPropertyValue("display")); + Assert.AreEqual("1px", abs.GetPropertyValue("--a")); + Assert.AreEqual("rgba(255, 0, 0, 1)", abs.GetPropertyValue("color")); + Assert.AreEqual(1, abs.Count(m => m.Name == "display")); + Assert.AreEqual(1, floating.Count(m => m.Name == "display")); + } + + [Test] + public void ShorthandsWithVariablesAreSubstitutedPerElement() + { + var document = Parse("" + + "

x

" + + "

y

" + + "

z

"); + var tree = document.DefaultView.Render(); + + foreach (var (id, width, color) in new[] { ("first", "1px", "rgba(255, 0, 0, 1)"), ("second", "2px", "rgba(0, 0, 255, 1)"), ("third", "1px", "rgba(255, 0, 0, 1)") }) + { + var computed = Find(tree, id).ComputedStyle; + + foreach (var side in new[] { "top", "right", "bottom", "left" }) + { + Assert.AreEqual(width, computed.GetPropertyValue($"border-{side}-width"), id); + Assert.AreEqual("solid", computed.GetPropertyValue($"border-{side}-style"), id); + Assert.AreEqual(color, computed.GetPropertyValue($"border-{side}-color"), id); + } + } + } + [Test] public void MutatingAChildStyleDoesNotChangeItsParent() { diff --git a/src/AngleSharp.Css.Tests/Styling/StyleRuleIndexTests.cs b/src/AngleSharp.Css.Tests/Styling/StyleRuleIndexTests.cs index 678c7d6e..fcd55a33 100644 --- a/src/AngleSharp.Css.Tests/Styling/StyleRuleIndexTests.cs +++ b/src/AngleSharp.Css.Tests/Styling/StyleRuleIndexTests.cs @@ -56,6 +56,27 @@ public async Task GroupedAndUngroupedRulesKeepTheCascadeOrder(string css) [TestCase(".parent { .child { color: green } }", "

A

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

A

")] [TestCase("@media screen { .a { color: green } }", "

A

")] + [TestCase("[data-x] { color: green }", "

A

")] + [TestCase("[DATA-X] { color: green }", "

A

")] + [TestCase("[data-x=\"a\"] { color: green }", "

A

")] + [TestCase("[data-x~=\"a\"] { color: green }", "

A

")] + [TestCase("[data-x|=\"a\"] { color: green }", "

A

")] + [TestCase("[data-x^=\"a\"] { color: green }", "

A

")] + [TestCase("[data-x$=\"b\"] { color: green }", "

A

")] + [TestCase("[data-x*=\"b\"] { color: green }", "

A

")] + [TestCase("[data-x!=\"a\"] { color: green }", "

A

")] + [TestCase("[*|data-x] { color: green }", "

A

")] + [TestCase(".a [data-x]:hover, [data-y] { color: green }", "

A

")] + [TestCase(":is(.x .a, #t) { color: green }", "
A
")] + [TestCase(":is(.x, [data-y]) { color: green }", "
A
")] + [TestCase(":where(.x .a, div) { color: green }", "
A
")] + [TestCase(":matches(.x, .b) { color: green }", "
A
")] + [TestCase(":is(:is(.x, .b)) { color: green }", "
A
")] + [TestCase(":is(.x, *) { color: green }", "
A
")] + [TestCase(":is(.x, :not(.y)) { color: green }", "
A
")] + [TestCase(".parent { :is(&) { color: green } }", "

A

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

A

")] + [TestCase("! { color: red } .a { color: green }", "

A

")] public async Task RulesAreFoundViaTheirSubject(string css, string body) { var color = await ColorOfAsync(css, body); @@ -66,6 +87,8 @@ public async Task RulesAreFoundViaTheirSubject(string css, string body) [TestCase("#x.y { color: red }", "

A

")] [TestCase(".a span { color: red }", "
A
")] [TestCase(".a .b { color: red }", "

A

")] + [TestCase("[data-x] { color: red }", "

A

")] + [TestCase(":is(.x .a, .b) { color: red }", "

A

")] public async Task RulesForOtherSubjectsDoNotApply(string css, string body) { var color = await ColorOfAsync(css, body); @@ -73,6 +96,23 @@ public async Task RulesForOtherSubjectsDoNotApply(string css, string body) Assert.AreNotEqual("rgba(255, 0, 0, 1)", color); } + [TestCase("[data-x] { color: red }", 0)] + [TestCase("[data-y] { color: red }", 1)] + [TestCase(":is(.x, .y) { color: red }", 0)] + [TestCase(":is(.x, .a) { color: red }", 1)] + [TestCase(":where(.x .y, #z) { color: red }", 0)] + [TestCase(":is(.x, :not(.y)) { color: red }", 1)] + [TestCase("! { color: red }", 0)] + public void OnlyRulesThatMayMatchAreCandidates(string css, int count) + { + var document = CssConstructionFunctions.ParseDocument($"

A

"); + var sheets = document.StyleSheets.OfType().ToList(); + var collection = new StyleCollection(Enumerable.Empty(), sheets, new PlainRenderDevice()); + var candidates = collection.GetIndex().GetCandidates(document.GetElementById("t")); + + Assert.AreEqual(count, candidates.Count); + } + [Test] public void PseudoElementsAreOnlyGeneratedWithContent() { diff --git a/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs b/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs index 35399224..58f70c8b 100644 --- a/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs +++ b/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs @@ -422,6 +422,27 @@ internal ICssProperty GetDeclaration(String name) return _inherited is not null ? Snapshot.Inherit(_inherited, _forced, name) : null; } + /// + /// Sets the given declaration as an own one, replacing the own declaration with the + /// same name, if any. An inherited declaration with the same name is shadowed, hence + /// in contrast to RemoveProperty the inherited declarations do not need to be merged. + /// + internal void ReplaceDeclaration(ICssProperty declaration) + { + EnsureWritable(); + var index = IndexOf(declaration.Name, _declarations.Count); + + if (index >= 0) + { + _declarations[index] = declaration; + } + else + { + _declarationIndex[declaration.Name] = _declarations.Count; + _declarations.Add(declaration); + } + } + /// /// Gets the declarations that are not just inherited from the parent, i.e., the /// own ones and the ones that explicitly inherit (e.g., "color: inherit"). diff --git a/src/AngleSharp.Css/Dom/Internal/StyleRuleIndex.cs b/src/AngleSharp.Css/Dom/Internal/StyleRuleIndex.cs index 9aeb749f..b1c59514 100644 --- a/src/AngleSharp.Css/Dom/Internal/StyleRuleIndex.cs +++ b/src/AngleSharp.Css/Dom/Internal/StyleRuleIndex.cs @@ -1,15 +1,16 @@ namespace AngleSharp.Css.Dom { + using AngleSharp.Css.Parser; 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 + /// Groups the style rules of a collection by the id, class, tag name, or + /// attribute 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, tag name, and attributes, 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. /// @@ -42,14 +43,14 @@ public StyleRuleIndex(IReadOnlyList rules) /// Collects the (ascending) indices of the rules that may match the /// given element. /// - public IReadOnlyList GetCandidates(IElement element) + public Candidates GetCandidates(IElement element) { if (element is IPseudoElement) { // a pseudo-element is matched by the rules for the element it belongs to return element is PseudoElement { Host: { } host } ? Collect(host, _pseudoKeys) - : _pseudo; + : new Candidates(_pseudo, null); } return Collect(element, _keys); @@ -63,6 +64,13 @@ private void Add(ICssStyleRule rule, Int32 index) { // nested rules are matched independently of their parent's selector var hasNested = rule.Rules.Length > 0; + + // a rule without a (valid) selector matches nothing, but its nested rules may + if (!hasNested && rule.Selector is null && rule is CssStyleRule) + { + return; + } + var info = SelectorInfo.Get(rule); var keys = hasNested ? null : info.Keys; @@ -75,62 +83,150 @@ private void Add(ICssStyleRule rule, Int32 index) _keys.Add(keys, index); } - private static IReadOnlyList Collect(IElement element, KeyedRules keyed) + private static Candidates Collect(IElement element, KeyedRules keyed) { - List? candidates = null; + // The ungrouped rules (e.g., [data-theme] or :root) are candidates for every + // element, hence they are not copied, but merged with the grouped ones while + // iterating. Only the (few) groups of the element are combined, if needed. + List? first = null; + List? combined = null; var id = element.Id; if (!String.IsNullOrEmpty(id) && keyed.Ids.TryGetValue(id!, out var rules)) { - AddGroup(ref candidates, keyed.Ungrouped, rules); + AddGroup(ref first, ref combined, rules); } foreach (var name in element.ClassList) { if (keyed.Classes.TryGetValue(name, out rules)) { - AddGroup(ref candidates, keyed.Ungrouped, rules); + AddGroup(ref first, ref combined, rules); } } if (keyed.Tags.TryGetValue(element.LocalName, out rules)) { - AddGroup(ref candidates, keyed.Ungrouped, rules); + AddGroup(ref first, ref combined, rules); } - // without a group the ungrouped rules are all there is, which need no copy - if (candidates is null) + if (keyed.Attributes.Count > 0) { - return keyed.Ungrouped; + foreach (var attribute in element.Attributes) + { + if (keyed.Attributes.TryGetValue(attribute.Name, out rules)) + { + AddGroup(ref first, ref combined, rules); + } + } } - // 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 (combined is not null) { - if (count == 0 || candidates[count - 1] != candidates[i]) + // a rule may be found via several keys, e.g., ".a, .b" for class="a b" + combined.Sort(); + var count = 0; + + for (var i = 0; i < combined.Count; i++) { - candidates[count++] = candidates[i]; + if (count == 0 || combined[count - 1] != combined[i]) + { + combined[count++] = combined[i]; + } } + + combined.RemoveRange(count, combined.Count - count); + first = combined; } - candidates.RemoveRange(count, candidates.Count - count); - return candidates; + return new Candidates(keyed.Ungrouped, first); } - private static void AddGroup(ref List? candidates, List ungrouped, List rules) + private static void AddGroup(ref List? first, ref List? combined, List rules) { - if (candidates is null) + if (first is null) { - candidates = new List(ungrouped.Count + rules.Count); - candidates.AddRange(ungrouped); + // a single group is already sorted and free of duplicates + first = rules; + } + else + { + if (combined is null) + { + combined = new List(first.Count + rules.Count); + combined.AddRange(first); + } + + combined.AddRange(rules); } + } + + #endregion + + #region Candidates + + /// + /// The ascending indices of the candidates, i.e., the union of two ascending + /// lists without common entries: a rule is either grouped or not. + /// + public readonly struct Candidates + { + private readonly List _ungrouped; + private readonly List? _grouped; + + public Candidates(List ungrouped, List? grouped) + { + _ungrouped = ungrouped; + _grouped = grouped; + } + + public Int32 Count => _ungrouped.Count + (_grouped?.Count ?? 0); + + public Enumerator GetEnumerator() => new Enumerator(_ungrouped, _grouped); + + public struct Enumerator + { + private readonly List _ungrouped; + private readonly List? _grouped; + private Int32 _i; + private Int32 _j; + + public Enumerator(List ungrouped, List? grouped) + { + _ungrouped = ungrouped; + _grouped = grouped; + _i = 0; + _j = 0; + Current = -1; + } + + public Int32 Current { get; private set; } + + public Boolean MoveNext() + { + var hasUngrouped = _i < _ungrouped.Count; + var hasGrouped = _grouped is not null && _j < _grouped.Count; + + if (hasUngrouped && (!hasGrouped || _ungrouped[_i] < _grouped![_j])) + { + Current = _ungrouped[_i++]; + return true; + } + else if (hasGrouped) + { + Current = _grouped![_j++]; + return true; + } - candidates.AddRange(rules); + return false; + } + } } + #endregion + + #region Groups + /// /// Rules grouped by the id, class, or tag name their subject requires. /// @@ -144,6 +240,8 @@ private sealed class KeyedRules public Dictionary> Tags { get; } = new(StringComparer.OrdinalIgnoreCase); + public Dictionary> Attributes { get; } = new(StringComparer.OrdinalIgnoreCase); + public List Ungrouped { get; } = new(); public void Add(SubjectKey[]? keys, Int32 index) @@ -160,7 +258,8 @@ public void Add(SubjectKey[]? keys, Int32 index) { SubjectKeyKind.Id => Ids, SubjectKeyKind.Class => Classes, - _ => Tags, + SubjectKeyKind.Tag => Tags, + _ => Attributes, }; if (!map.TryGetValue(key.Name, out var rules)) @@ -189,9 +288,9 @@ public void Add(SubjectKey[]? keys, Int32 index) ///
internal sealed class SelectorInfo { - private SelectorInfo(ISelector? selector) + private SelectorInfo(ISelector? selector, ICssSelectorParser? parser) { - Keys = SubjectKeyVisitor.GetKeys(selector); + Keys = SubjectKeyVisitor.GetKeys(selector, parser); TargetsPseudo = selector is not null && PseudoElementSubjectVisitor.TargetsPseudoElement(selector); } @@ -203,11 +302,15 @@ public static SelectorInfo Get(ICssStyleRule rule) { if (rule is CssStyleRule styleRule) { - return styleRule.IndexInfo ??= new SelectorInfo(rule.Selector); + return styleRule.IndexInfo ??= Create(rule); } - return new SelectorInfo(rule.Selector); + return Create(rule); } + + // the parser of the rule is needed to look into :is() and the like + private static SelectorInfo Create(ICssStyleRule rule) => + new(rule.Selector, rule.Owner?.Context?.GetService()); } /// @@ -218,6 +321,7 @@ internal enum SubjectKeyKind Id, Class, Tag, + Attribute, } internal readonly struct SubjectKey @@ -243,15 +347,25 @@ public SubjectKey(SubjectKeyKind kind, String name) /// private sealed class SubjectKeyVisitor : ISelectorVisitor { + // the functional pseudo-classes matching if any of their arguments matches + private static readonly String[] MatchingFunctions = new[] + { + PseudoClassNames.Is, + PseudoClassNames.Where, + PseudoClassNames.Matches, + }; + private readonly ISelector _selector; + private readonly ICssSelectorParser? _parser; private SubjectKey[]? _keys; - private SubjectKeyVisitor(ISelector selector) + private SubjectKeyVisitor(ISelector selector, ICssSelectorParser? parser) { _selector = selector; + _parser = parser; } - public static SubjectKey[]? GetKeys(ISelector? selector) + public static SubjectKey[]? GetKeys(ISelector? selector, ICssSelectorParser? parser) { // a forced pseudo-class matches independent of its own selector if (selector is null || selector is ForcingPseudoClassSelector) @@ -259,7 +373,7 @@ private SubjectKeyVisitor(ISelector selector) return null; } - var visitor = new SubjectKeyVisitor(selector); + var visitor = new SubjectKeyVisitor(selector, parser); selector.Accept(visitor); return visitor._keys; } @@ -279,6 +393,12 @@ void ISelectorVisitor.Type(String name) void ISelectorVisitor.Attribute(String name, String op, String? value) { + // the name is serialized, i.e., a namespace (ns|name) or escapes would not match + // the name of the attribute; [name!=value] also matches without the attribute + if (RequiresAttribute(op) && name.IndexOf('|') == -1 && name.IndexOf('\\') == -1) + { + Set(SubjectKeyKind.Attribute, name); + } } void ISelectorVisitor.Child(String name, Int32 step, Int32 offset, ISelector selector) @@ -287,6 +407,21 @@ void ISelectorVisitor.Child(String name, Int32 step, Int32 offset, ISelector sel void ISelectorVisitor.PseudoClass(String name) { + // :is(.a .b, .c) only exposes its text, but an element matching it matches one + // of the arguments, i.e., it requires one of their keys - just as a list does + var arguments = GetArguments(name); + + if (arguments is not null && _parser is not null) + { + var selector = _parser.ParseSelector(arguments); + + // the arguments form a forgiving list, which may have dropped arguments that + // are valid only where the rule is (e.g., nested); then the keys are incomplete + if (selector is not null && selector.Text.Equals(arguments, StringComparison.Ordinal)) + { + _keys = GetKeys(selector, _parser); + } + } } void ISelectorVisitor.PseudoElement(String name) @@ -299,7 +434,7 @@ void ISelectorVisitor.List(IEnumerable selectors) foreach (var selector in selectors) { - var alternative = GetKeys(selector); + var alternative = GetKeys(selector, _parser); if (alternative is null) { @@ -314,7 +449,7 @@ void ISelectorVisitor.List(IEnumerable selectors) void ISelectorVisitor.Combinator(IEnumerable selectors, IEnumerable symbols) { - _keys = GetKeys(selectors.LastOrDefault()); + _keys = GetKeys(selectors.LastOrDefault(), _parser); } void ISelectorVisitor.Many(IEnumerable selectors) @@ -322,7 +457,7 @@ 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); + var keys = GetKeys(selector, _parser); if (keys is not null && (_keys is null || IsBetter(keys, _keys))) { @@ -331,6 +466,24 @@ void ISelectorVisitor.Many(IEnumerable selectors) } } + private static Boolean RequiresAttribute(String op) => + op.Length == 0 || op is "=" or "~=" or "|=" or "^=" or "$=" or "*="; + + private static String? GetArguments(String pseudoClass) + { + foreach (var function in MatchingFunctions) + { + if (pseudoClass.Length > function.Length + 2 && + pseudoClass.StartsWith(function, StringComparison.OrdinalIgnoreCase) && + pseudoClass[function.Length] == '(' && pseudoClass[pseudoClass.Length - 1] == ')') + { + return pseudoClass.Substring(function.Length + 1, pseudoClass.Length - function.Length - 2); + } + } + + return null; + } + private void Set(SubjectKeyKind kind, String name) { if (!String.IsNullOrEmpty(name)) diff --git a/src/AngleSharp.Css/Extensions/CssOmExtensions.cs b/src/AngleSharp.Css/Extensions/CssOmExtensions.cs index 10c1f8c5..136a2be3 100644 --- a/src/AngleSharp.Css/Extensions/CssOmExtensions.cs +++ b/src/AngleSharp.Css/Extensions/CssOmExtensions.cs @@ -187,19 +187,18 @@ internal static ICssStyleDeclaration Blockify(this ICssStyleDeclaration style, I { if (style is CssStyleDeclaration declarations && IsBlockified(declarations, element)) { - var current = declarations.GetProperty(PropertyNames.Display); + // display has neither shorthands nor is it inherited, i.e., there is nothing to + // derive: a missing declaration is the same as one without a value. + var current = declarations.GetDeclaration(PropertyNames.Display); var value = Blockify(current?.RawValue); if (value is not null) { var info = element.Owner.Context.GetDeclarationInfo(PropertyNames.Display); - if (current is not null) - { - declarations.RemoveProperty(current); - } - - declarations.AddProperty(new CssProperty(PropertyNames.Display, info.Converter, info.Flags, value, current?.IsImportant ?? false)); + // Replacing the own declaration avoids merging all the inherited declarations + // (e.g., thousands of custom properties on :root) into this style. + declarations.ReplaceDeclaration(new CssProperty(PropertyNames.Display, info.Converter, info.Flags, value, current?.IsImportant ?? false)); } } @@ -213,8 +212,9 @@ private static Boolean IsBlockified(CssStyleDeclaration style, IElement element) return true; } - var floating = style.GetPropertyValue(PropertyNames.Float); - var position = style.GetPropertyValue(PropertyNames.Position); + // neither has shorthands, i.e., nothing needs to be derived from other declarations + var floating = style.GetDeclaration(PropertyNames.Float)?.Value; + var position = style.GetDeclaration(PropertyNames.Position)?.Value ?? String.Empty; return (!String.IsNullOrEmpty(floating) && !floating.Isi(CssKeywords.None)) || position.Isi(CssKeywords.Absolute) || position.Isi(CssKeywords.Fixed); } diff --git a/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs b/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs index 77833207..00fa3908 100644 --- a/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs +++ b/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs @@ -311,13 +311,13 @@ private static List SortBySpecificity(this IStyleCollection styles, I var rules = collection.GetRules(); var defaultRuleCount = collection.GetDefaultRuleCount(); var candidates = collection.GetIndex().GetCandidates(element); - // sized for the candidates, of which many match, instead of growing step by step - matches = new List(candidates.Count); + // not sized for the candidates: the unkeyed rules (e.g., [data-theme] or :root) + // are candidates for every element, but only a few of them match + matches = new List(); // the candidates keep the order of the rules, as required by the cascade - for (var j = 0; j < candidates.Count; j++) + foreach (var i in candidates) { - var i = candidates[j]; MapPriority(rules[i], element, scope, i < defaultRuleCount, collection.GetLayerRank(i), matches); } } diff --git a/src/AngleSharp.Css/Values/CssChildValue.cs b/src/AngleSharp.Css/Values/CssChildValue.cs index 6ba38a1b..742768dd 100644 --- a/src/AngleSharp.Css/Values/CssChildValue.cs +++ b/src/AngleSharp.Css/Values/CssChildValue.cs @@ -16,6 +16,9 @@ sealed class CssChildValue : ICssValue, IEquatable private readonly ICssValue _parent; private readonly ICssValue _value; private readonly String _shorthandName; + // the last substitution, which is usually the same for all elements (and + // longhands, as the value of a shorthand is shared by them) + private Substitution _substitution; #endregion @@ -115,9 +118,17 @@ internal ICssValue Compute(ICssComputeContext context, String longhandName) // Parse the substituted shorthand once its complete token stream // is known, rather than feeding it to an individual longhand's // converter and discarding the remaining components. - var parser = context.Context?.GetService() ?? new CssParser(context.Context); - var declarations = parser.ParseDeclaration(shorthandName + ":" + text); - return declarations.GetProperty(longhandName)?.RawValue?.Compute(context); + var substitution = _substitution; + + if (substitution is null || !substitution.Matches(context.Context, text)) + { + var parser = context.Context?.GetService() ?? new CssParser(context.Context); + var declarations = parser.ParseDeclaration(shorthandName + ":" + text); + substitution = new Substitution(context.Context, text, declarations); + _substitution = substitution; + } + + return substitution.Declarations.GetProperty(longhandName)?.RawValue?.Compute(context); } return ((ICssValue)this).Compute(context); @@ -126,5 +137,33 @@ internal ICssValue Compute(ICssComputeContext context, String longhandName) Boolean IEquatable.Equals(ICssValue other) => other is CssChildValue value && Equals(value); #endregion + + #region Substitution + + /// + /// The (uncomputed) declarations parsed from the substituted shorthand, which + /// are only read. Immutable, such that it can be shared without synchronization. + /// + private sealed class Substitution + { + public Substitution(IBrowsingContext context, String text, ICssStyleDeclaration declarations) + { + Context = context; + Text = text; + Declarations = declarations; + } + + // the parser (and hence the result) is determined by the context + public IBrowsingContext Context { get; } + + public String Text { get; } + + public ICssStyleDeclaration Declarations { get; } + + public Boolean Matches(IBrowsingContext context, String text) => + Object.ReferenceEquals(Context, context) && Text.Equals(text, StringComparison.Ordinal); + } + + #endregion } } diff --git a/src/AngleSharp.Css/Values/CssCustomPropertyResolver.cs b/src/AngleSharp.Css/Values/CssCustomPropertyResolver.cs index a9b01a10..9c7f800f 100644 --- a/src/AngleSharp.Css/Values/CssCustomPropertyResolver.cs +++ b/src/AngleSharp.Css/Values/CssCustomPropertyResolver.cs @@ -43,6 +43,7 @@ public CssCustomPropertyResolver(IEnumerable properties, ICssPrope var values = value is CssReferenceValue references ? references.GetVariableValues().ToArray() : value is null || value is CssInvalidValue ? Array.Empty() : + value is CssAnyValue any ? new[] { any.Tokens } : new[] { new CssVariableValue(value.CssText) }; var keyword = values.Length == 1 ? values[0].Keyword : null; diff --git a/src/AngleSharp.Css/Values/Raws/CssAnyValue.cs b/src/AngleSharp.Css/Values/Raws/CssAnyValue.cs index e6fd1de9..5bb4d4b0 100644 --- a/src/AngleSharp.Css/Values/Raws/CssAnyValue.cs +++ b/src/AngleSharp.Css/Values/Raws/CssAnyValue.cs @@ -15,6 +15,8 @@ sealed class CssAnyValue : ICssRawValue #region Fields private readonly String _text; + // the tokens are only needed for substituting variables, but then for every element + private CssVariableValue _tokens; #endregion @@ -33,6 +35,12 @@ public CssAnyValue(String text, Boolean isResolved = false) internal Boolean IsResolved { get; } + /// + /// Gets the (immutable) token stream of the value, which is shared by all + /// elements the value is substituted for. + /// + internal CssVariableValue Tokens => _tokens ??= new CssVariableValue(_text); + #endregion #region Properties From 74222b2f850584c9c5bd65754190f07a5a02ca41 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Fri, 30 Oct 2026 23:24:59 +0100 Subject: [PATCH 6/6] Improved CSSOM construction --- CHANGELOG.md | 7 ++ .../Declarations/CssBackgroundProperty.cs | 13 ++++ .../Extensions/ViewportWindow.cs | 35 +++++++++ .../DefaultStyleSheetRenderingSectionTests.cs | 9 +++ .../Styling/DuplicateStyleSheetTests.cs | 76 +++++++++++++++++++ .../CssDefaultStyleSheetProvider.cs | 2 +- .../Dom/Internal/CssStyleDeclaration.cs | 14 +++- .../Dom/Internal/MediaFeature.cs | 38 ++++++++++ .../Dom/Internal/StyleCollection.cs | 58 +++++++++++++- .../Extensions/MediaListExtensions.cs | 22 ++++-- .../Extensions/ValueConverterExtensions.cs | 35 ++++++--- .../Extensions/WindowExtensions.cs | 37 ++++++++- .../AspectRatioFeatureValidator.cs | 2 +- .../ColorFeatureValidator.cs | 2 +- .../ColorIndexFeatureValidator.cs | 2 +- .../DeviceAspectRatioFeatureValidator.cs | 2 +- .../DeviceHeightFeatureValidator.cs | 2 +- .../DevicePixelRatioFeatureValidator.cs | 2 +- .../DeviceWidthFeatureValidator.cs | 2 +- .../FeatureValidators/GridFeatureValidator.cs | 2 +- .../HeightFeatureValidator.cs | 2 +- .../HoverFeatureValidator.cs | 2 +- .../MonochromeFeatureValidator.cs | 2 +- .../OrientationFeatureValidator.cs | 2 +- .../PointerFeatureValidator.cs | 2 +- .../ResolutionFeatureValidator.cs | 2 +- .../FeatureValidators/ScanFeatureValidator.cs | 2 +- .../ScriptingFeatureValidator.cs | 2 +- .../UpdateFrequencyFeatureValidator.cs | 2 +- .../WidthFeatureValidator.cs | 2 +- .../Parser/Micro/PointParser.cs | 9 ++- 31 files changed, 350 insertions(+), 41 deletions(-) create mode 100644 src/AngleSharp.Css.Tests/Extensions/ViewportWindow.cs create mode 100644 src/AngleSharp.Css.Tests/Styling/DuplicateStyleSheetTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index ec1afc9d..d92adffc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,16 @@ Released on Wednesday, October 28 2026. - Improved the cascade to also group rules by an attribute their subject requires (e.g. `[data-color-mode]`) and by the arguments of `:is()`, `:where()`, and `:matches()`, and to skip rules with an invalid selector - Improved substituting variables by reusing the parsed shorthand (e.g. `border: var(--w) solid var(--c)`) and the tokens of custom properties across elements - Improved matching `::before` and `::after` by grouping the rules targeting pseudo-elements by the id, class, or tag name of the element they belong to, instead of matching all of them against every element +- Improved reading a property of a computed style that is neither declared nor inherited (e.g. `border-bottom-width`), which looked through the declarations of all ancestors, and of the shorthands containing it, only to discard what it found +- Improved serializing lists of a single entry or two (e.g. one background layer), which needed no buffer +- Improved evaluating media queries, which serialized and parsed the value of every feature (e.g. `(min-width: 768px)`) again for each style computation +- Improved the cascade to leave out a style sheet linked again later (e.g. dev.to links each of its style sheets twice), which the later copy overrides anyway - Fixed a declaration starting with `*` (the IE7 hack `*zoom: 1`) being taken for a nested rule that swallowed the following declarations or the rest of the style sheet; it is dropped up to its semicolon +- Fixed a `background` shorthand with a single position keyword followed by another value (e.g. `background: url(i.svg) center no-repeat` or `center red`) being dropped as invalid +- Fixed forms having a vertical margin (`1.12em`), which only quirks mode gives them - Added `CssParserOptions.IsNotResolvingRelativeUrls` to prevent resolving the relative URLs in linked and imported style sheets - Added `font-variant-east-asian` (`normal`, `jis78`/`jis83`/`jis90`/`jis04`/`simplified`/`traditional`, `full-width`/`proportional-width`, `ruby`), which used to be dropped as an unknown property +- Added `innerWidth`, `innerHeight`, and `devicePixelRatio` to the window, taken from the render device # 1.2.2 diff --git a/src/AngleSharp.Css.Tests/Declarations/CssBackgroundProperty.cs b/src/AngleSharp.Css.Tests/Declarations/CssBackgroundProperty.cs index 655392f8..f9a0fa46 100644 --- a/src/AngleSharp.Css.Tests/Declarations/CssBackgroundProperty.cs +++ b/src/AngleSharp.Css.Tests/Declarations/CssBackgroundProperty.cs @@ -17,6 +17,19 @@ public void CssBackgroundWithCurrentColorLegal(string snippet, string expected) Assert.AreEqual(expected, property.Value); } + [TestCase("background: center no-repeat", "center", "no-repeat")] + [TestCase("background: red center no-repeat", "center", "no-repeat")] + [TestCase("background: center red", "center", "initial")] + [TestCase("background: url(i.svg) center center no-repeat", "center", "no-repeat")] + [TestCase("background: left top repeat-x", "left top", "repeat-x")] + public void CssBackgroundPositionFollowedByOtherValuesLegal(string snippet, string position, string repeat) + { + // a keyword after a single position keyword used to be taken for its second component + var style = ParseDeclarations(snippet); + Assert.AreEqual(position, style.GetPropertyValue("background-position")); + Assert.AreEqual(repeat, style.GetPropertyValue("background-repeat")); + } + [Test] public void CssBackgroundSizeCoverTest() { diff --git a/src/AngleSharp.Css.Tests/Extensions/ViewportWindow.cs b/src/AngleSharp.Css.Tests/Extensions/ViewportWindow.cs new file mode 100644 index 00000000..092f4d0b --- /dev/null +++ b/src/AngleSharp.Css.Tests/Extensions/ViewportWindow.cs @@ -0,0 +1,35 @@ +#nullable disable +namespace AngleSharp.Css.Tests.Extensions +{ + using AngleSharp.Dom; + using AngleSharp.Html.Parser; + using NUnit.Framework; + + [TestFixture] + public class ViewportWindowTests + { + [Test] + public void InnerSizeIsTheViewportOfTheRenderDevice() + { + var window = CreateWindow(new DefaultRenderDevice { ViewPortWidth = 1280, ViewPortHeight = 800 }); + Assert.AreEqual(1280, window.InnerWidth()); + Assert.AreEqual(800, window.InnerHeight()); + } + + [Test] + public void DevicePixelRatioFollowsTheResolutionOfTheRenderDevice() + { + Assert.AreEqual(1.0, CreateWindow(new DefaultRenderDevice()).DevicePixelRatio()); + Assert.AreEqual(2.0, CreateWindow(new DefaultRenderDevice { Resolution = 192 }).DevicePixelRatio()); + } + + private static IWindow CreateWindow(IRenderDevice device) + { + var config = Configuration.Default.WithCss().WithRenderDevice(device); + var context = BrowsingContext.New(config); + var parser = context.GetService(); + var document = parser.ParseDocument("Example"); + return document.DefaultView; + } + } +} diff --git a/src/AngleSharp.Css.Tests/Styling/DefaultStyleSheetRenderingSectionTests.cs b/src/AngleSharp.Css.Tests/Styling/DefaultStyleSheetRenderingSectionTests.cs index 8d188611..f19de961 100644 --- a/src/AngleSharp.Css.Tests/Styling/DefaultStyleSheetRenderingSectionTests.cs +++ b/src/AngleSharp.Css.Tests/Styling/DefaultStyleSheetRenderingSectionTests.cs @@ -35,6 +35,15 @@ public void MarkIsHighlighted(string property, string expected) Assert.AreEqual(expected, Computed("

x

", property)); } + [TestCase("margin-top")] + [TestCase("margin-bottom")] + public void FormHasNoMargins(string property) + { + // only a quirks mode form has a bottom margin; forms in grids (e.g. dev.to's sign-in + // buttons) were spaced apart + Assert.AreEqual(Computed("
x
", property), Computed("
x
", property)); + } + [TestCase("margin-top")] [TestCase("margin-bottom")] public void PreHasBlockMargins(string property) diff --git a/src/AngleSharp.Css.Tests/Styling/DuplicateStyleSheetTests.cs b/src/AngleSharp.Css.Tests/Styling/DuplicateStyleSheetTests.cs new file mode 100644 index 00000000..8b5db591 --- /dev/null +++ b/src/AngleSharp.Css.Tests/Styling/DuplicateStyleSheetTests.cs @@ -0,0 +1,76 @@ +#nullable disable +namespace AngleSharp.Css.Tests.Styling +{ + using AngleSharp.Css.Dom; + using AngleSharp.Css.Tests.Mocks; + using AngleSharp.Dom; + using NUnit.Framework; + using System.Linq; + using System.Threading.Tasks; + + /// + /// Pages like dev.to link their style sheets twice. The cascade leaves out the earlier copy of + /// a sheet linked again, which the later copy overrides anyway, instead of matching every + /// element against both. + /// + [TestFixture] + public class DuplicateStyleSheetTests + { + private static Task LoadAsync(string body) + { + var requester = new MockRequester(); + requester.BuildResponse(request => + { + if (request.Address.Path.EndsWith("a.css")) + { + return "p { color: red } .x { color: blue }"; + } + else if (request.Address.Path.EndsWith("b.css")) + { + return "p { color: green } p.x { color: green }"; + } + else if (request.Address.Path.EndsWith("layers.css")) + { + return "@layer one { p { color: red } }"; + } + + return null; + }); + var config = Configuration.Default.WithCss().WithMockRequester(requester); + return BrowsingContext.New(config).OpenAsync(m => m.Content("" + body).Address("http://localhost/")); + } + + private static string ColorOf(IDocument document, string id) => + document.DefaultView.GetComputedStyle(document.GetElementById(id)).GetColor(); + + [Test] + public async Task SheetLinkedAgainOverridesTheSheetsInBetween() + { + var document = await LoadAsync("

A

B

"); + + Assert.AreEqual("rgba(255, 0, 0, 1)", ColorOf(document, "t")); + // a higher specificity in between still wins + Assert.AreEqual("rgba(0, 128, 0, 1)", ColorOf(document, "u")); + } + + [Test] + public async Task SheetLinkedTwiceIsMatchedOnce() + { + var document = await LoadAsync("

A

"); + var rules = document.DefaultView.GetStyleCollection(new DefaultRenderDevice()).OfType().Count(m => m.Owner?.Href is not null); + + Assert.AreEqual(4, rules); + Assert.AreEqual("rgba(0, 128, 0, 1)", ColorOf(document, "t")); + } + + [Test] + public async Task SheetWithLayersLinkedTwiceIsKept() + { + var document = await LoadAsync("

A

"); + var rules = document.DefaultView.GetStyleCollection(new DefaultRenderDevice()).OfType().Count(m => m.Owner?.Href is not null); + + Assert.AreEqual(2, rules); + Assert.AreEqual("rgba(255, 0, 0, 1)", ColorOf(document, "t")); + } + } +} diff --git a/src/AngleSharp.Css/CssDefaultStyleSheetProvider.cs b/src/AngleSharp.Css/CssDefaultStyleSheetProvider.cs index e45c0cd1..f7946bc1 100644 --- a/src/AngleSharp.Css/CssDefaultStyleSheetProvider.cs +++ b/src/AngleSharp.Css/CssDefaultStyleSheetProvider.cs @@ -98,7 +98,7 @@ private static ICssStyleSheet Parse(String source) h1 { font-size: 2em; margin: .67em 0 } h2 { font-size: 1.5em; margin: .75em 0 } h3 { font-size: 1.17em; margin: .83em 0 } -h4, form { margin: 1.12em 0 } +h4 { margin: 1.12em 0 } blockquote, figure, listing, p, plaintext, diff --git a/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs b/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs index 58f70c8b..e6dd6ab1 100644 --- a/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs +++ b/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs @@ -419,9 +419,19 @@ internal ICssProperty GetDeclaration(String name) return _declarations[index]; } - return _inherited is not null ? Snapshot.Inherit(_inherited, _forced, name) : null; + return _inherited is not null && MayInherit(name) ? Snapshot.Inherit(_inherited, _forced, name) : null; } + /// + /// Whether a declaration with the given name may come from the parent: only an inherited + /// property, or one inheriting explicitly. Everything else would walk up the ancestors + /// just to drop what it finds, which a computed style is asked for constantly (e.g. for + /// every undeclared non-inherited longhand and its shorthands, by a renderer). + /// + private Boolean MayInherit(String name) => + (_forced is not null && _forced.Contains(name)) || + (_context.GetDeclarationInfo(name).Flags & PropertyFlags.Inherited) == PropertyFlags.Inherited; + /// /// Sets the given declaration as an own one, replacing the own declaration with the /// same name, if any. An inherited declaration with the same name is shadowed, hence @@ -710,7 +720,7 @@ private ICssProperty FindUnserializedLonghand(String name, IEnumerable s } } - return _inherited is not null ? Snapshot.Inherit(_inherited, _forced, name) : null; + return _inherited is not null && MayInherit(name) ? Snapshot.Inherit(_inherited, _forced, name) : null; } private void SetProperty(ICssProperty property) diff --git a/src/AngleSharp.Css/Dom/Internal/MediaFeature.cs b/src/AngleSharp.Css/Dom/Internal/MediaFeature.cs index 7daf4fd1..eaa13403 100644 --- a/src/AngleSharp.Css/Dom/Internal/MediaFeature.cs +++ b/src/AngleSharp.Css/Dom/Internal/MediaFeature.cs @@ -1,6 +1,7 @@ #nullable disable namespace AngleSharp.Css.Dom { + using AngleSharp.Css.Converters; using AngleSharp.Css.Values; using AngleSharp.Text; using System; @@ -20,6 +21,8 @@ sealed class MediaFeature : IMediaFeature private readonly ICssValue _value; private readonly IReadOnlyList _comparisons; private readonly String _rangeText; + private Tuple _conversion; + private MediaFeature[] _bounds; #endregion @@ -75,6 +78,41 @@ internal MediaFeature(String name, IReadOnlyList comparisons, S #region Methods + /// + /// Converts the value of the feature with the given converter. Media queries are + /// evaluated again for every style computation, so the result of the last converter + /// is kept instead of serializing and parsing the value over and over. + /// + /// The converter to use. + /// The converted value, if valid. + internal ICssValue ConvertValue(IValueConverter converter) + { + var conversion = _conversion; + + if (conversion is null || !ReferenceEquals(conversion.Item1, converter)) + { + conversion = Tuple.Create(converter, converter.Convert(Value)); + _conversion = conversion; + } + + return conversion.Item2; + } + + /// + /// Gets the feature a comparison of the range syntax is evaluated as, i.e. the + /// min-, max-, or plain form of the feature with the value of the comparison. + /// + /// The index of the comparison. + /// The prefix: min-, max-, or none. + /// The feature to validate. + internal MediaFeature GetBound(Int32 index, String prefix) + { + var kind = prefix.Length == 0 ? 0 : prefix[1] == 'i' ? 1 : 2; + var bounds = _bounds ??= new MediaFeature[_comparisons.Count * 3]; + var slot = index * 3 + kind; + return bounds[slot] ??= new MediaFeature(prefix + Name, _comparisons[index].Value); + } + public void ToCss(TextWriter writer, IStyleFormatter formatter) { writer.Write(Symbols.RoundBracketOpen); diff --git a/src/AngleSharp.Css/Dom/Internal/StyleCollection.cs b/src/AngleSharp.Css/Dom/Internal/StyleCollection.cs index 1be812c7..34010344 100644 --- a/src/AngleSharp.Css/Dom/Internal/StyleCollection.cs +++ b/src/AngleSharp.Css/Dom/Internal/StyleCollection.cs @@ -101,7 +101,7 @@ private List CollectRules() var layers = new List(); AddRules(rules, layers, _defaultSheets); _defaultRuleCount = rules.Count; - AddRules(rules, layers, _sheets); + AddRules(rules, layers, WithoutEarlierDuplicates(_sheets)); _layerRanks = layers.ConvertAll(m => m.Rank); return rules; } @@ -122,6 +122,62 @@ private void AddRules(List rules, List layers, IEnumerable root.AssignRanks(0); } + /// + /// Leaves out a style sheet linked again later on (e.g. a page linking its style sheets + /// twice). The later copy wins every tie the earlier one could, so the cascade stays the + /// same, but every element would be matched against both. Sheets with cascade layers + /// are kept, as layers are ordered by their first appearance. + /// + private static IEnumerable WithoutEarlierDuplicates(IEnumerable sheets) + { + var list = sheets as IReadOnlyList ?? sheets.ToList(); + var duplicates = default(HashSet); + var seen = default(Dictionary); + + for (var i = list.Count - 1; i >= 0; i--) + { + var sheet = list[i]; + var href = sheet.Href; + + if (String.IsNullOrEmpty(href) || HasLayers(sheet.Rules)) + { + continue; + } + + var key = String.Concat(href, "\n", sheet.Media.MediaText, "\n", sheet.IsDisabled ? "1" : "0", "\n", sheet.Rules.Length.ToString()); + seen ??= new Dictionary(StringComparer.Ordinal); + + if (seen.ContainsKey(key)) + { + (duplicates ??= new HashSet()).Add(i); + } + else + { + seen.Add(key, i); + } + } + + if (duplicates is null) + { + return list; + } + + return list.Where((_, index) => !duplicates.Contains(index)); + } + + private static Boolean HasLayers(ICssRuleList rules) + { + foreach (var rule in rules) + { + if (rule is ICssLayerRule || (rule is ICssGroupingRule group && HasLayers(group.Rules))) + { + return true; + } + } + + return false; + } + private void AddRules(List rules, List layers, ICssRuleList list, Layer layer) { foreach (var rule in list) diff --git a/src/AngleSharp.Css/Extensions/MediaListExtensions.cs b/src/AngleSharp.Css/Extensions/MediaListExtensions.cs index 39057eb4..4fb83868 100644 --- a/src/AngleSharp.Css/Extensions/MediaListExtensions.cs +++ b/src/AngleSharp.Css/Extensions/MediaListExtensions.cs @@ -34,9 +34,17 @@ public static Boolean Validate(this IMediaFeature feature, IRenderDevice device) AssociatedValidators.TryGetValue(feature, out var validator); - if (validator is not null && feature is MediaFeature { Comparisons: { } comparisons }) + if (validator is not null && feature is MediaFeature { Comparisons: { } comparisons } range) { - return comparisons.All(m => validator.Validate(feature.Name, m, device)); + for (var i = 0; i < comparisons.Count; i++) + { + if (!validator.Validate(range, i, device)) + { + return false; + } + } + + return true; } return validator?.Validate(feature, device) ?? false; @@ -46,13 +54,13 @@ public static Boolean Validate(this IMediaFeature feature, IRenderDevice device) /// 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) + private static Boolean Validate(this IFeatureValidator validator, MediaFeature feature, Int32 index, 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); + Boolean AtLeast() => validator.Validate(feature.GetBound(index, "min-"), device); + Boolean AtMost() => validator.Validate(feature.GetBound(index, "max-"), device); + Boolean Equal() => validator.Validate(feature.GetBound(index, String.Empty), device); - return comparison.Operator switch + return feature.Comparisons[index].Operator switch { ">=" => AtLeast(), "<=" => AtMost(), diff --git a/src/AngleSharp.Css/Extensions/ValueConverterExtensions.cs b/src/AngleSharp.Css/Extensions/ValueConverterExtensions.cs index 5e5b0fc7..798097df 100644 --- a/src/AngleSharp.Css/Extensions/ValueConverterExtensions.cs +++ b/src/AngleSharp.Css/Extensions/ValueConverterExtensions.cs @@ -6,6 +6,7 @@ namespace AngleSharp.Css.Converters using AngleSharp.Text; using System; using System.Collections.Generic; + using System.Text; using static ValueConverters; /// @@ -13,6 +14,13 @@ namespace AngleSharp.Css.Converters /// static class ValueConverterExtensions { + /// + /// Converts the value of the given media feature, reusing the conversion of an earlier + /// evaluation where possible. + /// + internal static ICssValue? Convert(this IValueConverter converter, IMediaFeature feature) => + feature is MediaFeature known ? known.ConvertValue(converter) : converter.Convert(feature.Value); + public static ICssValue? Convert(this IValueConverter converter, String value) { var source = new StringSource(value); @@ -74,26 +82,33 @@ public static IValueConverter Option(this IValueConverter converter, ICssValue d public static String Join(this IEnumerable values, String separator) where T : ICssValue { - var buffer = StringBuilderPool.Obtain(); - var previous = false; + // Serialized for every property read of a computed style, and nearly all lists hold a + // single entry or two (e.g. one background layer, a tuple of two lengths), which need + // no buffer at all. + var first = default(String); + var buffer = default(StringBuilder); foreach (var value in values) { var str = value?.CssText; - if (!String.IsNullOrEmpty(str)) + if (String.IsNullOrEmpty(str)) { - if (previous) - { - buffer.Append(separator); - } + continue; + } - buffer.Append(str); - previous = true; + if (first is null) + { + first = str; + } + else + { + buffer ??= StringBuilderPool.Obtain().Append(first); + buffer.Append(separator).Append(str); } } - return buffer.ToPool(); + return buffer?.ToPool() ?? first ?? String.Empty; } } } diff --git a/src/AngleSharp.Css/Extensions/WindowExtensions.cs b/src/AngleSharp.Css/Extensions/WindowExtensions.cs index 9316c39f..b74ffbb4 100644 --- a/src/AngleSharp.Css/Extensions/WindowExtensions.cs +++ b/src/AngleSharp.Css/Extensions/WindowExtensions.cs @@ -70,6 +70,38 @@ public static ICssPseudoElementList GetPseudoElements(this IWindow window, IElem }).OfType()); } + /// + /// Gets the width of the viewport in CSS pixels, as given by the render device. + /// + /// The window to extend with the functionality. + /// The width of the viewport. + [DomName("innerWidth")] + [DomAccessor(Accessors.Getter)] + public static Int32 InnerWidth(this IWindow window) => window.GetRenderDevice().ViewPortWidth; + + /// + /// Gets the height of the viewport in CSS pixels, as given by the render device. + /// + /// The window to extend with the functionality. + /// The height of the viewport. + [DomName("innerHeight")] + [DomAccessor(Accessors.Getter)] + public static Int32 InnerHeight(this IWindow window) => window.GetRenderDevice().ViewPortHeight; + + /// + /// Gets the ratio of device pixels to CSS pixels, derived from the resolution of the + /// render device (96 dpi being one device pixel per CSS pixel). + /// + /// The window to extend with the functionality. + /// The device pixel ratio. + [DomName("devicePixelRatio")] + [DomAccessor(Accessors.Getter)] + public static Double DevicePixelRatio(this IWindow window) + { + var resolution = window.GetRenderDevice().Resolution; + return resolution > 0 ? resolution / 96.0 : 1.0; + } + /// /// Gives the values of all the CSS properties of an element after /// applying the active stylesheets and resolving any basic computation @@ -82,7 +114,7 @@ public static ICssPseudoElementList GetPseudoElements(this IWindow window, IElem [DomName("getComputedStyle")] public static ICssStyleDeclaration GetComputedStyle(this IWindow window, IElement element, String? pseudo = null) { - var device = window.Document.Context.GetService() ?? new DefaultRenderDevice(); + var device = window.GetRenderDevice(); var styleCollection = window.GetStyleCollection(device); return styleCollection.ComputeDeclarations(element, pseudo); } @@ -137,5 +169,8 @@ public static IRenderNode Render(this IWindow window, IRenderDevice? renderDevic var builder = RenderTreeBuilder.GetInstance(window); return builder.RenderDocument(renderDevice); } + + private static IRenderDevice GetRenderDevice(this IWindow window) => + window.Document.Context.GetService() ?? new DefaultRenderDevice(); } } diff --git a/src/AngleSharp.Css/FeatureValidators/AspectRatioFeatureValidator.cs b/src/AngleSharp.Css/FeatureValidators/AspectRatioFeatureValidator.cs index 9cd3596e..b093daf6 100644 --- a/src/AngleSharp.Css/FeatureValidators/AspectRatioFeatureValidator.cs +++ b/src/AngleSharp.Css/FeatureValidators/AspectRatioFeatureValidator.cs @@ -9,7 +9,7 @@ sealed class AspectRatioFeatureValidator : IFeatureValidator { public Boolean Validate(IMediaFeature feature, IRenderDevice renderDevice) { - var ratio = RatioConverter.Convert(feature.Value); + var ratio = RatioConverter.Convert(feature); if (ratio is not null) { diff --git a/src/AngleSharp.Css/FeatureValidators/ColorFeatureValidator.cs b/src/AngleSharp.Css/FeatureValidators/ColorFeatureValidator.cs index 77ec9a2c..427745c7 100644 --- a/src/AngleSharp.Css/FeatureValidators/ColorFeatureValidator.cs +++ b/src/AngleSharp.Css/FeatureValidators/ColorFeatureValidator.cs @@ -12,7 +12,7 @@ public Boolean Validate(IMediaFeature feature, IRenderDevice renderDevice) { var defaultValue = new CssLengthValue(1.0, CssLengthValue.Unit.None); var converter = feature.IsMinimum || feature.IsMaximum ? PositiveIntegerConverter : PositiveIntegerConverter.Option(defaultValue); - var color = converter.Convert(feature.Value); + var color = converter.Convert(feature); if (color != null) { diff --git a/src/AngleSharp.Css/FeatureValidators/ColorIndexFeatureValidator.cs b/src/AngleSharp.Css/FeatureValidators/ColorIndexFeatureValidator.cs index 15e4ea47..c30dfd67 100644 --- a/src/AngleSharp.Css/FeatureValidators/ColorIndexFeatureValidator.cs +++ b/src/AngleSharp.Css/FeatureValidators/ColorIndexFeatureValidator.cs @@ -12,7 +12,7 @@ public Boolean Validate(IMediaFeature feature, IRenderDevice renderDevice) { var defaultValue = new CssLengthValue(1.0, CssLengthValue.Unit.None); var converter = feature.IsMinimum || feature.IsMaximum ? NaturalIntegerConverter : NaturalIntegerConverter.Option(defaultValue); - var index = converter.Convert(feature.Value); + var index = converter.Convert(feature); if (index != null) { diff --git a/src/AngleSharp.Css/FeatureValidators/DeviceAspectRatioFeatureValidator.cs b/src/AngleSharp.Css/FeatureValidators/DeviceAspectRatioFeatureValidator.cs index 235f0bf9..4c49d2c1 100644 --- a/src/AngleSharp.Css/FeatureValidators/DeviceAspectRatioFeatureValidator.cs +++ b/src/AngleSharp.Css/FeatureValidators/DeviceAspectRatioFeatureValidator.cs @@ -9,7 +9,7 @@ sealed class DeviceAspectRatioFeatureValidator : IFeatureValidator { public Boolean Validate(IMediaFeature feature, IRenderDevice renderDevice) { - var ratio = RatioConverter.Convert(feature.Value); + var ratio = RatioConverter.Convert(feature); if (ratio is not null) { diff --git a/src/AngleSharp.Css/FeatureValidators/DeviceHeightFeatureValidator.cs b/src/AngleSharp.Css/FeatureValidators/DeviceHeightFeatureValidator.cs index 9532ad3b..3b88daaa 100644 --- a/src/AngleSharp.Css/FeatureValidators/DeviceHeightFeatureValidator.cs +++ b/src/AngleSharp.Css/FeatureValidators/DeviceHeightFeatureValidator.cs @@ -9,7 +9,7 @@ sealed class DeviceHeightFeatureValidator : IFeatureValidator { public Boolean Validate(IMediaFeature feature, IRenderDevice renderDevice) { - var length = LengthConverter.Convert(feature.Value); + var length = LengthConverter.Convert(feature); if (length != null) { diff --git a/src/AngleSharp.Css/FeatureValidators/DevicePixelRatioFeatureValidator.cs b/src/AngleSharp.Css/FeatureValidators/DevicePixelRatioFeatureValidator.cs index 2899699d..78515ca5 100644 --- a/src/AngleSharp.Css/FeatureValidators/DevicePixelRatioFeatureValidator.cs +++ b/src/AngleSharp.Css/FeatureValidators/DevicePixelRatioFeatureValidator.cs @@ -9,7 +9,7 @@ sealed class DevicePixelRatioFeatureValidator : IFeatureValidator { public Boolean Validate(IMediaFeature feature, IRenderDevice renderDevice) { - var value = NaturalNumberConverter.Convert(feature.Value); + var value = NaturalNumberConverter.Convert(feature); if (value != null) { diff --git a/src/AngleSharp.Css/FeatureValidators/DeviceWidthFeatureValidator.cs b/src/AngleSharp.Css/FeatureValidators/DeviceWidthFeatureValidator.cs index 87e65c83..a1af9c13 100644 --- a/src/AngleSharp.Css/FeatureValidators/DeviceWidthFeatureValidator.cs +++ b/src/AngleSharp.Css/FeatureValidators/DeviceWidthFeatureValidator.cs @@ -9,7 +9,7 @@ sealed class DeviceWidthFeatureValidator : IFeatureValidator { public Boolean Validate(IMediaFeature feature, IRenderDevice renderDevice) { - var length = LengthConverter.Convert(feature.Value); + var length = LengthConverter.Convert(feature); if (length != null) { diff --git a/src/AngleSharp.Css/FeatureValidators/GridFeatureValidator.cs b/src/AngleSharp.Css/FeatureValidators/GridFeatureValidator.cs index 9ba0ca60..3d0ad875 100644 --- a/src/AngleSharp.Css/FeatureValidators/GridFeatureValidator.cs +++ b/src/AngleSharp.Css/FeatureValidators/GridFeatureValidator.cs @@ -9,7 +9,7 @@ sealed class GridFeatureValidator : IFeatureValidator { public Boolean Validate(IMediaFeature feature, IRenderDevice renderDevice) { - var grid = BinaryConverter.Convert(feature.Value); + var grid = BinaryConverter.Convert(feature); if (grid != null) { diff --git a/src/AngleSharp.Css/FeatureValidators/HeightFeatureValidator.cs b/src/AngleSharp.Css/FeatureValidators/HeightFeatureValidator.cs index 3280bd16..ef47574e 100644 --- a/src/AngleSharp.Css/FeatureValidators/HeightFeatureValidator.cs +++ b/src/AngleSharp.Css/FeatureValidators/HeightFeatureValidator.cs @@ -9,7 +9,7 @@ sealed class HeightFeatureValidator : IFeatureValidator { public Boolean Validate(IMediaFeature feature, IRenderDevice renderDevice) { - var length = LengthConverter.Convert(feature.Value); + var length = LengthConverter.Convert(feature); if (length != null) { diff --git a/src/AngleSharp.Css/FeatureValidators/HoverFeatureValidator.cs b/src/AngleSharp.Css/FeatureValidators/HoverFeatureValidator.cs index 82a5998d..28baffac 100644 --- a/src/AngleSharp.Css/FeatureValidators/HoverFeatureValidator.cs +++ b/src/AngleSharp.Css/FeatureValidators/HoverFeatureValidator.cs @@ -23,7 +23,7 @@ public Boolean Validate(IMediaFeature feature, IRenderDevice renderDevice) return PreferenceFeatureValidator.Matches(feature, preference, CssKeywords.None); } - var hover = HoverAbilityConverter.Convert(feature.Value); + var hover = HoverAbilityConverter.Convert(feature); if (hover != null) { diff --git a/src/AngleSharp.Css/FeatureValidators/MonochromeFeatureValidator.cs b/src/AngleSharp.Css/FeatureValidators/MonochromeFeatureValidator.cs index 425edffd..60a5b8ee 100644 --- a/src/AngleSharp.Css/FeatureValidators/MonochromeFeatureValidator.cs +++ b/src/AngleSharp.Css/FeatureValidators/MonochromeFeatureValidator.cs @@ -12,7 +12,7 @@ public Boolean Validate(IMediaFeature feature, IRenderDevice renderDevice) { var defaultValue = new CssLengthValue(1.0, CssLengthValue.Unit.None); var converter = feature.IsMinimum || feature.IsMaximum ? NaturalIntegerConverter : NaturalIntegerConverter.Option(defaultValue); - var index = converter.Convert(feature.Value); + var index = converter.Convert(feature); if (index != null) { diff --git a/src/AngleSharp.Css/FeatureValidators/OrientationFeatureValidator.cs b/src/AngleSharp.Css/FeatureValidators/OrientationFeatureValidator.cs index d4f61fd6..af7b3bf1 100644 --- a/src/AngleSharp.Css/FeatureValidators/OrientationFeatureValidator.cs +++ b/src/AngleSharp.Css/FeatureValidators/OrientationFeatureValidator.cs @@ -9,7 +9,7 @@ sealed class OrientationFeatureValidator : IFeatureValidator { public Boolean Validate(IMediaFeature feature, IRenderDevice renderDevice) { - var portrait = OrientationModeConverter.Convert(feature.Value); + var portrait = OrientationModeConverter.Convert(feature); if (portrait != null) { diff --git a/src/AngleSharp.Css/FeatureValidators/PointerFeatureValidator.cs b/src/AngleSharp.Css/FeatureValidators/PointerFeatureValidator.cs index 81d18671..a3c7ab90 100644 --- a/src/AngleSharp.Css/FeatureValidators/PointerFeatureValidator.cs +++ b/src/AngleSharp.Css/FeatureValidators/PointerFeatureValidator.cs @@ -23,7 +23,7 @@ public Boolean Validate(IMediaFeature feature, IRenderDevice renderDevice) return PreferenceFeatureValidator.Matches(feature, preference, CssKeywords.None); } - var accuracy = PointerAccuracyConverter.Convert(feature.Value); + var accuracy = PointerAccuracyConverter.Convert(feature); if (accuracy != null) { diff --git a/src/AngleSharp.Css/FeatureValidators/ResolutionFeatureValidator.cs b/src/AngleSharp.Css/FeatureValidators/ResolutionFeatureValidator.cs index bb1b1e54..ec4785bd 100644 --- a/src/AngleSharp.Css/FeatureValidators/ResolutionFeatureValidator.cs +++ b/src/AngleSharp.Css/FeatureValidators/ResolutionFeatureValidator.cs @@ -9,7 +9,7 @@ sealed class ResolutionFeatureValidator : IFeatureValidator { public Boolean Validate(IMediaFeature feature, IRenderDevice renderDevice) { - var res = ResolutionConverter.Convert(feature.Value); + var res = ResolutionConverter.Convert(feature); if (res != null) { diff --git a/src/AngleSharp.Css/FeatureValidators/ScanFeatureValidator.cs b/src/AngleSharp.Css/FeatureValidators/ScanFeatureValidator.cs index 55a453b1..7e2fc2ce 100644 --- a/src/AngleSharp.Css/FeatureValidators/ScanFeatureValidator.cs +++ b/src/AngleSharp.Css/FeatureValidators/ScanFeatureValidator.cs @@ -9,7 +9,7 @@ sealed class ScanFeatureValidator : IFeatureValidator { public Boolean Validate(IMediaFeature feature, IRenderDevice renderDevice) { - var interlace = ScanModeConverter.Convert(feature.Value); + var interlace = ScanModeConverter.Convert(feature); if (interlace != null) { diff --git a/src/AngleSharp.Css/FeatureValidators/ScriptingFeatureValidator.cs b/src/AngleSharp.Css/FeatureValidators/ScriptingFeatureValidator.cs index f2d62c03..e8190ac8 100644 --- a/src/AngleSharp.Css/FeatureValidators/ScriptingFeatureValidator.cs +++ b/src/AngleSharp.Css/FeatureValidators/ScriptingFeatureValidator.cs @@ -9,7 +9,7 @@ sealed class ScriptingFeatureValidator : IFeatureValidator { public Boolean Validate(IMediaFeature feature, IRenderDevice renderDevice) { - var state = ScriptingStateConverter.Convert(feature.Value); + var state = ScriptingStateConverter.Convert(feature); if (state != null) { diff --git a/src/AngleSharp.Css/FeatureValidators/UpdateFrequencyFeatureValidator.cs b/src/AngleSharp.Css/FeatureValidators/UpdateFrequencyFeatureValidator.cs index 8ed423e3..ca109619 100644 --- a/src/AngleSharp.Css/FeatureValidators/UpdateFrequencyFeatureValidator.cs +++ b/src/AngleSharp.Css/FeatureValidators/UpdateFrequencyFeatureValidator.cs @@ -9,7 +9,7 @@ sealed class UpdateFrequencyFeatureValidator : IFeatureValidator { public Boolean Validate(IMediaFeature feature, IRenderDevice renderDevice) { - var frequency = UpdateFrequencyConverter.Convert(feature.Value); + var frequency = UpdateFrequencyConverter.Convert(feature); if (frequency != null) { diff --git a/src/AngleSharp.Css/FeatureValidators/WidthFeatureValidator.cs b/src/AngleSharp.Css/FeatureValidators/WidthFeatureValidator.cs index c5777742..8bc1a85c 100644 --- a/src/AngleSharp.Css/FeatureValidators/WidthFeatureValidator.cs +++ b/src/AngleSharp.Css/FeatureValidators/WidthFeatureValidator.cs @@ -9,7 +9,7 @@ sealed class WidthFeatureValidator : IFeatureValidator { public Boolean Validate(IMediaFeature feature, IRenderDevice renderDevice) { - var length = LengthConverter.Convert(feature.Value); + var length = LengthConverter.Convert(feature); if (length != null) { diff --git a/src/AngleSharp.Css/Parser/Micro/PointParser.cs b/src/AngleSharp.Css/Parser/Micro/PointParser.cs index 2f09f2a6..c202f0b9 100644 --- a/src/AngleSharp.Css/Parser/Micro/PointParser.cs +++ b/src/AngleSharp.Css/Parser/Micro/PointParser.cs @@ -84,6 +84,7 @@ public static CssOriginValue ParseOrigin(this StringSource source) var x = new CssLengthValue(50f, CssLengthValue.Unit.Percent); var y = new CssLengthValue(50f, CssLengthValue.Unit.Percent); var l = source.ParseIdent(); + var afterLeft = source.Index; source.SkipSpacesAndComments(); var r = source.ParseIdent(); @@ -102,8 +103,14 @@ public static CssOriginValue ParseOrigin(this StringSource source) y = KeywordToLength(r).Value; return new CssPoint2D(x, y); } + + // Not a second component, e.g. the repeat in `background: center no-repeat`. + source.BackTo(afterLeft); + source.SkipSpacesAndComments(); + r = null; } - else if (l != null) + + if (r == null && l != null) { var s = source.ParseDistanceOrCalc();