diff --git a/docs/general/02-Values.md b/docs/general/02-Values.md index c208cd27..9be72233 100644 --- a/docs/general/02-Values.md +++ b/docs/general/02-Values.md @@ -62,3 +62,32 @@ Console.WriteLine($"Computed font-size: {computedFontSize}"); - Shorthand values (e.g., `margin`, `background`) are decomposed internally to longhands. - Variables (`var(--x)`) may defer full resolution until cascade context is available. - Comparing raw source strings is often misleading; compare parsed or computed values instead. + +## Custom Properties At Computed-Value Time + +Custom properties are resolved only during style computation, for each element before +they are inherited. `GetDeclarations`, `ComputeExplicitStyle`, `ComputeCascadedStyle`, +and render-tree `SpecifiedStyle` retain the original variable expressions. Computed +results are separate declarations and do not rewrite stylesheet or inline values. + +During computation, an inherited +alias keeps the parent's resolved value; changing its dependencies on a child does not +resolve that alias again. A declaration explicitly matching both elements is resolved +locally on each element. + +Following [CSS Variables dependency-cycle rules](https://drafts.csswg.org/css-variables-1/#cycles), +every property in a cycle becomes guaranteed-invalid, including cycles through unused +fallbacks. A consuming `var(--name, fallback)` can recover from an invalid or missing +custom property. Without a usable fallback, the consuming declaration uses its inherited +or initial value, not an earlier declaration from the cascade. A valid custom-property +value that does not match the consumer's grammar does not trigger the `var()` fallback. + +Dependency analysis and fallback substitution are iterative, including deeply nested +fallbacks. The public parser still represents nested `var()` fallbacks as `CssVarValue` +objects, and direct `CssReferenceValue.Compute` calls honor the supplied `References` +array, including subsequent changes to its entries. + +Expanded values during style computation are limited to 1,048,576 UTF-16 code units (including token +separators) to bound exponential substitution; an expansion exceeding this limit is +invalid at computed-value time. Property-specific parsing, unit conversion, and layout +support still determine which resolved values can be used by a consuming property. diff --git a/src/AngleSharp.Css.Tests/Styling/CustomPropertyCompatibility.cs b/src/AngleSharp.Css.Tests/Styling/CustomPropertyCompatibility.cs new file mode 100644 index 00000000..d5f750e2 --- /dev/null +++ b/src/AngleSharp.Css.Tests/Styling/CustomPropertyCompatibility.cs @@ -0,0 +1,244 @@ +#nullable disable +namespace AngleSharp.Css.Tests.Styling +{ + using AngleSharp.Css.Dom; + using AngleSharp.Css.Parser; + using AngleSharp.Css.RenderTree; + using AngleSharp.Css.Values; + using AngleSharp.Dom; + using AngleSharp.Text; + using NUnit.Framework; + using System; + using System.Collections.Generic; + using System.Linq; + using static CssConstructionFunctions; + + [TestFixture] + public class CustomPropertyCompatibilityTests + { + [TestCase("visible", "visible")] + [TestCase("var(--b)", "hidden")] + public void OnlyComputedStylesResolveCustomProperties(String value, String expected) + { + using var document = ParseDocument("
"); + var element = document.QuerySelector("div"); + var child = document.QuerySelector("span"); + var styles = document.DefaultView.GetStyleCollection(new DefaultRenderDevice()); + var explicitStyle = styles.ComputeExplicitStyle(element); + var declarations = styles.GetDeclarations(element); + var cascade = styles.ComputeCascadedStyle(child, declarations); + var builder = RenderTreeBuilder.GetInstance(document.DefaultView); + var rendered = builder.RenderElement(element, styles.Device); + var renderedChild = rendered.Children.OfType().Single(); + + foreach (var raw in new[] { explicitStyle, declarations, cascade, styles.GetDeclarations(child), + rendered.SpecifiedStyle, renderedChild.SpecifiedStyle, builder.GetElementStyle(child) }) + { + Assert.AreEqual("var(--a)", raw.GetPropertyValue("--b")); + Assert.AreEqual("var(--b,hidden)", raw.GetPropertyValue("visibility")); + } + + Assert.AreEqual(expected, styles.ComputeDeclarations(child).GetPropertyValue("visibility")); + Assert.AreEqual(expected, renderedChild.ComputedStyle.GetPropertyValue("visibility")); + Assert.AreEqual("var(--a)", declarations.GetPropertyValue("--b")); + } + + [Test] + public void PublicFallbackParserPreservesNestedVariableObjects() + { + var source = new StringSource("var(--a,var(--b,red)))"); + var outer = source.ParseVarFallback() as CssVarValue; + Assert.IsNotNull(outer); + Assert.AreEqual("--a", outer.VariableName); + var inner = outer.DefaultValue as CssVarValue; + Assert.IsNotNull(inner); + Assert.AreEqual("--b", inner.VariableName); + Assert.AreEqual("red", inner.DefaultValue.CssText); + Assert.AreEqual("var(--a, var(--b, red))", outer.CssText); + Assert.AreEqual(')', source.Current); + } + + [Test] + public void ParsedReferencesPreserveNestedFallbackObjects() + { + var property = ParseDeclaration("visibility:var(--a,var(--b,var(--c,hidden)))"); + var reference = (CssReferenceValue)property.RawValue; + var second = reference.References[0].DefaultValue as CssVarValue; + Assert.IsNotNull(second); + Assert.AreEqual("--b", second.VariableName); + var third = second.DefaultValue as CssVarValue; + Assert.IsNotNull(third); + Assert.AreEqual("--c", third.VariableName); + Assert.AreEqual("hidden", third.DefaultValue.CssText); + } + + [Test] + public void PublicReferenceParserPreservesTheSourcePosition() + { + var source = new StringSource("var(--before) var(--after)"); + source.NextTo("var(--before) ".Length); + var index = source.Index; + var reference = source.ParseVars(); + Assert.AreEqual(index, source.Index); + Assert.AreEqual(1, reference.References.Length); + Assert.AreEqual("--after", reference.References[0].VariableName); + Assert.AreEqual("var(--before) var(--after)", reference.CssText); + Assert.AreEqual("after", ((ICssValue)reference).Compute(new TestComputeContext()).CssText); + } + + [TestCase("var(--a)", false)] + [TestCase("var(--a,)", true)] + [TestCase("var(--a, )", true)] + public void EmptyFallbacksAreDistinctFromAbsentFallbacks(String text, Boolean hasFallback) + { + var source = new StringSource(text); + var reference = (CssVarValue)source.ParseVarFallback(); + Assert.AreEqual("--a", reference.VariableName); + Assert.AreEqual(hasFallback, reference.DefaultValue is not null); + Assert.AreEqual(String.Empty, reference.DefaultValue?.CssText ?? String.Empty); + Assert.IsTrue(source.IsDone); + } + + [TestCase(false)] + [TestCase(true)] + public void DeepPublicFallbackTreesRemainIterative(Boolean parseDirectly) + { + const Int32 count = 8192; + var text = String.Concat(Enumerable.Repeat("var(--missing,", count)) + "visible" + new String(')', count); + var reference = parseDirectly ? (CssVarValue)new StringSource(text).ParseVarFallback() : + ((CssReferenceValue)ParseDeclaration("visibility:" + text).RawValue).References[0]; + var current = reference; + var depth = 1; + + while (current.DefaultValue is CssVarValue nested) + { + current = nested; + depth++; + } + + Assert.AreEqual(count, depth); + Assert.AreEqual("visible", current.DefaultValue.CssText); + Assert.AreEqual(text.Replace(",", ", "), reference.CssText); + var context = new TestComputeContext { Converter = ParseDeclaration("visibility:visible").Converter }; + Assert.AreEqual("visible", reference.Compute(context).CssText); + } + + [Test] + public void DirectReferenceComputationUsesSuppliedAndMutableReferences() + { + var reference = new CssReferenceValue("var(--literal)", new[] + { + Tuple.Create(new TextRange(default, default), new CssVarValue("--supplied")), + }); + var context = new TestComputeContext(); + Assert.AreEqual("supplied", ((ICssValue)reference).Compute(context).CssText); + reference.References[0] = new CssVarValue("--modified"); + Assert.AreEqual("modified", ((ICssValue)reference).Compute(context).CssText); + Assert.AreEqual("var(--literal)", reference.CssText); + } + + [Test] + public void DirectReferenceComputationRetainsFirstSuccessfulReference() + { + var reference = new CssReferenceValue("var(--literal)", new[] + { + Tuple.Create(new TextRange(default, default), new CssVarValue("--missing")), + Tuple.Create(new TextRange(default, default), new CssVarValue("--supplied")), + Tuple.Create(new TextRange(default, default), new CssVarValue("--unused")), + }); + var context = new TestComputeContext(); + Assert.AreEqual("supplied", ((ICssValue)reference).Compute(context).CssText); + CollectionAssert.AreEqual(new[] { "--missing", "--supplied" }, context.Names); + } + + [Test] + public void ModifiedParsedReferencesAffectComputedStyles() + { + using var document = ParseDocument("
"); + var element = document.QuerySelector("div"); + var reference = (CssReferenceValue)element.GetStyle().GetProperty("visibility").RawValue; + reference.References[0] = new CssVarValue("--b"); + Assert.AreEqual("hidden", element.ComputeCurrentStyle().GetPropertyValue("visibility")); + Assert.AreEqual("var(--a)", reference.CssText); + } + + [TestCase("--b", "hidden")] + [TestCase("--alias", "collapse")] + public void ModifiedCustomPropertyReferencesParticipateInResolution(String name, String expected) + { + using var document = ParseDocument("
"); + var element = document.QuerySelector("div"); + var reference = (CssReferenceValue)element.GetStyle().GetProperty("--alias").RawValue; + reference.References[0] = new CssVarValue(name); + Assert.AreEqual(expected, element.ComputeCurrentStyle().GetPropertyValue("visibility")); + Assert.AreEqual("var(--a)", element.GetStyle().GetPropertyValue("--alias")); + } + + [Test] + public void ConstructedCustomPropertyReferencesAreNotReparsedFromLiteralText() + { + using var document = ParseDocument("
"); + var element = document.QuerySelector("div"); + ((CssProperty)element.GetStyle().GetProperty("--alias")).RawValue = new CssReferenceValue("var(--a)", new[] + { + Tuple.Create(new TextRange(default, default), new CssVarValue("--missing")), + Tuple.Create(new TextRange(default, default), new CssVarValue("--b")), + }); + Assert.AreEqual("hidden", element.ComputeCurrentStyle().GetPropertyValue("visibility")); + } + + [Test] + public void ModifiedShorthandReferencesParticipateInResolution() + { + using var document = ParseDocument("
"); + var element = document.QuerySelector("div"); + var child = (CssChildValue)element.GetStyle().GetProperty("margin-top").RawValue; + var reference = (CssReferenceValue)child.Parent; + reference.References[0] = new CssVarValue("--b"); + var computed = element.ComputeCurrentStyle(); + Assert.AreEqual("3px", computed.GetPropertyValue("margin-top")); + Assert.AreEqual("4px", computed.GetPropertyValue("margin-right")); + } + + [Test] + public void DirectVariableComputationRetainsFallbackOnFailedComputation() + { + var reference = new CssVarValue("--invalid", new CssIdentifierValue("fallback")); + var context = new TestComputeContext(); + Assert.AreEqual("fallback", reference.Compute(context).CssText); + } + + [Test] + public void ComputationDoesNotSuppressValueExceptions() + { + var reference = new CssVarValue("--throw", new CssIdentifierValue("fallback")); + Assert.Throws(() => reference.Compute(new TestComputeContext())); + } + + private sealed class TestComputeContext : ICssComputeContext + { + public IRenderDevice Device { get; } = new DefaultRenderDevice(); + public IBrowsingContext Context => null; + public IValueConverter Converter { get; set; } + public List Names { get; } = new(); + + public ICssValue Resolve(String name) + { + Names.Add(name); + + if (name == "--invalid") + { + return new CssAnyValue("not a value"); + } + + if (name == "--throw") + { + throw new InvalidOperationException("Test exception"); + } + + return name == "--missing" ? null : new CssIdentifierValue(name.Substring(2)); + } + } + } +} diff --git a/src/AngleSharp.Css.Tests/Styling/CustomPropertyCycles.cs b/src/AngleSharp.Css.Tests/Styling/CustomPropertyCycles.cs new file mode 100644 index 00000000..0d9a912d --- /dev/null +++ b/src/AngleSharp.Css.Tests/Styling/CustomPropertyCycles.cs @@ -0,0 +1,415 @@ +#nullable disable +namespace AngleSharp.Css.Tests.Styling +{ + using AngleSharp.Css.Dom; + using AngleSharp.Css.RenderTree; + using AngleSharp.Css.Values; + using AngleSharp.Dom; + using NUnit.Framework; + using System; + using System.Collections; + using System.Collections.Generic; + using System.Linq; + using System.Text; + using System.Threading.Tasks; + using static CssConstructionFunctions; + + [TestFixture] + public class CustomPropertyCyclesTests + { + [Test] + public async Task OriginalReproductionDoesNotOverflow() + { + using var context = BrowsingContext.New(Configuration.Default.WithCss()); + using var document = await context.OpenAsync(response => response.Content( + "")); + var style = document.QuerySelector("button").ComputeCurrentStyle(); + Assert.AreEqual("rgba(0, 0, 0, 1)", style.GetPropertyValue("color")); + Assert.AreEqual(String.Empty, style.GetPropertyValue("--a")); + Assert.AreEqual(String.Empty, style.GetPropertyValue("--b")); + } + + [TestCase("--a:var(--a)")] + [TestCase("--a:var(--a,visible)")] + [TestCase("--a:var(--b);--b:var(--a)")] + [TestCase("--a:var(--b,visible);--b:var(--a,visible)")] + [TestCase("--a:var(--b);--b:var(--c);--c:var(--a)")] + [TestCase("--present:visible;--a:var(--present,var(--a))")] + [TestCase("--present:visible;--a:var(--present,calc(var(--a)))")] + [TestCase(@"--a:var(--\61,visible)")] + [TestCase(@"--a:v\61 r(--a,visible)")] + [TestCase("--a:var(--b,var(--c));--b:var(--a);--c:var(--b,visible)")] + public void EveryCyclicMemberIsInvalid(String declarations) + { + using var document = ParseDocument("
"); + var element = document.QuerySelector("div"); + element.SetAttribute("style", declarations + ";visibility:var(--a,hidden);--outside:var(--a,visible);display:block"); + var style = element.ComputeCurrentStyle(); + Assert.AreEqual("hidden", style.GetPropertyValue("visibility")); + Assert.AreEqual("visible", style.GetPropertyValue("--outside")); + Assert.AreEqual("block", style.GetPropertyValue("display")); + + foreach (var name in new[] { "--a", "--b", "--c" }) + { + Assert.AreEqual(String.Empty, style.GetPropertyValue(name), name); + } + } + + [TestCase("var(--missing,hidden)", "hidden")] + [TestCase("var(--missing,var(--other,hidden))", "hidden")] + [TestCase("var(--missing,var(--other,var(--third,hidden)))", "hidden")] + [TestCase("var(--missing)", "visible")] + [TestCase("var(--missing,)", "visible")] + public void MissingVariablesAndNestedFallbacks(String value, String expected) + { + using var document = ParseDocument("
"); + var element = document.QuerySelector("div"); + element.SetAttribute("style", "visibility:" + value); + Assert.AreEqual(expected, element.ComputeCurrentStyle().GetPropertyValue("visibility")); + } + + [TestCase("--a:var(--a)", "var(--a)")] + [TestCase("--a:12px", "var(--a,visible)")] + [TestCase("--a:var(--missing,)", "var(--a,visible)")] + public void InvalidAtComputedValueTimeUsesInheritanceNotPreviousDeclaration(String custom, String value) + { + using var document = ParseDocument("
"); + var element = document.QuerySelector("span"); + element.SetAttribute("style", custom + ";visibility:visible;visibility:" + value + ";width:10px;width:var(--missing)"); + var style = element.ComputeCurrentStyle(); + Assert.AreEqual("hidden", style.GetPropertyValue("visibility")); + Assert.AreEqual("auto", style.GetPropertyValue("width")); + } + + [Test] + public void ResolvedTokensAreNotConvertedUsingTheCustomPropertyConverter() + { + using var document = ParseDocument("
"); + var style = document.QuerySelector("div").ComputeCurrentStyle(); + Assert.AreEqual("0", style.GetPropertyValue("--a")); + Assert.AreEqual("0", style.GetPropertyValue("opacity")); + Assert.AreEqual("0", style.GetPropertyValue("width")); + } + + [TestCase("--a:red;--b:var(--a);--c:var(--a);--d:var(--b) var(--c)", "red red")] + [TestCase("--d:var(--missing,)", "")] + public void AcyclicDiamondsAndEmptyFallbacksRemainValid(String declarations, String expected) + { + using var document = ParseDocument("
"); + var element = document.QuerySelector("div"); + element.SetAttribute("style", declarations); + var style = element.ComputeCurrentStyle(); + Assert.AreEqual(expected, style.GetPropertyValue("--d")); + Assert.IsNotInstanceOf(style.GetProperty("--d").RawValue); + } + + [TestCase("--a:visible;--b:var(--a)", "--a:hidden", "visible")] + [TestCase("--a:var(--b);--b:var(--a)", "--a:visible", "hidden")] + [TestCase("--a:visible;--b:var(--a)", "--b:initial", "hidden")] + [TestCase("--a:visible;--b:var(--a)", "--a:hidden;--b:inherit", "visible")] + [TestCase("--a:visible;--b:var(--a)", "--a:hidden;--b:unset", "visible")] + public void InheritanceUsesTheParentsResolvedCustomValues(String parent, String child, String expected) + { + using var document = ParseDocument("
"); + document.QuerySelector("div").SetAttribute("style", parent); + var element = document.QuerySelector("span"); + element.SetAttribute("style", child + ";visibility:var(--b,hidden)"); + Assert.AreEqual(expected, element.ComputeCurrentStyle().GetPropertyValue("visibility")); + } + + [TestCase("var(--b)", "hidden")] + [TestCase("hidden", "hidden")] + [TestCase("visible", "visible")] + public void SharedRulesAreStillLocalDeclarations(String childValue, String expected) + { + using var document = ParseDocument( + "
Child
Sibling
"); + var child = document.QuerySelector("#c"); + var sibling = document.QuerySelector("#s"); + var parent = document.QuerySelector("#p"); + var styles = document.DefaultView.GetStyleCollection(new DefaultRenderDevice()); + var parentStyle = styles.ComputeDeclarations(parent); + + Assert.AreEqual(expected, child.ComputeCurrentStyle().GetPropertyValue("visibility")); + Assert.AreEqual(expected, styles.ComputeDeclarationsWithParent(child, parentStyle).GetPropertyValue("visibility")); + var rendered = RenderTreeBuilder.GetInstance(document.DefaultView).RenderElement(parent, styles.Device); + var renderedChild = rendered.Children.OfType().Single(node => node.Ref == child); + Assert.AreEqual(expected, renderedChild.ComputedStyle.GetPropertyValue("visibility")); + var cascade = styles.ComputeCascadedStyle(child, parentStyle); + Assert.AreEqual(expected, cascade.Compute(new CssComputeContext(styles.Device, document.Context, cascade, parentStyle)).GetPropertyValue("visibility")); + Assert.AreEqual("visible", sibling.ComputeCurrentStyle().GetPropertyValue("visibility")); + Assert.AreEqual("visible", parent.ComputeCurrentStyle().GetPropertyValue("visibility")); + Assert.AreEqual(expected, child.ComputeCurrentStyle().GetPropertyValue("visibility")); + + using var inlineDocument = ParseDocument("
" + + "
"); + Assert.AreEqual(expected, inlineDocument.QuerySelector("div div").ComputeCurrentStyle().GetPropertyValue("visibility")); + } + + [Test] + public void InheritedOrdinaryValuesAreNotRecomputedAgainstChildVariables() + { + using var document = ParseDocument("
"); + Assert.AreEqual("hidden", document.QuerySelector("span").ComputeCurrentStyle().GetPropertyValue("visibility")); + } + + [TestCase("initial", "visible")] + [TestCase("inherit", "hidden")] + [TestCase("unset", "hidden")] + public void SubstitutedCssWideKeywordsAreAppliedToConsumers(String keyword, String expected) + { + using var document = ParseDocument("
"); + var element = document.QuerySelector("span"); + var styles = document.DefaultView.GetStyleCollection(new DefaultRenderDevice()); + var computed = element.ComputeCurrentStyle(); + Assert.AreEqual(expected, computed.GetPropertyValue("visibility")); + Assert.AreEqual(keyword, computed.GetPropertyValue("--a")); + Assert.AreEqual(keyword, computed.Compute(new CssComputeContext(styles.Device, document.Context, computed)).GetPropertyValue("--a")); + } + + [TestCase("--a:var(--a);--a:visible", "visible")] + [TestCase("--a:var(--a)!important;--a:visible", "hidden")] + [TestCase("--a:visible;--a:var(--a)", "hidden")] + public void OnlyTheWinningDeclarationParticipatesInTheGraph(String text, String expected) + { + using var document = ParseDocument("
"); + Assert.AreEqual(expected, document.QuerySelector("div").ComputeCurrentStyle().GetPropertyValue("visibility")); + } + + [TestCase("--a:var(--a);margin:var(--a)", "0", "0")] + [TestCase("--a:var(--a);margin:var(--a,1px 2px)", "1px", "2px")] + [TestCase("--a:1px 2px;margin:var(--a)", "1px", "2px")] + [TestCase("--a:var(--a);margin:3px var(--a,4px)", "3px", "4px")] + public void ShorthandsUseTheCompleteSubstitutedValue(String text, String top, String right) + { + using var document = ParseDocument("
"); + var style = document.QuerySelector("div").ComputeCurrentStyle(); + Assert.AreEqual(top, style.GetPropertyValue("margin-top")); + Assert.AreEqual(right, style.GetPropertyValue("margin-right")); + Assert.AreEqual(top, style.GetPropertyValue("margin-bottom")); + Assert.AreEqual(right, style.GetPropertyValue("margin-left")); + } + + [TestCase("'var(--a)'")] + [TestCase("\"var(--a)\"")] + [TestCase("url('var(--a)')")] + [TestCase("visible /*var(--a)*/")] + [TestCase("myvar(--a)")] + public void LiteralVariableTextDoesNotCreateDependencies(String text) + { + var value = new CssVariableValue(text); + Assert.IsEmpty(value.Dependencies); + Assert.AreEqual(text, value.Substitute(_ => null)); + } + + [TestCase(@"var(--\61)", "--a")] + [TestCase(@"v\61 r(--a)", "--a")] + [TestCase("VAR(--A)", "--A")] + [TestCase("var(/*comment*/--a)", "--a")] + public void DependenciesUseDecodedCaseSensitiveNames(String text, String name) + { + var value = new CssVariableValue(text); + Assert.AreEqual(new[] { name }, value.Dependencies.ToArray()); + Assert.AreEqual("red", value.Substitute(n => n == name ? new CssAnyValue("red") : null)); + } + + [Test] + public void CustomNamesAreCaseSensitiveThroughoutCssomAndComputation() + { + using var document = ParseDocument("
"); + var element = document.QuerySelector("div"); + Assert.AreEqual("hidden", element.GetStyle().GetPropertyValue("--a")); + Assert.AreEqual("visible", element.GetStyle().GetPropertyValue("--A")); + Assert.AreEqual("visible", element.ComputeCurrentStyle().GetPropertyValue("visibility")); + element.GetStyle().RemoveProperty("--A"); + Assert.AreEqual("hidden", element.ComputeCurrentStyle().GetPropertyValue("--a")); + Assert.AreEqual(String.Empty, element.ComputeCurrentStyle().GetPropertyValue("--A")); + } + + [Test] + public void MutationAndPriorityDoNotChangeSharedDeclarationObjects() + { + using var document = ParseDocument("" + + "
"); + var element = document.QuerySelector("#a"); + var other = document.QuerySelector("#b"); + var sheet = (ICssStyleSheet)document.GetStyleSheets().Single(); + var source = sheet.Rules[0].CssText; + var inline = element.GetStyle().CssText; + Assert.AreEqual("hidden", element.ComputeCurrentStyle().GetPropertyValue("visibility")); + Assert.AreEqual(source, sheet.Rules[0].CssText); + Assert.AreEqual(inline, element.GetStyle().CssText); + element.GetStyle().SetProperty("--b", "visible"); + Assert.AreEqual("visible", element.ComputeCurrentStyle().GetPropertyValue("visibility")); + Assert.AreEqual("hidden", other.ComputeCurrentStyle().GetPropertyValue("visibility")); + Assert.AreEqual(source, sheet.Rules[0].CssText); + element.GetStyle().RemoveProperty("--b"); + Assert.AreEqual("hidden", element.ComputeCurrentStyle().GetPropertyValue("visibility")); + } + + [Test] + public void SubstitutionPreservesSurroundingTokensAndTokenBoundaries() + { + using var document = ParseDocument("
"); + var style = document.QuerySelector("div").ComputeCurrentStyle(); + Assert.AreEqual("rgba(255, 0, 0, 1)", style.GetPropertyValue("color")); + Assert.AreEqual("auto", style.GetPropertyValue("width")); + } + + [Test] + public void MatchingIsReusedAtEachInheritanceBoundary() + { + using var document = ParseDocument("
"); + var styles = new CountingStyleCollection(document.DefaultView.GetStyleCollection(new DefaultRenderDevice())); + var element = document.QuerySelector("span"); + styles.ComputeDeclarations(element); + Assert.AreEqual(element.GetAncestors().OfType().Count() + 1, styles.Enumerations); + var parent = styles.ComputeDeclarations(element.ParentElement); + var before = styles.Enumerations; + styles.ComputeDeclarationsWithParent(element, parent); + Assert.AreEqual(before + 1, styles.Enumerations); + } + + [Test] + public void ComponentDetectionAgreesWithReachability() + { + const Int32 count = 16; + var random = new Random(241); + + for (var sample = 0; sample < 100; sample++) + { + var reachable = new Boolean[count, count]; + var text = new StringBuilder(); + + for (var i = 0; i < count; i++) + { + text.Append("--v").Append(i).Append(':'); + + if (random.Next(3) == 0) + { + text.Append("red;"); + } + else + { + var first = random.Next(count); + var second = random.Next(count); + reachable[i, first] = reachable[i, second] = true; + text.Append("var(--v").Append(first).Append(",var(--v").Append(second).Append(",red));"); + } + } + + for (var k = 0; k < count; k++) + { + for (var i = 0; i < count; i++) + { + for (var j = 0; j < count; j++) + { + reachable[i, j] |= reachable[i, k] && reachable[k, j]; + } + } + } + + var resolver = new CssCustomPropertyResolver(ParseDeclarations(text.ToString())); + + for (var i = 0; i < count; i++) + { + var value = resolver.Resolve("--v" + i); + Assert.AreEqual(reachable[i, i], value is null, "Sample {0}, variable {1}", sample, i); + + if (value is not null) + { + Assert.AreEqual("red", value.CssText.Replace("/**/", String.Empty)); + } + } + } + } + + [Test] + public void SubstitutionLimitIncludesTheBoundary() + { + var variable = new CssVariableValue("var(--a)"); + var maximum = new String('x', CssVariableValue.MaxSubstitutionLength); + Assert.AreEqual(maximum, variable.Substitute(_ => new CssAnyValue(maximum))); + Assert.IsNull(variable.Substitute(_ => new CssAnyValue(maximum + "x"))); + } + + [TestCase(false)] + [TestCase(true)] + public void LongNamedChainsAndCyclesUseBoundedStackSpace(Boolean cycle) + { + const Int32 count = 4096; + var text = new StringBuilder(); + + for (var i = 0; i < count - 1; i++) + { + text.Append("--v").Append(i).Append(":var(--v").Append(i + 1).Append(");"); + } + + text.Append("--v").Append(count - 1).Append(cycle ? ":var(--v0);" : ":visible;"); + text.Append("visibility:var(--v0,hidden)"); + using var document = ParseDocument("
"); + var element = document.QuerySelector("div"); + element.SetAttribute("style", text.ToString()); + Assert.AreEqual(cycle ? "hidden" : "visible", element.ComputeCurrentStyle().GetPropertyValue("visibility")); + } + + [TestCase(false)] + [TestCase(true)] + public void DeepFallbacksUseBoundedStackSpace(Boolean rawFallback) + { + const Int32 count = 8192; + var prefix = rawFallback ? "var(--missing,calc(" : "var(--missing,"; + var suffix = rawFallback ? "))" : ")"; + var text = String.Concat(Enumerable.Repeat(prefix, count)) + "red" + String.Concat(Enumerable.Repeat(suffix, count)); + using var document = ParseDocument(""); + var element = document.QuerySelector("span"); + element.GetStyle().SetProperty("--a", text); + element.GetStyle().SetProperty("color", "var(--a,blue)"); + Assert.IsNotNull(element.GetStyle().GetProperty("--a").RawValue); + var style = element.ComputeCurrentStyle(); + Assert.IsNotNull(style); + + if (!rawFallback) + { + Assert.AreEqual("rgba(255, 0, 0, 1)", style.GetPropertyValue("color")); + } + } + + [Test] + public void ExponentialSubstitutionIsBounded() + { + var text = new StringBuilder("--v0:red;"); + + for (var i = 1; i < 24; i++) + { + text.Append("--v").Append(i).Append(":var(--v").Append(i - 1).Append(") var(--v").Append(i - 1).Append(");"); + } + + using var document = ParseDocument("
"); + var element = document.QuerySelector("div"); + element.SetAttribute("style", text + "visibility:var(--v23,hidden)"); + Assert.AreEqual("hidden", element.ComputeCurrentStyle().GetPropertyValue("visibility")); + } + + private sealed class CountingStyleCollection : IStyleCollection + { + private readonly IStyleCollection _inner; + + public CountingStyleCollection(IStyleCollection inner) => _inner = inner; + + public IRenderDevice Device => _inner.Device; + + public Int32 Enumerations { get; private set; } + + public IEnumerator GetEnumerator() + { + Enumerations++; + return _inner.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + } +} diff --git a/src/AngleSharp.Css/Dom/Internal/CssProperty.cs b/src/AngleSharp.Css/Dom/Internal/CssProperty.cs index e199e267..3a1413a4 100644 --- a/src/AngleSharp.Css/Dom/Internal/CssProperty.cs +++ b/src/AngleSharp.Css/Dom/Internal/CssProperty.cs @@ -111,7 +111,10 @@ public Boolean IsImportant public ICssProperty Compute(ICssComputeContext context) { var propertyContext = new PropertyComputeContext(context, _converter); - var computedValue = _value?.Compute(propertyContext); + var computedValue = _name.StartsWith("--", StringComparison.Ordinal) ? + context.Resolve(_name) ?? CssInvalidValue.Instance : + _value is CssChildValue child ? child.Compute(propertyContext, _name) : + _value is CssReferenceValue reference ? reference.ComputeSubstituted(propertyContext) : _value?.Compute(propertyContext); if (computedValue != _value) { diff --git a/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs b/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs index 1163a282..bc44cbb0 100644 --- a/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs +++ b/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs @@ -37,7 +37,7 @@ sealed class CssStyleDeclaration : ICssStyleDeclaration public CssStyleDeclaration(IBrowsingContext context) { _declarations = new List(); - _declarationIndex = new Dictionary(StringComparer.OrdinalIgnoreCase); + _declarationIndex = new Dictionary(StringComparer.Ordinal); _context = context; } @@ -79,11 +79,13 @@ public String CssText public ICssProperty GetProperty(String name) { + name = name.StartsWith("--", StringComparison.Ordinal) ? name : name.ToLowerFast(); + if (_declarationIndex.TryGetValue(name, out var index) && index < _declarations.Count) { var declaration = _declarations[index]; - if (declaration.Name.Isi(name)) + if (declaration.Name.Is(name)) { return declaration; } @@ -391,6 +393,7 @@ private void SetProperty(ICssProperty property) private void RemovePropertyByName(String propertyName) { + propertyName = propertyName.StartsWith("--", StringComparison.Ordinal) ? propertyName : propertyName.ToLowerFast(); var info = _context.GetDeclarationInfo(propertyName); var longhands = info.Longhands; diff --git a/src/AngleSharp.Css/Extensions/CssOmExtensions.cs b/src/AngleSharp.Css/Extensions/CssOmExtensions.cs index c2f05b58..c57b2564 100644 --- a/src/AngleSharp.Css/Extensions/CssOmExtensions.cs +++ b/src/AngleSharp.Css/Extensions/CssOmExtensions.cs @@ -1,6 +1,7 @@ #nullable disable namespace AngleSharp.Css.Dom { + using AngleSharp.Css.Converters; using AngleSharp.Css.Parser; using AngleSharp.Css.Values; using AngleSharp.Dom; @@ -92,10 +93,40 @@ public static ICssStyleDeclaration Compute(this ICssStyleDeclaration style, ICss foreach (var property in style) { - computedStyle.AddProperty(property.Compute(context)); + var computed = property.Compute(context); + + var substitutedKeyword = property.RawValue is not ICssSpecialValue && computed.RawValue is ICssSpecialValue; + + if ((computed.RawValue is null || substitutedKeyword) && property.RawValue is not null && property is CssProperty cssProperty) + { + var inherit = computed.RawValue is CssInheritValue || + (computed.RawValue is not CssInitialValue && property.CanBeInherited); + var inherited = inherit && context is CssComputeContext cssContext ? + cssContext.InheritedValue(property.Name) : null; + var initial = context.Context.GetDeclarationInfo(property.Name).InitialValue; + var value = inherited ?? (initial is null ? null : cssProperty.Converter.Convert(initial.CssText)?.Compute(context)); + computed = new CssProperty(property.Name, cssProperty.Converter, cssProperty.Flags, value, property.IsImportant); + } + + computedStyle.AddProperty(computed); } return computedStyle; } + + internal static CssStyleDeclaration PrepareComputedDeclarations(this ICssStyleDeclaration style, ICssStyleDeclaration parent, ICssComputeContext context) + { + var declarations = new CssStyleDeclaration(context.Context); + + // Resolve local custom declarations before merging the parent. In + // particular, initial must not disappear through IsInherited. + foreach (var property in style) + { + declarations.AddProperty(property.Name.StartsWith("--", StringComparison.Ordinal) ? property.Compute(context) : property); + } + + declarations.UpdateDeclarations(parent); + return declarations; + } } } diff --git a/src/AngleSharp.Css/Extensions/DeclarationInfoExtensions.cs b/src/AngleSharp.Css/Extensions/DeclarationInfoExtensions.cs index e2a7a549..a61f2257 100644 --- a/src/AngleSharp.Css/Extensions/DeclarationInfoExtensions.cs +++ b/src/AngleSharp.Css/Extensions/DeclarationInfoExtensions.cs @@ -56,7 +56,7 @@ public static IEnumerable NotNull(this IEnumerable enumerable) if (value is ICssRawValue || value is CssChildValue) { - var child = new CssChildValue(value); + var child = new CssChildValue(value, shorthandName: info.Name); return Enumerable .Repeat(child, longhands.Length) .ToArray(); diff --git a/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs b/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs index de94d76f..50606462 100644 --- a/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs +++ b/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs @@ -44,13 +44,7 @@ public static IStyleCollection GetStyleCollection(this IWindow window, IRenderDe /// The optional pseudo selector to use. /// The style declaration containing all the declarations. public static ICssStyleDeclaration ComputeDeclarations(this IStyleCollection styles, IElement element, String? pseudoSelector = null) - { - var ctx = element.Owner?.Context; - var declarations = GetDeclarations(styles, element, pseudoSelector); - var context = new CssComputeContext(styles.Device, ctx, declarations); - - return declarations.Compute(context); - } + => GetComputedDeclarations(styles, element, pseudoSelector); /// /// Gets the declarations for the given element in the context of @@ -63,27 +57,55 @@ public static ICssStyleDeclaration ComputeDeclarations(this IStyleCollection sty public static ICssStyleDeclaration GetDeclarations(this IStyleCollection styles, IElement element, String? pseudoSelector = null) { var ctx = element.Owner?.Context; - var computedStyle = new CssStyleDeclaration(ctx); - var nodes = element.GetAncestors().OfType(); + var declarations = new CssStyleDeclaration(ctx); + var ancestors = element.GetAncestors().OfType(); if (!String.IsNullOrEmpty(pseudoSelector)) { - var pseudoElement = element?.Pseudo(pseudoSelector!.TrimStart(':')); + element = element.Pseudo(pseudoSelector!.TrimStart(':')) ?? element; + } + + declarations.SetDeclarations(styles.ComputeExplicitStyle(element)); + + foreach (var ancestor in ancestors) + { + declarations.UpdateDeclarations(styles.ComputeExplicitStyle(ancestor)); + } + + return declarations; + } + + private static ICssStyleDeclaration GetComputedDeclarations(IStyleCollection styles, IElement element, String? pseudoSelector) + { + var ctx = element.Owner?.Context; + ICssStyleDeclaration? parent = null; + var nodes = new Stack(); + + if (!String.IsNullOrEmpty(pseudoSelector)) + { + var pseudoElement = element.Pseudo(pseudoSelector!.TrimStart(':')); if (pseudoElement is not null) { - element = pseudoElement; + nodes.Push(pseudoElement); } } - computedStyle.SetDeclarations(styles.ComputeExplicitStyle(element!)); + nodes.Push(element); - foreach (var node in nodes) + foreach (var ancestor in element.GetAncestors().OfType()) { - computedStyle.UpdateDeclarations(styles.ComputeExplicitStyle(node)); + nodes.Push(ancestor); } - return computedStyle; + while (nodes.Count > 0) + { + var explicitStyle = styles.ComputeExplicitStyle(nodes.Pop()); + var context = new CssComputeContext(styles.Device, ctx, explicitStyle, parent); + parent = explicitStyle.PrepareComputedDeclarations(parent!, context).Compute(context); + } + + return parent!; } /// @@ -96,9 +118,9 @@ public static ICssStyleDeclaration GetDeclarations(this IStyleCollection styles, /// Returns the cascaded read-only style declaration. public static ICssStyleDeclaration ComputeCascadedStyle(this IStyleCollection styles, IElement element, ICssStyleDeclaration parent) { - var computedStyle = (CssStyleDeclaration)styles.ComputeExplicitStyle(element); - computedStyle.UpdateDeclarations(parent); - return computedStyle; + var declarations = (CssStyleDeclaration)styles.ComputeExplicitStyle(element); + declarations.UpdateDeclarations(parent); + return declarations; } /// @@ -140,18 +162,9 @@ public static ICssStyleDeclaration ComputeExplicitStyle(this IStyleCollection st internal static ICssStyleDeclaration ComputeDeclarationsWithParent(this IStyleCollection styles, IElement element, ICssStyleDeclaration parentComputedStyle) { var ctx = element.Owner?.Context; - var computedStyle = new CssStyleDeclaration(ctx); - - // Element's own cascaded style (CSS rule matching + inline style). - computedStyle.SetDeclarations(styles.ComputeExplicitStyle(element)); - - // Inherit from the parent's already-computed style instead of walking - // all ancestors individually. The parent style already includes the - // full ancestor inheritance chain. - computedStyle.UpdateDeclarations(parentComputedStyle); - - var context = new CssComputeContext(styles.Device, ctx, computedStyle); - return computedStyle.Compute(context); + var explicitStyle = styles.ComputeExplicitStyle(element); + var context = new CssComputeContext(styles.Device, ctx, explicitStyle, parentComputedStyle); + return explicitStyle.PrepareComputedDeclarations(parentComputedStyle, context).Compute(context); } #endregion diff --git a/src/AngleSharp.Css/Parser/CssTokenizer.cs b/src/AngleSharp.Css/Parser/CssTokenizer.cs index 47cd672c..88b8d7b8 100644 --- a/src/AngleSharp.Css/Parser/CssTokenizer.cs +++ b/src/AngleSharp.Css/Parser/CssTokenizer.cs @@ -260,6 +260,10 @@ private CssToken Data(Char current) Advance(2); return NewCloseComment(); } + else if (c1 == Symbols.Minus) + { + return IdentStart(current); + } } else { @@ -706,7 +710,7 @@ private CssToken IdentStart(Char current) { current = GetNext(); - if (current.IsNameStart() || IsValidEscape(current)) + if (current.IsNameStart() || current == Symbols.Minus || IsValidEscape(current)) { StringBuffer.Append(Symbols.Minus); return IdentRest(current); diff --git a/src/AngleSharp.Css/Parser/Micro/FunctionParser.cs b/src/AngleSharp.Css/Parser/Micro/FunctionParser.cs index 72d269f6..dbc20746 100644 --- a/src/AngleSharp.Css/Parser/Micro/FunctionParser.cs +++ b/src/AngleSharp.Css/Parser/Micro/FunctionParser.cs @@ -73,7 +73,7 @@ public static CssReferenceValue ParseVars(this StringSource source) continue; } } - + break; } @@ -81,7 +81,7 @@ public static CssReferenceValue ParseVars(this StringSource source) if (refs != null) { - return new CssReferenceValue(source.Content, refs); + return new CssReferenceValue(new CssVariableValue(source.Content), refs); } return null; @@ -116,15 +116,45 @@ public static CssVarValue ParseVar(this StringSource source) /// public static ICssValue ParseVarFallback(this StringSource source) { - if (!source.IsFunction(FunctionNames.Var)) + var names = new Stack(); + ICssValue fallback = null; + var readFallback = true; + + while (source.IsFunction(FunctionNames.Var)) + { + var name = source.ParseCustomIdent(); + var separator = source.SkipGetSkip(); + + if (name is null || (separator != Symbols.Comma && separator != Symbols.RoundBracketClose)) + { + readFallback = false; + break; + } + + names.Push(name); + + if (separator == Symbols.RoundBracketClose) + { + readFallback = false; + break; + } + + source.SkipSpacesAndComments(); + } + + if (readFallback) { var content = source.TakeUntilClosed(); source.SkipCurrentAndSpaces(); - return new CssAnyValue(content); + fallback = new CssAnyValue(content); } - return source.ParseVar(); + while (names.Count > 0) + { + fallback = new CssVarValue(names.Pop(), fallback); + } + return fallback; } /// diff --git a/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs b/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs index cda0ef31..bdbc9070 100644 --- a/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs +++ b/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs @@ -92,16 +92,8 @@ private ElementRenderNode RenderElement( specifiedStyle.UpdateDeclarations(parentSpecifiedStyle); } - var computedDeclarations = new CssStyleDeclaration(_context); - computedDeclarations.SetDeclarations(explicitStyle); - - if (parentComputedStyle is not null) - { - computedDeclarations.UpdateDeclarations(parentComputedStyle); - } - - var computeContext = new CssComputeContext(collection.Device, _context, computedDeclarations); - var computedStyle = computedDeclarations.Compute(computeContext); + var computeContext = new CssComputeContext(collection.Device, _context, explicitStyle, parentComputedStyle); + var computedStyle = explicitStyle.PrepareComputedDeclarations(parentComputedStyle!, computeContext).Compute(computeContext); var children = new List(); var node = new ElementRenderNode(element, parent, children, specifiedStyle, computedStyle); diff --git a/src/AngleSharp.Css/Values/CssChildValue.cs b/src/AngleSharp.Css/Values/CssChildValue.cs index 7aeda032..6ba38a1b 100644 --- a/src/AngleSharp.Css/Values/CssChildValue.cs +++ b/src/AngleSharp.Css/Values/CssChildValue.cs @@ -2,6 +2,7 @@ namespace AngleSharp.Css.Values { using AngleSharp.Css.Dom; + using AngleSharp.Css.Parser; using System; using System.Collections.Generic; @@ -14,6 +15,7 @@ sealed class CssChildValue : ICssValue, IEquatable private readonly ICssValue _parent; private readonly ICssValue _value; + private readonly String _shorthandName; #endregion @@ -24,10 +26,12 @@ sealed class CssChildValue : ICssValue, IEquatable /// /// The reference to the shorthand value. /// The value of the child, if any. - public CssChildValue(ICssValue parent, ICssValue value = null) + /// The shorthand that supplied the pending value. + public CssChildValue(ICssValue parent, ICssValue value = null, String shorthandName = null) { _parent = parent; _value = value; + _shorthandName = shorthandName; } #endregion @@ -73,7 +77,50 @@ ICssValue ICssValue.Compute(ICssComputeContext context) { var parent = _parent.Compute(context); var value = _value?.Compute(context); - return new CssChildValue(parent, value); + return new CssChildValue(parent, value, _shorthandName); + } + + internal ICssValue Compute(ICssComputeContext context, String longhandName) + { + var parent = _parent; + var shorthandName = _shorthandName; + + while (parent is CssChildValue child) + { + shorthandName = child._shorthandName; + parent = child.Parent; + } + + if (shorthandName is not null && parent is ICssRawValue) + { + var values = parent is CssReferenceValue reference ? reference.GetVariableValues() : + new[] { new CssVariableValue(parent.CssText) }; + String text = null; + + foreach (var candidate in values) + { + text = candidate.Substitute(context.Resolve); + + if (text is not null) + { + break; + } + } + + if (text is null) + { + return null; + } + + // Parse the substituted shorthand once its complete token stream + // is known, rather than feeding it to an individual longhand's + // converter and discarding the remaining components. + var parser = context.Context?.GetService() ?? new CssParser(context.Context); + var declarations = parser.ParseDeclaration(shorthandName + ":" + text); + return declarations.GetProperty(longhandName)?.RawValue?.Compute(context); + } + + return ((ICssValue)this).Compute(context); } Boolean IEquatable.Equals(ICssValue other) => other is CssChildValue value && Equals(value); diff --git a/src/AngleSharp.Css/Values/CssComputeContext.cs b/src/AngleSharp.Css/Values/CssComputeContext.cs index 09055071..d2e7255f 100644 --- a/src/AngleSharp.Css/Values/CssComputeContext.cs +++ b/src/AngleSharp.Css/Values/CssComputeContext.cs @@ -8,13 +8,15 @@ sealed class CssComputeContext : ICssComputeContext { private readonly IRenderDevice _device; private readonly IBrowsingContext? _context; - private readonly ICssProperties _properties; + private readonly CssCustomPropertyResolver _variables; + private readonly ICssProperties? _parent; - public CssComputeContext(IRenderDevice device, IBrowsingContext? context, ICssProperties properties) + public CssComputeContext(IRenderDevice device, IBrowsingContext? context, ICssProperties properties, ICssProperties? parent = null) { _device = device ?? new DefaultRenderDevice(); _context = context; - _properties = properties; + _variables = new CssCustomPropertyResolver(properties, parent); + _parent = parent; } public IRenderDevice Device => _device; @@ -23,16 +25,9 @@ public CssComputeContext(IRenderDevice device, IBrowsingContext? context, ICssPr public IValueConverter? Converter => null; - public ICssValue? Resolve(String name) - { - if (name.StartsWith("--")) - { - var property = _properties.FirstOrDefault(m => m.Name.Equals(name, StringComparison.Ordinal)); - return property?.RawValue; - } + public ICssValue? Resolve(String name) => _variables.Resolve(name); - return null; - } + internal ICssValue? InheritedValue(String name) => _parent?.FirstOrDefault(m => m.Name == name)?.RawValue; } } diff --git a/src/AngleSharp.Css/Values/CssCustomPropertyResolver.cs b/src/AngleSharp.Css/Values/CssCustomPropertyResolver.cs new file mode 100644 index 00000000..926b4eb3 --- /dev/null +++ b/src/AngleSharp.Css/Values/CssCustomPropertyResolver.cs @@ -0,0 +1,180 @@ +namespace AngleSharp.Css.Values +{ + using AngleSharp.Css.Dom; + using System; + using System.Collections.Generic; + using System.Linq; + + sealed class CssCustomPropertyResolver + { + private readonly Dictionary _values = new(StringComparer.Ordinal); + + public CssCustomPropertyResolver(IEnumerable properties, ICssProperties? parent = null) + { + if (parent is not null) + { + foreach (var property in parent) + { + if (property.Name.StartsWith("--", StringComparison.Ordinal)) + { + _values[property.Name] = property.RawValue is CssInvalidValue ? null : property.RawValue; + } + } + } + + var nodes = new Dictionary(StringComparer.Ordinal); + + foreach (var property in properties) + { + if (property.Name.StartsWith("--", StringComparison.Ordinal)) + { + var value = property.RawValue; + + if (value is CssAnyValue { IsResolved: true }) + { + _values[property.Name] = value; + continue; + } + + var values = value is CssReferenceValue references ? references.GetVariableValues().ToArray() : + value is null || value is CssInvalidValue ? Array.Empty() : + new[] { new CssVariableValue(value.CssText) }; + var keyword = values.Length == 1 ? values[0].Keyword : null; + + if (String.Equals(keyword, CssKeywords.Inherit, StringComparison.OrdinalIgnoreCase) || + String.Equals(keyword, CssKeywords.Unset, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + _values[property.Name] = null; + + if (values.Length > 0 && !String.Equals(keyword, CssKeywords.Initial, StringComparison.OrdinalIgnoreCase)) + { + nodes[property.Name] = new Node(property.Name, values); + } + } + } + + foreach (var node in nodes.Values) + { + foreach (var value in node.Values) + { + if (!value.IsValid) + { + continue; + } + + foreach (var name in value.Dependencies) + { + if (nodes.TryGetValue(name, out var dependency)) + { + node.Dependencies.Add(dependency); + } + } + } + } + + // Iterative Tarjan traversal: complete components in dependency order. + // All fallback edges participate, even if substitution won't use them. + var index = 0; + var active = new Stack(); + var visits = new Stack(); + var component = new List(); + + foreach (var root in nodes.Values) + { + if (root.Index >= 0) + { + continue; + } + + Enter(root); + + while (visits.Count > 0) + { + var node = visits.Peek(); + + if (node.NextDependency < node.Dependencies.Count) + { + var dependency = node.Dependencies[node.NextDependency++]; + + if (dependency.Index < 0) + { + Enter(dependency); + } + else if (dependency.Active) + { + node.LowLink = Math.Min(node.LowLink, dependency.Index); + } + + continue; + } + + visits.Pop(); + + if (visits.Count > 0) + { + var previous = visits.Peek(); + previous.LowLink = Math.Min(previous.LowLink, node.LowLink); + } + + if (node.LowLink == node.Index) + { + component.Clear(); + Node member; + + do + { + member = active.Pop(); + member.Active = false; + component.Add(member); + } + while (member != node); + + if (component.Count == 1 && !node.Dependencies.Contains(node)) + { + foreach (var value in node.Values) + { + var text = value.Substitute(Resolve); + + if (text is not null) + { + _values[node.Name] = new CssAnyValue(text, isResolved: true); + break; + } + } + } + } + } + } + + void Enter(Node node) + { + node.Index = node.LowLink = index++; + node.Active = true; + active.Push(node); + visits.Push(node); + } + } + + public ICssValue? Resolve(String name) => _values.TryGetValue(name, out var value) ? value : null; + + private sealed class Node + { + public Node(String name, CssVariableValue[] values) + { + Name = name; + Values = values; + } + + public String Name { get; } + public CssVariableValue[] Values { get; } + public List Dependencies { get; } = new(); + public Int32 Index { get; set; } = -1; + public Int32 LowLink { get; set; } + public Int32 NextDependency { get; set; } + public Boolean Active { get; set; } + } + } +} diff --git a/src/AngleSharp.Css/Values/CssVariableValue.cs b/src/AngleSharp.Css/Values/CssVariableValue.cs new file mode 100644 index 00000000..85d403b7 --- /dev/null +++ b/src/AngleSharp.Css/Values/CssVariableValue.cs @@ -0,0 +1,260 @@ +namespace AngleSharp.Css.Values +{ + using AngleSharp.Css.Dom; + using AngleSharp.Css.Parser; + using AngleSharp.Css.Parser.Tokens; + using AngleSharp.Text; + using System; + using System.Collections.Generic; + using System.Text; + + // A flat token stream keeps both dependency discovery and nested fallback + // substitution off the CLR stack. Strings, URLs and comments are opaque. + sealed class CssVariableValue + { + // Bound expansion of small, exponentially growing variable definitions. + internal const Int32 MaxSubstitutionLength = 1024 * 1024; + + private readonly List _tokens = new(); + private readonly Dictionary _references = new(); + + public CssVariableValue(String text) + { + Text = text; + var tokenizer = new CssTokenizer(new TextSource(text)); + var blocks = new Stack(); + var ends = new Dictionary(); + + while (true) + { + var token = tokenizer.Get(); + + if (token.Type == CssTokenType.EndOfFile) + { + break; + } + + var index = _tokens.Count; + _tokens.Add(token); + + if (IsOpen(token.Type)) + { + blocks.Push(index); + } + else if (IsClose(token.Type) && blocks.Count > 0) + { + var start = blocks.Pop(); + IsValid &= Matches(_tokens[start].Type, token.Type); + ends[start] = index; + } + } + + // CSS syntax closes outstanding blocks at EOF. Keep the original + // text for CSSOM serialization, including incomplete URL strings. + while (blocks.Count > 0) + { + var start = blocks.Pop(); + ends[start] = _tokens.Count; + _tokens.Add(new CssToken(CloseType(_tokens[start].Type), String.Empty) + { + Position = new TextPosition(0, 0, text.Length + 1), + }); + } + + for (var i = 0; i < _tokens.Count; i++) + { + var token = _tokens[i]; + + if (token.Type == CssTokenType.Function && token.Data.Equals(FunctionNames.Var, StringComparison.OrdinalIgnoreCase)) + { + var name = SkipTrivia(i + 1); + var separator = SkipTrivia(name + 1); + var valid = ends.TryGetValue(i, out var end) && + name < end && _tokens[name].Type == CssTokenType.Ident && + _tokens[name].Data.StartsWith("--", StringComparison.Ordinal) && + _tokens[name].Data.Length > 2 && + (separator == end || _tokens[separator].Type == CssTokenType.Comma); + + IsValid &= valid; + + if (valid) + { + _references.Add(i, new Reference(_tokens[name].Data, end, separator < end ? separator + 1 : -1)); + } + } + } + } + + public String Text { get; } + + public Boolean IsValid { get; } = true; + + public Boolean HasReferences => _references.Count > 0; + + public IEnumerable Dependencies + { + get + { + foreach (var reference in _references.Values) + { + yield return reference.Name; + } + } + } + + public String? Keyword + { + get + { + var index = SkipTrivia(0); + return index < _tokens.Count && _tokens[index].Type == CssTokenType.Ident && + SkipTrivia(index + 1) == _tokens.Count ? _tokens[index].Data : null; + } + } + + public String? Substitute(Func resolve) + { + if (!IsValid) + { + return null; + } + + if (!HasReferences) + { + return Text; + } + + var result = new StringBuilder(); + var fallbacks = new Stack(); + var cursor = 0; + + for (var i = 0; i < _tokens.Count; i++) + { + if (fallbacks.Count > 0 && fallbacks.Peek() == i) + { + if (!Append(result, cursor, Offset(i)) || !Separate(result, NeedsSeparator(EndOffset(i)))) + { + return null; + } + + cursor = EndOffset(i); + fallbacks.Pop(); + } + else if (_references.TryGetValue(i, out var reference)) + { + if (!Append(result, cursor, Offset(i)) || !Separate(result, result.Length > 0)) + { + return null; + } + + var value = resolve(reference.Name); + + if (value is not null) + { + var text = value.CssText; + + if (text.Length > MaxSubstitutionLength - result.Length) + { + return null; + } + + result.Append(text); + cursor = EndOffset(reference.End); + i = reference.End; + + if (!Separate(result, NeedsSeparator(cursor))) + { + return null; + } + } + else if (reference.Fallback >= 0) + { + cursor = Offset(reference.Fallback - 1) + 1; + i = reference.Fallback - 1; + fallbacks.Push(reference.End); + } + else + { + return null; + } + } + } + + return Append(result, cursor, Text.Length) ? result.ToString().Trim() : null; + } + + private Boolean Append(StringBuilder result, Int32 start, Int32 end) + { + var length = end - start; + + if (length > MaxSubstitutionLength - result.Length) + { + return false; + } + + result.Append(Text, start, length); + return true; + } + + private static Boolean Separate(StringBuilder result, Boolean needed) + { + if (needed && result.Length > 0 && !result[result.Length - 1].IsSpaceCharacter()) + { + if (result.Length > MaxSubstitutionLength - 4) + { + return false; + } + + // Substitution must not turn adjacent tokens into a new token + // (for example, var(--number)px must not become a dimension). + result.Append("/**/"); + } + + return true; + } + + private Int32 Offset(Int32 index) => _tokens[index].Position.Position - 1; + + private Int32 EndOffset(Int32 index) => Math.Min(Offset(index) + 1, Text.Length); + + private Boolean NeedsSeparator(Int32 index) => index < Text.Length && !Text[index].IsSpaceCharacter(); + + private Int32 SkipTrivia(Int32 index) + { + while (index < _tokens.Count && (_tokens[index].Type == CssTokenType.Whitespace || _tokens[index].Type == CssTokenType.Comment)) + { + index++; + } + + return index; + } + + private static Boolean IsOpen(CssTokenType type) => + type == CssTokenType.Function || type == CssTokenType.RoundBracketOpen || + type == CssTokenType.SquareBracketOpen || type == CssTokenType.CurlyBracketOpen; + + private static Boolean IsClose(CssTokenType type) => + type == CssTokenType.RoundBracketClose || type == CssTokenType.SquareBracketClose || + type == CssTokenType.CurlyBracketClose; + + private static CssTokenType CloseType(CssTokenType type) => + type == CssTokenType.SquareBracketOpen ? CssTokenType.SquareBracketClose : + type == CssTokenType.CurlyBracketOpen ? CssTokenType.CurlyBracketClose : CssTokenType.RoundBracketClose; + + private static Boolean Matches(CssTokenType open, CssTokenType close) => CloseType(open) == close; + + private readonly struct Reference + { + public Reference(String name, Int32 end, Int32 fallback) + { + Name = name; + End = end; + Fallback = fallback; + } + + public String Name { get; } + public Int32 End { get; } + public Int32 Fallback { get; } + } + } +} diff --git a/src/AngleSharp.Css/Values/Functions/CssVarValue.cs b/src/AngleSharp.Css/Values/Functions/CssVarValue.cs index 957fedf5..29a70689 100644 --- a/src/AngleSharp.Css/Values/Functions/CssVarValue.cs +++ b/src/AngleSharp.Css/Values/Functions/CssVarValue.cs @@ -5,6 +5,7 @@ namespace AngleSharp.Css.Values using AngleSharp.Text; using System; using System.Collections.Generic; + using System.Text; /// /// Represents a CSS var replacement. @@ -78,18 +79,30 @@ public String CssText { get { - var fn = FunctionNames.Var; - var args = new List - { - _variableName, - }; + var text = new StringBuilder(); + var value = this; + var depth = 0; - if (_defaultValue is not null) + while (true) { - args.Add(_defaultValue.CssText); - } + text.Append(FunctionNames.Var).Append('(').Append(value._variableName); + depth++; + + if (value._defaultValue is not null) + { + text.Append(", "); - return fn.CssFunction(String.Join(", ", args)); + if (value._defaultValue is CssVarValue nested) + { + value = nested; + continue; + } + + text.Append(value._defaultValue.CssText); + } + + return text.Append(')', depth).ToString(); + } } } @@ -121,14 +134,25 @@ public Boolean Equals(CssVarValue other) /// The resolved value or null. public ICssValue Compute(ICssComputeContext context) { - var value = context.Resolve(_variableName)?.Compute(context); + var reference = this; - if (value is not null) + while (true) { - return value; - } + var value = context.Resolve(reference._variableName)?.Compute(context); - return _defaultValue?.Compute(context); + if (value is not null) + { + return value; + } + + if (reference._defaultValue is CssVarValue nested) + { + reference = nested; + continue; + } + + return reference._defaultValue?.Compute(context); + } } Boolean IEquatable.Equals(ICssValue other) => other is CssVarValue value && Equals(value); diff --git a/src/AngleSharp.Css/Values/Raws/CssAnyValue.cs b/src/AngleSharp.Css/Values/Raws/CssAnyValue.cs index f942f97c..33418082 100644 --- a/src/AngleSharp.Css/Values/Raws/CssAnyValue.cs +++ b/src/AngleSharp.Css/Values/Raws/CssAnyValue.cs @@ -3,6 +3,8 @@ namespace AngleSharp.Css.Values { using AngleSharp.Css.Converters; using AngleSharp.Css.Dom; + using AngleSharp.Css.Parser; + using AngleSharp.Text; using System; /// @@ -22,11 +24,15 @@ sealed class CssAnyValue : ICssRawValue /// Creates a new unknown value with the given literal content. /// /// The serialized value representation.. - public CssAnyValue(String text) + /// Whether variable substitution has already been performed. + public CssAnyValue(String text, Boolean isResolved = false) { _text = text; + IsResolved = isResolved; } + internal Boolean IsResolved { get; } + #endregion #region Properties @@ -51,11 +57,14 @@ ICssValue ICssValue.Compute(ICssComputeContext context) if (converter is not null && converter is not AnyValueConverter) { - var value = converter.Convert(_text); - return value?.Compute(context); + var source = new StringSource(_text); + source.SkipSpacesAndComments(); + var value = converter.Convert(source); + source.SkipSpacesAndComments(); + return source.IsDone ? value?.Compute(context) : null; } - return null; + return IsResolved ? this : null; } Boolean IEquatable.Equals(ICssValue other) => other is CssAnyValue o && _text == o.CssText; diff --git a/src/AngleSharp.Css/Values/Raws/CssInvalidValue.cs b/src/AngleSharp.Css/Values/Raws/CssInvalidValue.cs new file mode 100644 index 00000000..0f5a2705 --- /dev/null +++ b/src/AngleSharp.Css/Values/Raws/CssInvalidValue.cs @@ -0,0 +1,22 @@ +namespace AngleSharp.Css.Values +{ + using AngleSharp.Css.Dom; + using System; + + // Unlike a missing declaration, guaranteed-invalid is inherited as-is and + // cannot be repaired by resolving its original references on a descendant. + sealed class CssInvalidValue : ICssValue + { + public static readonly CssInvalidValue Instance = new(); + + private CssInvalidValue() + { + } + + public String CssText => String.Empty; + + public ICssValue Compute(ICssComputeContext context) => this; + + public Boolean Equals(ICssValue? other) => other is CssInvalidValue; + } +} diff --git a/src/AngleSharp.Css/Values/Raws/CssReferenceValue.cs b/src/AngleSharp.Css/Values/Raws/CssReferenceValue.cs index f1ab66ea..b1844e46 100644 --- a/src/AngleSharp.Css/Values/Raws/CssReferenceValue.cs +++ b/src/AngleSharp.Css/Values/Raws/CssReferenceValue.cs @@ -17,6 +17,8 @@ public sealed class CssReferenceValue : ICssRawValue private readonly String _value; private readonly TextRange[] _ranges; private readonly CssVarValue[] _references; + private readonly CssVariableValue _tokens; + private readonly CssVarValue[] _parsedReferences; #endregion @@ -34,6 +36,13 @@ public CssReferenceValue(String value, IEnumerable _references = references.Select(m => m.Item2).ToArray(); } + internal CssReferenceValue(CssVariableValue value, IEnumerable> references) + : this(value.Text, references) + { + _parsedReferences = (CssVarValue[])_references.Clone(); + _tokens = value; + } + #endregion #region Properties @@ -77,6 +86,56 @@ ICssValue ICssValue.Compute(ICssComputeContext context) return null; } + internal ICssValue ComputeSubstituted(ICssComputeContext context) + { + // Direct value computation retains the public References contract. + // Only unmodified parser-owned values use token-stream substitution + // at the property computation boundary. + if (HasCustomReferences) + { + return ((ICssValue)this).Compute(context); + } + + var text = _tokens.Substitute(context.Resolve); + return text is null ? null : ((ICssValue)new CssAnyValue(text)).Compute(context); + } + + internal IEnumerable GetVariableValues() + { + if (HasCustomReferences) + { + foreach (var reference in _references) + { + yield return new CssVariableValue(reference.CssText); + } + } + else + { + yield return _tokens; + } + } + + private Boolean HasCustomReferences + { + get + { + if (_tokens is null) + { + return true; + } + + for (var i = 0; i < _references.Length; i++) + { + if (!Object.ReferenceEquals(_references[i], _parsedReferences[i])) + { + return true; + } + } + + return false; + } + } + Boolean IEquatable.Equals(ICssValue other) => Object.ReferenceEquals(this, other); #endregion