diff --git a/CHANGELOG.md b/CHANGELOG.md index 07627178..7b4426e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +# 1.4.1 + +Released on Sunday, November 8 2026. + +- Improved finding the rules of an element: `:root` rules and rules whose subject requires no id or class (e.g., `.list > *`, `.menu a`) are only tried on elements of that context, which made building GitHub's render tree about twice as fast +- Improved the cascade of many declarations replacing each other (e.g., the custom properties of a design system on `:root`) +- Improved reading values of computed styles, e.g., of properties that are not declared +- Improved the memory use of styles: declarations of a few entries have no index of their names, the explicit style of an element gives back the room it did not use, and resolving custom properties allocates nothing for elements without any +- Improved building the render tree of large documents by matching the selectors of their elements on all cores (turned off by the AppContext switch `AngleSharp.Css.DisableParallelism`) +- Fixed an empty custom property (e.g. `--tw-blur: ;`) being dropped, which made every value using it invalid, e.g. the `filter` of Tailwind's utilities +- Added `GetStyleCollection` to shadow roots, for the rules that style the elements of a shadow tree +- Added `RenderPartial` to the window for rendering an element into a render tree whose children are only built when they are read, e.g., to style an element again after a change of its state, or the document, telling about the children of each node once they are built, e.g., to style just what is laid out +- Added `RenderDocument` to the window for rendering the document with the given styles instead of gathering and indexing the rules of its style sheets anew +- Added `ComputeDeclarationsWithParent` to compute the style of an element (or of a pseudo-element) from the computed style of its parent, e.g., of a render tree, instead of computing the styles of all its ancestors + # 1.4.0 Released on Friday, November 6 2026. diff --git a/src/AngleSharp.Css.Docs/package.json b/src/AngleSharp.Css.Docs/package.json index c1920045..bdc7dbf5 100644 --- a/src/AngleSharp.Css.Docs/package.json +++ b/src/AngleSharp.Css.Docs/package.json @@ -1,6 +1,6 @@ { "name": "@anglesharp/css", - "version": "1.4.0", + "version": "1.4.1", "preview": true, "description": "The doclet for the AngleSharp.Css documentation.", "keywords": [ diff --git a/src/AngleSharp.Css.Tests/Styling/CustomPropertyCompatibility.cs b/src/AngleSharp.Css.Tests/Styling/CustomPropertyCompatibility.cs index d5f750e2..def98e1a 100644 --- a/src/AngleSharp.Css.Tests/Styling/CustomPropertyCompatibility.cs +++ b/src/AngleSharp.Css.Tests/Styling/CustomPropertyCompatibility.cs @@ -44,6 +44,18 @@ public void OnlyComputedStylesResolveCustomProperties(String value, String expec Assert.AreEqual("var(--a)", declarations.GetPropertyValue("--b")); } + [Test] + public void EmptyCustomPropertyIsSubstitutedAsWhiteSpace() + { + // what Tailwind declares for the parts of a filter not in use + using var document = ParseDocument("
"); + var element = document.QuerySelector("div"); + var styles = document.DefaultView.GetStyleCollection(new DefaultRenderDevice()); + + Assert.AreEqual(2, ((ICssStyleRule)document.GetStyleSheets().OfType().Single().Rules[0]).Style.Length); + Assert.AreEqual("invert(100%)", styles.ComputeDeclarations(element).GetPropertyValue("filter")); + } + [Test] public void PublicFallbackParserPreservesNestedVariableObjects() { diff --git a/src/AngleSharp.Css.Tests/Styling/ParallelRenderTreeTests.cs b/src/AngleSharp.Css.Tests/Styling/ParallelRenderTreeTests.cs new file mode 100644 index 00000000..95bf2110 --- /dev/null +++ b/src/AngleSharp.Css.Tests/Styling/ParallelRenderTreeTests.cs @@ -0,0 +1,78 @@ +#nullable disable +namespace AngleSharp.Css.Tests.Styling +{ + using AngleSharp.Css.RenderTree; + using AngleSharp.Css.Tests.Mocks; + using AngleSharp.Dom; + using NUnit.Framework; + using System; + using System.Linq; + using System.Text; + + /// + /// The explicit styles of a large document are computed on several threads before its render + /// tree is built, which must give the same tree as computing them one after the other. + /// + [TestFixture] + public class ParallelRenderTreeTests + { + [Test] + public void ComputingStylesInParallelGivesTheSameRenderTree() + { + var html = new StringBuilder(""); + + for (var i = 0; i < 40; i++) + { + html.Append($"
Link {i}x"); + html.Append($"
  • a
  • b
  • c
"); + html.Append(i % 10 == 0 ? "
light
" : "

text

"); + html.Append("
"); + } + + html.Append(""); + var document = CssConstructionFunctions.ParseDocument(html.ToString()); + Assert.Greater(document.All.Length, 300); + + var parallel = Describe(document); + + AppContext.SetSwitch(RenderTreeBuilder.DisableParallelismSwitch, true); + + try + { + Assert.AreEqual(Describe(document), parallel); + } + finally + { + AppContext.SetSwitch(RenderTreeBuilder.DisableParallelismSwitch, false); + } + } + + private static String Describe(IDocument document) + { + var root = (ElementRenderNode)document.DefaultView.Render(new PlainRenderDevice()); + var result = new StringBuilder(); + Describe(root, result); + return result.ToString(); + } + + private static void Describe(IRenderNode node, StringBuilder result) + { + if (node is ElementRenderNode element) + { + result.Append(element.Ref.LocalName).Append(" { ").Append(element.SpecifiedStyle.CssText).Append(" } { ") + .Append(element.ComputedStyle.CssText).AppendLine(" }"); + } + + foreach (var child in node.Children) + { + Describe(child, result); + } + } + } +} diff --git a/src/AngleSharp.Css.Tests/Styling/PartialRenderTreeTests.cs b/src/AngleSharp.Css.Tests/Styling/PartialRenderTreeTests.cs index f80a86ca..7a455264 100644 --- a/src/AngleSharp.Css.Tests/Styling/PartialRenderTreeTests.cs +++ b/src/AngleSharp.Css.Tests/Styling/PartialRenderTreeTests.cs @@ -74,6 +74,45 @@ public void ConcurrentFirstReadsOfTheChildrenGetTheSameOnes() CollectionAssert.AreEqual((System.Collections.IEnumerable)results[1], partial.Children.Where(m => m.Ref is not IPseudoElement)); } + [Test] + public void APartialTreeOfTheDocumentWithItsStylesIsTheRenderedTree() + { + var document = Parse(); + var expected = (ElementRenderNode)document.DefaultView.Render(); + var styles = document.DefaultView.GetStyleCollection(new DefaultRenderDevice { ViewPortWidth = 1000, ViewPortHeight = 800 }); + var partial = document.DefaultView.RenderPartial(styles); + + AssertSame(expected, partial); + } + + [Test] + public void APartialTreeOfTheDocumentTellsAboutTheChildrenItBuilds() + { + var document = Parse(); + var styles = document.DefaultView.GetStyleCollection(new DefaultRenderDevice { ViewPortWidth = 1000, ViewPortHeight = 800 }); + var built = new System.Collections.Generic.List<(ElementRenderNode, System.Collections.Generic.IList)>(); + var partial = document.DefaultView.RenderPartial(styles, (node, children) => built.Add((node, children.ToList()))); + var body = partial.Children.OfType().Single(m => m.Ref.LocalName == "body"); + var bodyChildren = body.Children.ToList(); + _ = partial.Children.ToList(); + + // the ones read, once; not the ones of the elements in the body + CollectionAssert.AreEqual(new[] { partial, body }, built.Select(m => m.Item1)); + CollectionAssert.AreEqual(partial.Children, built[0].Item2); + CollectionAssert.AreEqual(bodyChildren, built[1].Item2); + } + + [Test] + public void TheDocumentRenderedWithItsStylesIsTheRenderedTree() + { + var document = Parse(); + var expected = (ElementRenderNode)document.DefaultView.Render(); + var styles = document.DefaultView.GetStyleCollection(new DefaultRenderDevice { ViewPortWidth = 1000, ViewPortHeight = 800 }); + var actual = document.DefaultView.RenderDocument(styles); + + AssertSame(expected, actual); + } + private static bool IsGenerated(IRenderNode node) => node.Ref is IPseudoElement || node.Ref.Parent is null; private static void AssertSame(IRenderNode expected, IRenderNode actual) diff --git a/src/AngleSharp.Css.Tests/Styling/ShadowStyleCollectionTests.cs b/src/AngleSharp.Css.Tests/Styling/ShadowStyleCollectionTests.cs new file mode 100644 index 00000000..bb09bd37 --- /dev/null +++ b/src/AngleSharp.Css.Tests/Styling/ShadowStyleCollectionTests.cs @@ -0,0 +1,32 @@ +#nullable disable +namespace AngleSharp.Css.Tests.Styling +{ + using AngleSharp.Css.Dom; + using AngleSharp.Dom; + using NUnit.Framework; + + /// + /// The elements of a shadow tree are styled by the style sheets of their shadow root, which + /// the style collection of the document does not have. + /// + [TestFixture] + public class ShadowStyleCollectionTests + { + [Test] + public void TheStyleCollectionOfAShadowRootStylesItsElementsAndPseudoElements() + { + var document = CssConstructionFunctions.ParseDocument( + "" + + "
"); + var shadowRoot = document.GetElementById("host").ShadowRoot; + var styles = shadowRoot.GetStyleCollection(new DefaultRenderDevice()); + + var paragraph = shadowRoot.QuerySelector("p"); + var input = shadowRoot.QuerySelector("input"); + + Assert.AreEqual("rgba(0, 128, 0, 1)", styles.ComputeDeclarations(paragraph).GetPropertyValue("color")); + Assert.AreEqual("rgba(1, 2, 3, 1)", styles.ComputeDeclarations(input, "::placeholder").GetPropertyValue("color")); + } + } +} diff --git a/src/AngleSharp.Css.Tests/Styling/StyleRuleIndexTests.cs b/src/AngleSharp.Css.Tests/Styling/StyleRuleIndexTests.cs index a74b71d4..0c5e7cfe 100644 --- a/src/AngleSharp.Css.Tests/Styling/StyleRuleIndexTests.cs +++ b/src/AngleSharp.Css.Tests/Styling/StyleRuleIndexTests.cs @@ -118,6 +118,103 @@ public void OnlyRulesThatMayMatchAreCandidates(string css, int count) Assert.AreEqual(count, candidates.Count); } + [TestCase(".a > * { color: green }", "

A

")] + [TestCase(".a > :first-child { color: green }", "

A

B

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

A

")] + [TestCase(".a > * > * { color: green }", "

A

")] + [TestCase(".a > * + * { color: green }", "

A

B

")] + [TestCase(".a > * ~ * { color: green }", "

A

B

")] + [TestCase(".a + * > * { color: green }", "

A

")] + [TestCase(".x .a + * > * { color: green }", "

A

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

A

")] + [TestCase(".a, .b > * { color: green }", "

A

")] + [TestCase("#x > * { color: green }", "

A

")] + [TestCase(":root > * > * { color: green }", "

A

")] + [TestCase("div > * { color: green }", "

A

")] + [TestCase(".a:hover > * { color: green }", "

A

", "a")] + [TestCase(".a > *::before, .a > * { color: green }", "

A

")] + public async Task RulesAreFoundViaTheParentOrAncestorOfTheirSubject(string css, string body, string forcedHover = null) + { + var context = BrowsingContext.New(Configuration.Default.WithCss()); + var document = await context.OpenAsync(req => req.Content($"{body}")); + + if (forcedHover is not null) + { + document.QuerySelector("." + forcedHover).SetPseudoClass("hover", true); + } + + var color = document.DefaultView.GetComputedStyle(document.GetElementById("t")).GetColor(); + + Assert.AreEqual(Green, color); + } + + [TestCase("* { color: blue } .a > * { color: red }", "

A

")] + [TestCase("* { color: blue } .a * { color: red }", "

A

")] + [TestCase("* { color: blue } .a + * { color: red }", "

A

")] + [TestCase("* { color: blue } .a span { color: red }", "
A
")] + public async Task RulesForOtherContextsDoNotApply(string css, string body) + { + var color = await ColorOfAsync(css, body); + + Assert.AreNotEqual("rgba(255, 0, 0, 1)", color); + } + + [TestCase(".a > * { color: red }", 1)] + [TestCase(".x > * { color: red }", 0)] + [TestCase(".a * { color: red }", 1)] + [TestCase(".b * { color: red }", 1)] + [TestCase(".x * { color: red }", 0)] + [TestCase(".b > * { color: red }", 0)] + [TestCase(".x + * { color: red }", 1)] + [TestCase(".x > * + * { color: red }", 0)] + [TestCase(".x span { color: red }", 0)] + [TestCase(".a p { color: red }", 1)] + [TestCase(":root > * > * > * { color: red }", 1)] + [TestCase(":root { color: red } html > * { color: blue }", 0)] + [TestCase(".x || p { color: red }", 1)] + public void OnlyRulesWhoseContextMayMatchAreCandidates(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 ForcedRootIsFoundViaTheRootGroup() + { + var document = CssConstructionFunctions.ParseDocument("

A

"); + var sheets = document.StyleSheets.OfType().ToList(); + var collection = new StyleCollection(Enumerable.Empty(), sheets, new PlainRenderDevice()); + var element = document.GetElementById("t"); + + Assert.AreEqual(0, collection.GetIndex().GetCandidates(element).Count); + Assert.AreEqual(1, collection.GetIndex().GetCandidates(document.DocumentElement).Count); + + element.SetPseudoClass("root", true); + + Assert.AreEqual(1, collection.GetIndex().GetCandidates(element).Count); + } + + [Test] + public async Task ContextOfTheShadowHostIsMatched() + { + var context = BrowsingContext.New(Configuration.Default.WithCss()); + var document = await context.OpenAsync(req => req.Content( + "
")); + var shadow = document.GetElementById("host").ShadowRoot; + var tree = document.DefaultView.Render(new PlainRenderDevice()); + var nodes = Flatten((ElementRenderNode)tree).ToArray(); + + Assert.AreEqual(Green, nodes.First(m => m.Ref == shadow.GetElementById("t")).ComputedStyle.GetPropertyValue("color")); + Assert.AreEqual(Green, nodes.First(m => m.Ref == shadow.GetElementById("u")).ComputedStyle.GetPropertyValue("background-color")); + } + [Test] public void PseudoElementsAreOnlyGeneratedWithContent() { @@ -145,6 +242,9 @@ public void PseudoElementsAreOnlyGeneratedWithContent() [TestCase("p::before { content: 'x' }", "
A
", false)] [TestCase(".a:before { content: 'x' }", "

A

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

A

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

A

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

A

", false)] + [TestCase(".a span::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) diff --git a/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs b/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs index 0663fbcb..ba09a3a2 100644 --- a/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs +++ b/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs @@ -20,6 +20,9 @@ sealed class CssStyleDeclaration : ICssStyleDeclaration #region Fields private List _declarations; + // where the declaration of a name is; null as long as there are just a few, which are + // found by a scan (see Find): there is a declaration for the computed and the specified + // style of every element, which live as long as the render tree private Dictionary _declarationIndex; private readonly IBrowsingContext _context; private Boolean _reportsInitialValues; @@ -49,7 +52,6 @@ sealed class CssStyleDeclaration : ICssStyleDeclaration public CssStyleDeclaration(IBrowsingContext context) { _declarations = new List(); - _declarationIndex = new Dictionary(StringComparer.Ordinal); _context = context; } @@ -60,10 +62,25 @@ public CssStyleDeclaration(IBrowsingContext context) internal CssStyleDeclaration(IBrowsingContext context, Int32 capacity) { _declarations = new List(capacity); - _declarationIndex = new Dictionary(capacity, StringComparer.Ordinal); + _declarationIndex = capacity > MaxUnindexed ? new Dictionary(capacity, StringComparer.Ordinal) : null; _context = context; } + /// + /// Gives back the room of a declaration created with more capacity than it ended up + /// using (see the constructor), e.g. a cascade where many declarations replace each + /// other. It lives as long as the render tree, and would be copied with all its room + /// by every garbage collection meanwhile. + /// + internal void TrimExcess() + { + if (_snapshot is null && _declarations.Capacity > 2 * _declarations.Count + 8) + { + _declarations.TrimExcess(); + _declarationIndex = _declarations.Count > MaxUnindexed ? CreateIndex(_declarations, _declarationIndex) : null; + } + } + public CssStyleDeclaration(ICssRule parent) : this(parent.Owner?.Context) { @@ -172,7 +189,7 @@ public void Update(String value) _inherited = null; _forced = null; _declarations.Clear(); - _declarationIndex.Clear(); + _declarationIndex = null; if (!String.IsNullOrEmpty(value)) { @@ -325,7 +342,7 @@ public String GetPropertyValue(String propertyName) return used; } - var value = GetProperty(propertyName)?.Value; + var value = GetPropertyValueCore(propertyName); if (String.IsNullOrEmpty(value) && _reportsInitialValues) { @@ -335,6 +352,30 @@ public String GetPropertyValue(String propertyName) return value ?? String.Empty; } + /// + /// Gets the value as GetProperty would, without creating the empty property of an + /// undeclared longhand: a renderer asks for many properties that most elements never + /// declare, such as counter-set. + /// + private String GetPropertyValueCore(String name) + { + name = name.StartsWith("--", StringComparison.Ordinal) ? name : name.ToLowerFast(); + var declaration = GetDeclaration(name) ?? GetPropertyFromShorthand(name); + + if (declaration is not null) + { + return declaration.Value; + } + + // only a shorthand can be put together from its longhands + if (_context.GetDeclarationInfo(name).Longhands.Length == 0) + { + return String.Empty; + } + + return GetPropertyShorthand(name)?.Value; + } + /// /// Gets the copy script sees of a computed style, where nothing is left empty (CSSOM 9): /// a script compares with the initial values, as with position !== 'static'. Layout on @@ -441,8 +482,8 @@ public void SetProperty(String propertyName, String propertyValue, String priori public void AddProperty(ICssProperty declaration) { EnsureWritable(); - _declarationIndex[declaration.Name] = _declarations.Count; _declarations.Add(declaration); + Indexed(declaration.Name, _declarations.Count - 1); } public void RemoveProperty(ICssProperty declaration) @@ -531,8 +572,8 @@ internal void ReplaceDeclaration(ICssProperty declaration) } else { - _declarationIndex[declaration.Name] = _declarations.Count; _declarations.Add(declaration); + Indexed(declaration.Name, _declarations.Count - 1); } } @@ -637,7 +678,7 @@ private void EnsureWritable() if (_snapshot is not null) { _declarations = new List(_declarations); - _declarationIndex = new Dictionary(_declarationIndex, StringComparer.Ordinal); + _declarationIndex = _declarationIndex is null ? null : new Dictionary(_declarationIndex, StringComparer.Ordinal); _forced = _forced is null ? null : new HashSet(_forced, StringComparer.Ordinal); _snapshot = null; } @@ -652,10 +693,10 @@ private void Materialize() if (_inherited is not null) { var declarations = new List(_declarations); - var index = new Dictionary(_declarationIndex, StringComparer.Ordinal); + var index = CreateIndex(_declarations, _declarationIndex); Snapshot.Merge(declarations, index, _inherited.GetDeclarations(), _forced); _declarations = declarations; - _declarationIndex = index; + _declarationIndex = declarations.Count > MaxUnindexed ? index : null; _inherited = null; _forced = null; _snapshot = null; @@ -672,8 +713,10 @@ private sealed class Snapshot private readonly Snapshot _parent; private readonly HashSet _forced; private List _merged; - // the names looked up through this snapshot, with the nearest declaration found + // the names looked up through this snapshot, with the nearest declaration found: + // the first _nearestCount entries are set (see AddNearest) private NearestEntry[] _nearest; + private Int32 _nearestCount; public Snapshot(List declarations, Dictionary index, Snapshot parent, HashSet forced) { @@ -729,12 +772,12 @@ public List GetDeclarations() return _declarations; } - if (_merged is null) + if (Volatile.Read(ref _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) + for (var current = this; current._parent is not null && Volatile.Read(ref current._merged) is null; current = current._parent) { pending.Push(current); } @@ -743,13 +786,14 @@ public List GetDeclarations() { var current = pending.Pop(); var declarations = new List(current._declarations); - var index = new Dictionary(current._index, StringComparer.Ordinal); + var index = CreateIndex(current._declarations, current._index); Merge(declarations, index, current._parent.GetDeclarations(), current._forced); - current._merged = declarations; + // a computed style may be read from several threads: the first list wins + Interlocked.CompareExchange(ref current._merged, declarations, null); } } - return _merged; + return Volatile.Read(ref _merged); } public static void Merge(List declarations, Dictionary index, List inherited, HashSet forced) @@ -798,11 +842,15 @@ private ICssProperty Nearest(String name) private Boolean TryGetNearest(String name, out ICssProperty declaration) { + // the count is published after the entries it covers (see AddNearest) + var count = Volatile.Read(ref _nearestCount); var entries = Volatile.Read(ref _nearest); if (entries is not null) { - for (var i = 0; i < entries.Length; i++) + count = Math.Min(count, entries.Length); + + for (var i = 0; i < count; i++) { if (String.Equals(entries[i].Name, name, StringComparison.Ordinal)) { @@ -818,13 +866,14 @@ private Boolean TryGetNearest(String name, out ICssProperty declaration) private void AddNearest(String name, ICssProperty declaration) { - // only a few names are looked up, so the entries are copied on write; a - // concurrent reader of the same computed style sees either list - var entries = Volatile.Read(ref _nearest); - - while (true) + // a computed style may be read from several threads: readers never lock, as an + // entry is written before the count including it is published, and an array is + // only replaced by a larger copy (instead of a copy per name, which made looking + // up the few dozen names of a renderer quadratic per element) + lock (this) { - var count = entries?.Length ?? 0; + var entries = _nearest; + var count = _nearestCount; for (var i = 0; i < count; i++) { @@ -834,22 +883,24 @@ private void AddNearest(String name, ICssProperty declaration) } } - var updated = new NearestEntry[count + 1]; - - if (count > 0) + if (entries is null || count == entries.Length) { - Array.Copy(entries, updated, count); - } + var grown = new NearestEntry[Math.Max(8, count * 2)]; - updated[count] = new NearestEntry(name, declaration); - var previous = Interlocked.CompareExchange(ref _nearest, updated, entries); + if (count > 0) + { + Array.Copy(entries, grown, count); + } - if (previous == entries) + grown[count] = new NearestEntry(name, declaration); + Volatile.Write(ref _nearest, grown); + } + else { - return; + entries[count] = new NearestEntry(name, declaration); } - entries = previous; + Volatile.Write(ref _nearestCount, count + 1); } } @@ -869,7 +920,9 @@ public NearestEntry(String name, ICssProperty declaration) private ICssProperty FindOwn(String name) { - if (_index.TryGetValue(name, out var i) && i < _declarations.Count) + var i = CssStyleDeclaration.Find(_declarations, _index, name, _declarations.Count); + + if (i >= 0) { var declaration = _declarations[i]; @@ -943,8 +996,9 @@ private void RemovePropertyByName(String propertyName) Materialize(); EnsureWritable(); - if (_declarationIndex.TryGetValue(propertyName, out var index) && index < _declarations.Count && - _declarations[index].Name.Is(propertyName)) + var index = Find(_declarations, _declarationIndex, propertyName, _declarations.Count); + + if (index >= 0 && _declarations[index].Name.Is(propertyName)) { _declarations.RemoveAt(index); RebuildIndex(); @@ -996,7 +1050,7 @@ private void ChangeDeclarations(IEnumerable decls, Predicate decls, Predicate m is null); - RebuildIndex(); + // compacted in place: only the entries that moved need a new index, the index + // is not cleared (it may have room for thousands of entries, e.g., the custom + // properties of :root) and built again for every rule replacing a declaration + var write = 0; + + for (var read = 0; read < _declarations.Count; read++) + { + var declaration = _declarations[read]; + + if (declaration is not null) + { + if (write != read) + { + _declarations[write] = declaration; + + if (_declarationIndex is not null) + { + _declarationIndex[declaration.Name] = write; + } + } + else if (read >= count && _declarationIndex is not null) + { + _declarationIndex[declaration.Name] = write; + } + + write++; + } + } + + _declarations.RemoveRange(write, _declarations.Count - write); + + if (_declarationIndex is null && _declarations.Count > MaxUnindexed) + { + RebuildIndex(); + } } - else + else if (_declarationIndex is not null) { for (var i = count; i < _declarations.Count; i++) { _declarationIndex[_declarations[i].Name] = i; } } + else if (_declarations.Count > MaxUnindexed) + { + RebuildIndex(); + } } /// @@ -1032,7 +1123,9 @@ private void ChangeDeclarations(IEnumerable decls, Predicate private Int32 IndexOf(String name, Int32 count) { - if (_declarationIndex.TryGetValue(name, out var index) && index < count) + var index = Find(_declarations, _declarationIndex, name, count); + + if (index >= 0) { var declaration = _declarations[index]; @@ -1048,8 +1141,9 @@ private Int32 IndexOf(String name, Int32 count) private void SetLonghand(ICssProperty property) { EnsureWritable(); + var index = Find(_declarations, _declarationIndex, property.Name, _declarations.Count); - if (_declarationIndex.TryGetValue(property.Name, out var index) && index < _declarations.Count) + if (index >= 0) { var declaration = _declarations[index]; @@ -1065,8 +1159,8 @@ private void SetLonghand(ICssProperty property) } } - _declarationIndex[property.Name] = _declarations.Count; _declarations.Add(property); + Indexed(property.Name, _declarations.Count - 1); } private void SetShorthand(ICssProperty shorthand) @@ -1118,14 +1212,91 @@ private ICssProperty GetPropertyFromShorthand(String name) return null; } + // below, a scan of the declarations is about as fast as an index, which is a lot larger + private const Int32 MaxUnindexed = 16; + private void RebuildIndex() { - _declarationIndex.Clear(); + if (_declarations.Count <= MaxUnindexed) + { + _declarationIndex = null; + return; + } + + var index = _declarationIndex ?? new Dictionary(_declarations.Count, StringComparer.Ordinal); + index.Clear(); for (var i = 0; i < _declarations.Count; i++) { - _declarationIndex[_declarations[i].Name] = i; + index[_declarations[i].Name] = i; + } + + _declarationIndex = index; + } + + /// + /// Takes the declaration just added at the given position into the index, which is + /// created once there are too many to scan. + /// + private void Indexed(String name, Int32 position) + { + if (_declarationIndex is not null) + { + _declarationIndex[name] = position; + } + else if (_declarations.Count > MaxUnindexed) + { + RebuildIndex(); + } + } + + /// + /// Finds the position of the declaration with the given name among the first count + /// ones: by the index, or by a scan when there is none. The index has the last + /// position set for a name, hence the scan starts at the end. + /// + private static Int32 Find(List declarations, Dictionary index, String name, Int32 count) + { + if (index is not null) + { + return index.TryGetValue(name, out var position) && position < count ? position : -1; + } + + for (var i = Math.Min(count, declarations.Count) - 1; i >= 0; i--) + { + var declaration = declarations[i]; + + if (declaration is not null && String.Equals(declaration.Name, name, StringComparison.Ordinal)) + { + return i; + } } + + return -1; + } + + /// + /// Creates a copy of the index (with room for more), or an index of the declarations + /// if there is none. + /// + private static Dictionary CreateIndex(List declarations, Dictionary index) + { + if (index is not null) + { + return new Dictionary(index, StringComparer.Ordinal); + } + + var created = new Dictionary(declarations.Count, StringComparer.Ordinal); + + for (var i = 0; i < declarations.Count; i++) + { + if (declarations[i] is { } declaration) + { + created[declaration.Name] = i; + } + } + + return created; } private void RaiseChanged() diff --git a/src/AngleSharp.Css/Dom/Internal/ForcingPseudoClassSelector.cs b/src/AngleSharp.Css/Dom/Internal/ForcingPseudoClassSelector.cs index 61cc950c..897c870b 100644 --- a/src/AngleSharp.Css/Dom/Internal/ForcingPseudoClassSelector.cs +++ b/src/AngleSharp.Css/Dom/Internal/ForcingPseudoClassSelector.cs @@ -20,6 +20,11 @@ public ForcingPseudoClassSelector(String name, ISelector inner) _inner = inner; } + /// + /// Gets the name of the pseudo-class. + /// + public String Name => _name; + public Priority Specificity => _inner.Specificity; public String Text => _inner.Text; diff --git a/src/AngleSharp.Css/Dom/Internal/LogicalProperties.cs b/src/AngleSharp.Css/Dom/Internal/LogicalProperties.cs index 6991c0a0..0f28ef2a 100644 --- a/src/AngleSharp.Css/Dom/Internal/LogicalProperties.cs +++ b/src/AngleSharp.Css/Dom/Internal/LogicalProperties.cs @@ -32,24 +32,30 @@ static class LogicalProperties public static void Apply(CssStyleDeclaration declarations, ICssComputeContext context) { List? logical = null; - var order = new Dictionary(StringComparer.OrdinalIgnoreCase); + var locals = declarations.GetLocalDeclarations(); // logical properties are not inherited, so only the local declarations matter - foreach (var property in declarations.GetLocalDeclarations()) + foreach (var property in locals) { - order[property.Name] = order.Count; - if (Sides.ContainsKey(property.Name) || Sizes.ContainsKey(property.Name)) { (logical ??= new List()).Add(property); } } + // most elements have none (and the order of thousands of custom properties is not needed) if (logical is null) { return; } + var order = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var property in locals) + { + order[property.Name] = order.Count; + } + var writingMode = declarations.GetPropertyValue(PropertyNames.WritingMode); var vertical = writingMode.StartsWith("vertical", StringComparison.OrdinalIgnoreCase) || writingMode.StartsWith("sideways", StringComparison.OrdinalIgnoreCase) || diff --git a/src/AngleSharp.Css/Dom/Internal/StyleRuleIndex.cs b/src/AngleSharp.Css/Dom/Internal/StyleRuleIndex.cs index 6134008e..df028575 100644 --- a/src/AngleSharp.Css/Dom/Internal/StyleRuleIndex.cs +++ b/src/AngleSharp.Css/Dom/Internal/StyleRuleIndex.cs @@ -2,6 +2,7 @@ namespace AngleSharp.Css.Dom { using AngleSharp.Css.Parser; using AngleSharp.Dom; + using AngleSharp.Text; using System; using System.Collections.Generic; using System.Linq; @@ -119,34 +120,20 @@ private static Candidates Collect(IElement element, KeyedRules keyed) // iterating. Only the (few) groups of the element are combined, if needed. List? first = null; List? combined = null; - var id = element.Id; + AddGroups(element, keyed, ref first, ref combined); - if (!String.IsNullOrEmpty(id) && keyed.Ids.TryGetValue(id!, out var rules)) + // the rules for children of an element with a key, e.g., .list > * + if (keyed.ParentRules is { } parentRules && element.ParentElement is { } parent) { - AddGroup(ref first, ref combined, rules); + AddGroups(parent, parentRules, ref first, ref combined); } - foreach (var name in element.ClassList) + // the rules for descendants of an element with a key, e.g., .prose * + if (keyed.AncestorRules is { } ancestorRules) { - if (keyed.Classes.TryGetValue(name, out rules)) + for (var ancestor = element.ParentElement; ancestor is not null; ancestor = ancestor.ParentElement) { - AddGroup(ref first, ref combined, rules); - } - } - - if (keyed.Tags.TryGetValue(element.LocalName, out rules)) - { - AddGroup(ref first, ref combined, rules); - } - - if (keyed.Attributes.Count > 0) - { - foreach (var attribute in element.Attributes) - { - if (keyed.Attributes.TryGetValue(attribute.Name, out rules)) - { - AddGroup(ref first, ref combined, rules); - } + AddGroups(ancestor, ancestorRules, ref first, ref combined); } } @@ -171,6 +158,56 @@ private static Candidates Collect(IElement element, KeyedRules keyed) return new Candidates(keyed.Ungrouped, first); } + /// + /// Adds the groups of the keys the element has. + /// + private static void AddGroups(IElement element, KeyedRules keyed, ref List? first, ref List? combined) + { + var id = element.Id; + + // e.g., the hundreds of rules declaring the custom properties of a design system + if (keyed.Root.Count > 0 && ((element.ParentElement is null && element.Owner?.DocumentElement == element) || + PseudoClassStateStore.TryGet(element, PseudoClassNames.Root, out _))) + { + AddGroup(ref first, ref combined, keyed.Root); + } + + if (!String.IsNullOrEmpty(id) && keyed.Ids.TryGetValue(id!, out var rules)) + { + AddGroup(ref first, ref combined, rules); + } + + if (keyed.Classes.Count > 0 && element.HasAttribute(AttributeNames.Class)) + { + // by index, as enumerating the list allocates (for every element and its ancestors) + var classes = element.ClassList; + + for (var i = 0; i < classes.Length; i++) + { + if (classes[i] is { } name && keyed.Classes.TryGetValue(name, out rules)) + { + AddGroup(ref first, ref combined, rules); + } + } + } + + if (keyed.Tags.TryGetValue(element.LocalName, out rules)) + { + AddGroup(ref first, ref combined, rules); + } + + if (keyed.Attributes.Count > 0) + { + foreach (var attribute in element.Attributes) + { + if (keyed.Attributes.TryGetValue(attribute.Name, out rules)) + { + AddGroup(ref first, ref combined, rules); + } + } + } + } + private static void AddGroup(ref List? first, ref List? combined, List rules) { if (first is null) @@ -271,8 +308,16 @@ private sealed class KeyedRules public Dictionary> Attributes { get; } = new(StringComparer.OrdinalIgnoreCase); + // the rules for :root, i.e., the document element + public List Root { get; } = new(); + public List Ungrouped { get; } = new(); + // the rules keyed by the parent or an ancestor of their subject (only the groups are used) + public KeyedRules? ParentRules { get; private set; } + + public KeyedRules? AncestorRules { get; private set; } + public void Add(SubjectKey[]? keys, Int32 index) { if (keys is null) @@ -283,6 +328,23 @@ public void Add(SubjectKey[]? keys, Int32 index) foreach (var key in keys) { + if (key.Relation != KeyRelation.Self) + { + var context = key.Relation == KeyRelation.Parent ? ParentRules ??= new KeyedRules() : AncestorRules ??= new KeyedRules(); + context.Add(new[] { new SubjectKey(key.Kind, key.Name) }, index); + continue; + } + + if (key.Kind == SubjectKeyKind.Root) + { + if (Root.Count == 0 || Root[Root.Count - 1] != index) + { + Root.Add(index); + } + + continue; + } + var map = key.Kind switch { SubjectKeyKind.Id => Ids, @@ -347,23 +409,38 @@ private static SelectorInfo Create(ICssStyleRule rule) => /// internal enum SubjectKeyKind { + Root, Id, Class, Tag, Attribute, } + /// + /// Which element has to have a key: the subject itself, or (if the subject requires + /// none) its parent or one of its ancestors, as with .list > * or .prose *. + /// + internal enum KeyRelation + { + Self, + Parent, + Ancestor, + } + internal readonly struct SubjectKey { - public SubjectKey(SubjectKeyKind kind, String name) + public SubjectKey(SubjectKeyKind kind, String name, KeyRelation relation = KeyRelation.Self) { Kind = kind; Name = name; + Relation = relation; } public SubjectKeyKind Kind { get; } public String Name { get; } + + public KeyRelation Relation { get; } } #endregion @@ -406,8 +483,13 @@ private SubjectKeyVisitor(ISelector selector, ICssSelectorParser? parser, Int32 private static SubjectKey[]? GetKeys(ISelector? selector, ICssSelectorParser? parser, Int32 depth) { - // a forced pseudo-class matches independent of its own selector - if (selector is null || selector is ForcingPseudoClassSelector) + // a forced pseudo-class matches independent of its own selector, which the + // root group considers (see Collect) + if (selector is ForcingPseudoClassSelector forcing && forcing.Name.Is(PseudoClassNames.Root)) + { + return new[] { new SubjectKey(SubjectKeyKind.Root, PseudoClassNames.Root) }; + } + else if (selector is null || selector is ForcingPseudoClassSelector) { return null; } @@ -446,6 +528,12 @@ void ISelectorVisitor.Child(String name, Int32 step, Int32 offset, ISelector sel void ISelectorVisitor.PseudoClass(String name) { + if (name.Is(PseudoClassNames.Root)) + { + Set(SubjectKeyKind.Root, name); + return; + } + // :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 = _depth < MaxArgumentDepth ? GetArguments(name) : null; @@ -488,7 +576,93 @@ void ISelectorVisitor.List(IEnumerable selectors) void ISelectorVisitor.Combinator(IEnumerable selectors, IEnumerable symbols) { - _keys = GetKeys(selectors.LastOrDefault(), _parser, _depth); + var compounds = selectors.ToList(); + _keys = GetKeys(compounds.LastOrDefault(), _parser, _depth); + + // a subject without a key, or just a common one (e.g., the a of .menu a, or the + // [class] of .card [class*=icon]) is better found by the id or class of its context + if (compounds.Count > 1 && (_keys is null || _keys.All(key => key.Kind >= SubjectKeyKind.Tag)) && + GetContextKeys(compounds, symbols.ToList()) is { } context && + (_keys is null || context.All(key => key.Kind < SubjectKeyKind.Tag))) + { + _keys = context; + } + } + + /// + /// Finds the keys of the closest compound left of the subject that matches its parent + /// or one of its ancestors, e.g., .list in .list > :first-child. Going left, the + /// position of the matched element relative to the subject is tracked: it is the subject + /// or a sibling of it, its parent or a sibling of that, or an ancestor or a sibling of + /// one. Only the parent and the ancestors are looked up for an element (see Collect). + /// + private SubjectKey[]? GetContextKeys(List compounds, List symbols) + { + var position = Position.SelfOrSibling; + + for (var i = Math.Min(compounds.Count - 2, symbols.Count - 1); i >= 0; i--) + { + position = Move(position, symbols[i]); + + if (position == Position.Unknown) + { + return null; + } + + var relation = position switch + { + Position.Parent => KeyRelation.Parent, + Position.Ancestor => KeyRelation.Ancestor, + _ => KeyRelation.Self, + }; + + // a shadow host is matched from its shadow tree, but it is no parent element + if (relation != KeyRelation.Self && !compounds[i].Text.StartsWith(":host", StringComparison.OrdinalIgnoreCase)) + { + var keys = GetKeys(compounds[i], _parser, _depth); + + if (keys is not null && keys.All(key => key.Relation == KeyRelation.Self)) + { + return keys.Select(key => new SubjectKey(key.Kind, key.Name, relation)).ToArray(); + } + } + } + + return null; + } + + private static Position Move(Position position, String combinator) + { + if (combinator == CombinatorSymbols.Child) + { + return position == Position.SelfOrSibling ? Position.Parent : Position.Ancestor; + } + else if (combinator == CombinatorSymbols.Descendant) + { + return Position.Ancestor; + } + else if (combinator == CombinatorSymbols.Adjacent || combinator == CombinatorSymbols.Sibling) + { + return position switch + { + Position.SelfOrSibling => Position.SelfOrSibling, + Position.Parent or Position.ParentSibling => Position.ParentSibling, + _ => Position.AncestorSibling, + }; + } + + // e.g., a column or a shadow-piercing combinator + return Position.Unknown; + } + + private enum Position + { + Unknown, + SelfOrSibling, + Parent, + ParentSibling, + Ancestor, + AncestorSibling, } void ISelectorVisitor.Many(IEnumerable selectors) @@ -531,9 +705,20 @@ private void Set(SubjectKeyKind kind, String name) } } - // a single key is more selective than a list of keys - private static Boolean IsBetter(SubjectKey[] keys, SubjectKey[] current) => - keys.Length < current.Length || (keys.Length == 1 && current.Length == 1 && keys[0].Kind < current[0].Kind); + // a key of the element itself is more selective than one of an ancestor, and a single + // key is more selective than a list of keys + private static Boolean IsBetter(SubjectKey[] keys, SubjectKey[] current) + { + var own = keys.All(key => key.Relation == KeyRelation.Self); + var currentOwn = current.All(key => key.Relation == KeyRelation.Self); + + if (own != currentOwn) + { + return own; + } + + return keys.Length < current.Length || (keys.Length == 1 && current.Length == 1 && keys[0].Kind < current[0].Kind); + } } #endregion diff --git a/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs b/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs index af47d2a7..a4f0ed74 100644 --- a/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs +++ b/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs @@ -34,6 +34,24 @@ public static IStyleCollection GetStyleCollection(this IWindow window, IRenderDe return new StyleCollection(defaultSheets, currentSheets, renderDevice); } + /// + /// Generates the style collection for the tree of the given shadow root: its elements are + /// styled by the rules of its own style sheets (and the user agent's), scoped to its host, + /// like the render tree styles them. + /// + /// The shadow root whose elements are styled. + /// The device to get the style collection for. + /// The device-bound style collection. + public static IStyleCollection GetStyleCollection(this IShadowRoot shadowRoot, IRenderDevice renderDevice) + { + var host = shadowRoot.Host; + var ctx = host.Owner?.Context ?? throw new InvalidOperationException("The shadow root must be associated with a browsing context."); + var defaultStyleSheetProvider = ctx.GetServices(); + var defaultSheets = defaultStyleSheetProvider.Select(m => m.Default).Where(m => m != null).ToList(); + var shadowSheets = shadowRoot.GetEffectiveStyleSheets().ToList(); + return new StyleCollection(defaultSheets, shadowSheets, renderDevice, host); + } + /// /// Computes the declarations for the given element in the context of /// the specified styling rules. @@ -158,18 +176,23 @@ public static ICssStyleDeclaration ComputeExplicitStyle(this IStyleCollection st computedStyle.SetDeclarations(element.GetStyle()); } + computedStyle.TrimExcess(); return computedStyle; } /// /// Computes the declarations for the given element using a pre-computed - /// parent style for inheritance, avoiding the O(depth) ancestor walk. + /// parent style for inheritance, avoiding the O(depth) ancestor walk of + /// , e.g., with the computed style of a + /// node of a render tree. For a pseudo-element (see + /// ) the parent is the element + /// it belongs to. /// /// The styles to use. /// The element that is questioned. /// The parent's already-computed style. /// The style declaration containing all the declarations. - internal static ICssStyleDeclaration ComputeDeclarationsWithParent(this IStyleCollection styles, IElement element, ICssStyleDeclaration parentComputedStyle) + public static ICssStyleDeclaration ComputeDeclarationsWithParent(this IStyleCollection styles, IElement element, ICssStyleDeclaration parentComputedStyle) { var ctx = element.Owner?.Context; var explicitStyle = styles.ComputeExplicitStyle(element); diff --git a/src/AngleSharp.Css/Extensions/WindowExtensions.cs b/src/AngleSharp.Css/Extensions/WindowExtensions.cs index 5d1df9d1..56c7fe25 100644 --- a/src/AngleSharp.Css/Extensions/WindowExtensions.cs +++ b/src/AngleSharp.Css/Extensions/WindowExtensions.cs @@ -6,6 +6,7 @@ namespace AngleSharp.Dom using AngleSharp.Css.Dom; using AngleSharp.Css.RenderTree; using System; + using System.Collections.Generic; using System.Linq; /// @@ -192,6 +193,22 @@ public static IRenderNode Render(this IWindow window, IRenderDevice? renderDevic return builder.RenderDocument(renderDevice); } + /// + /// Renders the currently available document into a render tree like + /// , but with the given styles of the window + /// (see ) + /// instead of gathering and indexing its rules anew, e.g. the ones that the styles of + /// some elements were computed with already. + /// + /// The window to extend. + /// The styles of the window to use. + /// The created render node. + public static IRenderNode RenderDocument(this IWindow window, IStyleCollection styles) + { + var builder = RenderTreeBuilder.GetInstance(window); + return builder.RenderDocument(styles); + } + /// /// Renders the element and its subtree again, below the render node of its parent from an /// earlier render tree, which stays as it is. This is for a change confined to the subtree @@ -211,6 +228,44 @@ public static ElementRenderNode Render(this IWindow window, IElement element, El return builder.RenderElement(element, parent, renderDevice ?? builder.DefaultDevice); } + /// + /// Renders the element below the render node of its parent like + /// , but with the given + /// styles of the window (see ) + /// instead of gathering them anew, and the children of a node are only built when they + /// are read, e.g. to find out if a change of state (like hovering) changes what is + /// shown of an element without styling all of its subtree. + /// + /// The window to extend. + /// The element to render. + /// The render node of the element's parent. + /// The styles of the window to use. + /// The created render node, whose parent is the given one. + public static ElementRenderNode RenderPartial(this IWindow window, IElement element, ElementRenderNode parent, IStyleCollection styles) + { + var builder = RenderTreeBuilder.GetInstance(window); + return builder.RenderPartial(element, parent, styles); + } + + /// + /// Renders the currently available document into a render tree whose children are only + /// built when they are read (like + /// ), with + /// the given styles of the window, e.g. to lay out just the part of a page that is in view + /// without styling the rest. The given action gets the children of each node once they are + /// built, before they are read, e.g. to complete them for the layout. The parts are built + /// from the DOM and style sheets of the moment they are read. + /// + /// The window to extend. + /// The styles of the window to use. + /// Gets a node and its children once they are built, if given. + /// The render node of the document element. + public static ElementRenderNode RenderPartial(this IWindow window, IStyleCollection styles, Action>? childrenBuilt = null) + { + var builder = RenderTreeBuilder.GetInstance(window); + return builder.RenderPartial(styles, childrenBuilt); + } + private static IRenderDevice GetRenderDevice(this IWindow window) => window.Document.Context.GetService() ?? new DefaultRenderDevice(); } diff --git a/src/AngleSharp.Css/Factories/DefaultDeclarationFactory.cs b/src/AngleSharp.Css/Factories/DefaultDeclarationFactory.cs index a4a072f3..a9ce100b 100644 --- a/src/AngleSharp.Css/Factories/DefaultDeclarationFactory.cs +++ b/src/AngleSharp.Css/Factories/DefaultDeclarationFactory.cs @@ -3,6 +3,7 @@ namespace AngleSharp.Css { using AngleSharp.Css.Declarations; using System; + using System.Collections.Concurrent; using System.Collections.Generic; /// @@ -10,6 +11,11 @@ namespace AngleSharp.Css /// public class DefaultDeclarationFactory : IDeclarationFactory { + // a page defines a few thousand at most, the cap only guards against unbounded growth + private const Int32 MaxCachedCustomProperties = 65536; + + private readonly ConcurrentDictionary _customProperties = new(StringComparer.Ordinal); + private readonly Dictionary _declarations = new(StringComparer.OrdinalIgnoreCase) { { @@ -3402,6 +3408,26 @@ public DeclarationInfo Create(String propertyName) return info; } + // custom properties are looked up constantly (e.g., inheriting the thousands of a + // design system), but each info is the same for the same name (unless a derived + // factory creates them otherwise) + if (propertyName is not null && propertyName.StartsWith("--", StringComparison.Ordinal) && GetType() == typeof(DefaultDeclarationFactory)) + { + if (_customProperties.TryGetValue(propertyName, out info)) + { + return info; + } + + info = CreateDefault(propertyName); + + if (_customProperties.Count < MaxCachedCustomProperties) + { + _customProperties.TryAdd(propertyName, info); + } + + return info; + } + return CreateDefault(propertyName); } } diff --git a/src/AngleSharp.Css/Parser/CssBuilder.cs b/src/AngleSharp.Css/Parser/CssBuilder.cs index 721f1659..46e706ae 100644 --- a/src/AngleSharp.Css/Parser/CssBuilder.cs +++ b/src/AngleSharp.Css/Parser/CssBuilder.cs @@ -852,6 +852,13 @@ public void CreateDeclarationWith(ICssStyleSheet owner, ICssProperties propertie CollectTrivia(owner, ref token); var value = CreateValue(ref token, out var important); + // a custom property may be empty (e.g. `--tw-blur: ;`), and is substituted as + // white space then, as CSS Variables 1 says: only for others it is missing + if (String.IsNullOrEmpty(value) && name.StartsWith("--", StringComparison.Ordinal)) + { + value = " "; + } + if (String.IsNullOrEmpty(value)) { RaiseErrorOccurred(CssParseError.ValueMissing, token.Position); diff --git a/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs b/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs index 0868ccba..6d60b1d3 100644 --- a/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs +++ b/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs @@ -12,9 +12,18 @@ namespace AngleSharp.Css.RenderTree using System.Linq; using System.Runtime.CompilerServices; using System.Threading; + using System.Threading.Tasks; sealed class RenderTreeBuilder { + // below, computing the styles on several threads costs more than it saves + private const Int32 MinElementsComputedInParallel = 200; + + /// + /// The name of the AppContext switch turning off computing styles on several threads. + /// + internal const String DisableParallelismSwitch = "AngleSharp.Css.DisableParallelism"; + private static readonly ConditionalWeakTable _windowRenderTrees = new(); private readonly IBrowsingContext _context; @@ -22,6 +31,9 @@ sealed class RenderTreeBuilder private readonly List _defaultSheets; private readonly Dictionary _cascadedStyles = new(); private readonly Dictionary _renderedNodes = new(); + // the explicit styles computed up front for RenderDocument (see ComputeExplicitStyles) + private Dictionary? _explicitStyles; + private StyleCollection? _explicitStylesCollection; private RenderTreeBuilder(IWindow window) { @@ -36,16 +48,89 @@ private RenderTreeBuilder(IWindow window) public static RenderTreeBuilder GetInstance(IWindow window) => _windowRenderTrees.GetValue(window, (IWindow w) => new RenderTreeBuilder(w)); - public ElementRenderNode RenderDocument(IRenderDevice? device = null) + public ElementRenderNode RenderDocument(IRenderDevice? device = null) => + RenderDocument(CreateStyleCollection(device ?? DefaultDevice)); + + /// + /// Renders the document with the given styles, which are gathered anew if they are not a + /// collection of this window's. + /// + public ElementRenderNode RenderDocument(IStyleCollection styles) { var document = _window.Document; var root = document.DocumentElement ?? throw new InvalidOperationException("The document does not have a root element."); - var collection = CreateStyleCollection(device ?? DefaultDevice); + var collection = styles as StyleCollection ?? CreateStyleCollection(styles.Device); _cascadedStyles.Clear(); _renderedNodes.Clear(); + _explicitStyles = ComputeExplicitStyles(root, collection); + _explicitStylesCollection = collection; + + try + { + return RenderElement(root, collection, null, null, null); + } + finally + { + _explicitStyles = null; + _explicitStylesCollection = null; + } + } + + /// + /// Matching the selectors and running the cascade of an element is independent of all other + /// elements, and makes up most of the time to build the render tree of a page, so it is done + /// for all elements of the document on all cores first. Only the inheritance needs the + /// parent's computed style, and is done while the tree is built. The DOM is just read. + /// + private static Dictionary? ComputeExplicitStyles(IElement root, StyleCollection collection) + { + var elements = new List(); + + // shadow hosts (and the shadow trees) are styled by more than the document's rules + foreach (var element in root.DescendantsAndSelf()) + { + if (element.ShadowRoot is null) + { + elements.Add(element); + } + } + + if (elements.Count < MinElementsComputedInParallel || Environment.ProcessorCount < 2 || + (AppContext.TryGetSwitch(DisableParallelismSwitch, out var isDisabled) && isDisabled)) + { + return null; + } + + // built once, before the threads read them + collection.GetIndex(); + collection.GetDefaultRuleCount(); + + var styles = new ICssStyleDeclaration?[elements.Count]; - return RenderElement(root, collection, null, null, null); + Parallel.For(0, elements.Count, i => + { + try + { + styles[i] = collection.ComputeExplicitStyle(elements[i]); + } + catch + { + // computed (and thrown) once more in order, as without computing it up front + } + }); + + var result = new Dictionary(elements.Count); + + for (var i = 0; i < elements.Count; i++) + { + if (styles[i] is { } style) + { + result[elements[i]] = style; + } + } + + return result; } public ICssStyleDeclaration GetElementStyle(IElement element, IRenderDevice? device = null) @@ -87,6 +172,19 @@ public ElementRenderNode RenderElement(IElement element, ElementRenderNode paren return RenderElement(element, collection, parent, parentSpecifiedStyle, parent.ComputedStyle); } + /// + /// Renders the element below the given node of its parent into a partial render tree (see + /// RenderPartial below) with the given styles, which are gathered anew if they are not a + /// collection of this window's. + /// + public ElementRenderNode RenderPartial(IElement element, ElementRenderNode parent, IStyleCollection styles) + { + var collection = styles as StyleCollection ?? CreateStyleCollection(styles.Device); + // the specified style of the parent as it is now, also if modified since + var parentSpecifiedStyle = parent.SpecifiedStyle is CssStyleDeclaration style ? style.Freeze() : Copy(parent.SpecifiedStyle); + return RenderElement(element, collection, parent, parentSpecifiedStyle, parent.ComputedStyle, partial: true); + } + /// /// Renders the element into a partial render tree: the children of a node are only /// built when they are read, so e.g. the content of a hidden element is never styled. @@ -102,6 +200,18 @@ public ElementRenderNode RenderPartial(IElement element, IRenderDevice device) return RenderElement(element, collection, null, null, parentComputedStyle, getInheritedStyle: getInheritedStyle, partial: true); } + /// + /// Renders the document into a partial render tree (see above) with the given styles, + /// which are gathered anew if they are not a collection of this window's. The given + /// action gets the children of each node once they are built, before they are read. + /// + public ElementRenderNode RenderPartial(IStyleCollection styles, Action>? childrenBuilt) + { + var root = _window.Document.DocumentElement ?? throw new InvalidOperationException("The document does not have a root element."); + var collection = styles as StyleCollection ?? CreateStyleCollection(styles.Device); + return RenderElement(root, collection, null, null, null, partial: true, childrenBuilt: childrenBuilt); + } + private CssStyleDeclaration Copy(ICssStyleDeclaration style) { var copy = new CssStyleDeclaration(_context); @@ -125,16 +235,24 @@ private ElementRenderNode RenderElement( ICssStyleDeclaration? explicitStyle = null, StyleCollection? slottedCollection = null, Func? getInheritedStyle = null, - Boolean partial = false) + Boolean partial = false, + Action>? childrenBuilt = null) { var shadowCollection = CreateShadowCollection(element, collection); + + if (explicitStyle is null && shadowCollection is null && _explicitStyles is not null && + ReferenceEquals(collection, _explicitStylesCollection) && _explicitStyles.TryGetValue(element, out var precomputed)) + { + explicitStyle = precomputed; + } + explicitStyle ??= shadowCollection is not null ? collection.ComputeHostStyle(shadowCollection, element) : collection.ComputeExplicitStyle(element); var computeContext = new CssComputeContext(collection.Device, _context, explicitStyle, parentComputedStyle); var computedStyle = explicitStyle.PrepareComputedDeclarations(parentComputedStyle!, computeContext).Compute(computeContext).Blockify(element); - var pending = partial ? new PendingChildren(this, collection, shadowCollection, slottedCollection, getInheritedStyle) : null; + var pending = partial ? new PendingChildren(this, collection, shadowCollection, slottedCollection, getInheritedStyle, childrenBuilt) : null; var children = partial ? null : new List(); // inherits the specified style of the parent node, unless another one is given var node = new ElementRenderNode(element, parent, children, explicitStyle, inheritedStyle, computedStyle, _context, pending); @@ -171,7 +289,7 @@ private void AddChildren(ElementRenderNode node, List children, Sty /// /// Adds the children of the node without its ::before and ::after. /// - private void AddContent(ElementRenderNode node, List children, StyleCollection collection, StyleCollection? shadowCollection, StyleCollection? slottedCollection, Boolean partial) + private void AddContent(ElementRenderNode node, List children, StyleCollection collection, StyleCollection? shadowCollection, StyleCollection? slottedCollection, Boolean partial, Action>? childrenBuilt = null) { var element = node.Ref; var computedStyle = node.ComputedStyle; @@ -192,7 +310,7 @@ private void AddContent(ElementRenderNode node, List children, Styl { if (child.Node is IElement childElement) { - children.Add(RenderElement(childElement, child.Collection, node, null, computedStyle, slottedCollection: child.SlottedCollection, partial: partial)); + children.Add(RenderElement(childElement, child.Collection, node, null, computedStyle, slottedCollection: child.SlottedCollection, partial: partial, childrenBuilt: childrenBuilt)); } else if (child.Node is IText childText) { @@ -211,11 +329,13 @@ internal sealed class PendingChildren private readonly StyleCollection _collection; private readonly StyleCollection? _shadowCollection; private readonly StyleCollection? _slottedCollection; + private readonly Action>? _childrenBuilt; private List? _content; private List? _children; - public PendingChildren(RenderTreeBuilder builder, StyleCollection collection, StyleCollection? shadowCollection, StyleCollection? slottedCollection, Func? getInheritedStyle) + public PendingChildren(RenderTreeBuilder builder, StyleCollection collection, StyleCollection? shadowCollection, StyleCollection? slottedCollection, Func? getInheritedStyle, Action>? childrenBuilt = null) { + _childrenBuilt = childrenBuilt; _builder = builder; _collection = collection; _shadowCollection = shadowCollection; @@ -240,7 +360,7 @@ public List GetContent(ElementRenderNode node) if (content is null) { content = new List(); - _builder.AddContent(node, content, _collection, _shadowCollection, _slottedCollection, true); + _builder.AddContent(node, content, _collection, _shadowCollection, _slottedCollection, true, _childrenBuilt); // another thread may have been faster: then its children are the ones of the node content = Interlocked.CompareExchange(ref _content, content, null) ?? content; } @@ -279,6 +399,8 @@ public IEnumerable GetChildren(ElementRenderNode node) children.Add(after); } + _childrenBuilt?.Invoke(node, children); + // another thread may have been faster: then its children are the ones of the node children = Interlocked.CompareExchange(ref _children, children, null) ?? children; } @@ -291,7 +413,7 @@ public IEnumerable GetChildren(ElementRenderNode node) /// public ElementRenderNode? BuildPseudoElement(ElementRenderNode node, String name) => node.Ref is IPseudoElement ? null : - _builder.RenderPseudoElement(node.Ref, name, _collection, node, node.ComputedStyle, true); + _builder.RenderPseudoElement(node.Ref, name, _collection, node, node.ComputedStyle, true, _childrenBuilt); } /// @@ -387,7 +509,8 @@ public FlatChild(INode node, StyleCollection collection, StyleCollection? slotte StyleCollection collection, ElementRenderNode parent, ICssStyleDeclaration parentComputedStyle, - Boolean partial) + Boolean partial, + Action>? childrenBuilt = null) { var pseudo = element.Pseudo(name); @@ -407,7 +530,7 @@ public FlatChild(INode node, StyleCollection collection, StyleCollection? slotte return null; } - var node = RenderElement(pseudo, collection, parent, null, parentComputedStyle, explicitStyle, partial: partial); + var node = RenderElement(pseudo, collection, parent, null, parentComputedStyle, explicitStyle, partial: partial, childrenBuilt: childrenBuilt); // Per spec, `content: none` (and the initial/unset `normal`, equivalent for these two // pseudo-elements specifically) means no box is generated at all - not merely an empty diff --git a/src/AngleSharp.Css/Values/CssCustomPropertyResolver.cs b/src/AngleSharp.Css/Values/CssCustomPropertyResolver.cs index 9c7f800f..21e79350 100644 --- a/src/AngleSharp.Css/Values/CssCustomPropertyResolver.cs +++ b/src/AngleSharp.Css/Values/CssCustomPropertyResolver.cs @@ -7,8 +7,10 @@ namespace AngleSharp.Css.Values sealed class CssCustomPropertyResolver { - private readonly Dictionary _values = new(StringComparer.Ordinal); + // created on demand: most elements declare no custom properties (but are computed with a resolver) + private Dictionary? _values; private readonly CssStyleDeclaration? _parent; + private Func? _resolve; public CssCustomPropertyResolver(IEnumerable properties, ICssProperties? parent = null) { @@ -22,12 +24,12 @@ public CssCustomPropertyResolver(IEnumerable properties, ICssPrope { if (property.Name.StartsWith("--", StringComparison.Ordinal)) { - _values[property.Name] = property.RawValue is CssInvalidValue ? null : property.RawValue; + SetValue(property.Name, property.RawValue is CssInvalidValue ? null : property.RawValue); } } } - var nodes = new Dictionary(StringComparer.Ordinal); + Dictionary? nodes = null; foreach (var property in properties) { @@ -37,7 +39,7 @@ public CssCustomPropertyResolver(IEnumerable properties, ICssPrope if (value is CssAnyValue { IsResolved: true }) { - _values[property.Name] = value; + SetValue(property.Name, value); continue; } @@ -53,15 +55,21 @@ public CssCustomPropertyResolver(IEnumerable properties, ICssPrope continue; } - _values[property.Name] = null; + SetValue(property.Name, null); if (values.Length > 0 && !String.Equals(keyword, CssKeywords.Initial, StringComparison.OrdinalIgnoreCase)) { + nodes ??= new Dictionary(StringComparer.Ordinal); nodes[property.Name] = new Node(property.Name, values); } } } + if (nodes is null) + { + return; + } + foreach (var node in nodes.Values) { foreach (var value in node.Values) @@ -142,11 +150,11 @@ public CssCustomPropertyResolver(IEnumerable properties, ICssPrope { foreach (var value in node.Values) { - var text = value.Substitute(Resolve); + var text = value.Substitute(_resolve ??= Resolve); if (text is not null) { - _values[node.Name] = new CssAnyValue(text, isResolved: true); + SetValue(node.Name, new CssAnyValue(text, isResolved: true)); break; } } @@ -164,9 +172,11 @@ void Enter(Node node) } } + private void SetValue(String name, ICssValue? value) => (_values ??= new Dictionary(StringComparer.Ordinal))[name] = value; + public ICssValue? Resolve(String name) { - if (_values.TryGetValue(name, out var value)) + if (_values is not null && _values.TryGetValue(name, out var value)) { return value; } diff --git a/src/AngleSharp.Css/Values/CssVariableValue.cs b/src/AngleSharp.Css/Values/CssVariableValue.cs index d1f65e4b..ea527c86 100644 --- a/src/AngleSharp.Css/Values/CssVariableValue.cs +++ b/src/AngleSharp.Css/Values/CssVariableValue.cs @@ -124,13 +124,28 @@ public String? Keyword return Text; } - var result = new StringBuilder(); - var fallbacks = new Stack(); + // substituted for every element using the value; the builder is taken while in use + var result = t_builder ?? new StringBuilder(); + t_builder = null; + result.Clear(); + Stack? fallbacks = null; var cursor = 0; + var text = Substitute(resolve, result, ref fallbacks, ref cursor); + t_builder = result.Capacity <= MaxPooledCapacity ? result : null; + return text; + } + + // one per thread, as Substitute is called for many elements on several threads + [ThreadStatic] + private static StringBuilder? t_builder; + private const Int32 MaxPooledCapacity = 4096; + + private String? Substitute(Func resolve, StringBuilder result, ref Stack? fallbacks, ref Int32 cursor) + { for (var i = 0; i < _tokens.Count; i++) { - if (fallbacks.Count > 0 && fallbacks.Peek() == i) + if (fallbacks is { Count: > 0 } && fallbacks.Peek() == i) { if (!Append(result, cursor, Offset(i)) || !Separate(result, CharAt(EndOffset(i)))) { @@ -177,7 +192,7 @@ public String? Keyword } i = reference.Fallback - 1; - fallbacks.Push(reference.End); + (fallbacks ??= new Stack()).Push(reference.End); } else { diff --git a/src/Directory.Build.props b/src/Directory.Build.props index bf95587e..e495b1d5 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.4.0 + 1.4.1 enable latest true