diff --git a/CHANGELOG.md b/CHANGELOG.md index b7e7ac5..4f9da92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,24 @@ +# 1.1.2 + +Released on Friday, September 11 2026 + +- Fixed issue with calculation of CSS grid style +- Fixed default value of CSS gradients +- Fixed issue with Point2D style computations +- Fixed issue with style computation of `border-image-slice` declarations +- Fixed handling of `::before` and `::after` w.r.t. `content` declarations +- Added support for more `text-overflow` keywords +- Added `alignment-baseline` declaration +- Added `baseline-shift` declaration +- Added `color-interpolation-filters` declaration +- Added `clip-path` and `clip-rule` declarations +- Added `dominant-baseline` declaration +- Added `fill-opacity` and `fill-rule` declarations +- Added `marker-start`, `marker-mid`, and `marker-end` declarations +- Added `mask` declaration +- Added `text-underline-position` declaration +- Added `writing-mode` declaration + # 1.1.1 Released on Wednesday, September 9 2026 diff --git a/src/AngleSharp.Css.Docs/package.json b/src/AngleSharp.Css.Docs/package.json index 32de862..d68f95c 100644 --- a/src/AngleSharp.Css.Docs/package.json +++ b/src/AngleSharp.Css.Docs/package.json @@ -1,6 +1,6 @@ { "name": "@anglesharp/css", - "version": "1.1.1", + "version": "1.1.2", "preview": true, "description": "The doclet for the AngleSharp.Css documentation.", "keywords": [ diff --git a/src/AngleSharp.Css.Tests/Declarations/CssVariables.cs b/src/AngleSharp.Css.Tests/Declarations/CssVariables.cs index 7a66bb2..b1c5be2 100644 --- a/src/AngleSharp.Css.Tests/Declarations/CssVariables.cs +++ b/src/AngleSharp.Css.Tests/Declarations/CssVariables.cs @@ -194,10 +194,13 @@ public void BackgroundShorthandWithVariableKeepsLonghands() [Test] public void GridGapShorthandWithVariableKeepsLonghands() { + // gap's two-value form is (the first value is the row gap) - + // this test previously encoded the reverse, matching a real, now-fixed bug in + // GapDeclaration's own Longhands ordering (see GapShorthandComputedStyleTests). var style = ParseDeclarations(@"gap: 12px var(--gap-x)"); - Assert.AreEqual("12px", style.GetProperty("column-gap").Value); - Assert.AreEqual("var(--gap-x)", style.GetProperty("row-gap").Value); + Assert.AreEqual("12px", style.GetProperty("row-gap").Value); + Assert.AreEqual("var(--gap-x)", style.GetProperty("column-gap").Value); } [Test] diff --git a/src/AngleSharp.Css.Tests/Extensions/AnalysisWindow.cs b/src/AngleSharp.Css.Tests/Extensions/AnalysisWindow.cs index e97d3e7..9f68a3f 100644 --- a/src/AngleSharp.Css.Tests/Extensions/AnalysisWindow.cs +++ b/src/AngleSharp.Css.Tests/Extensions/AnalysisWindow.cs @@ -199,7 +199,10 @@ public void GetComputedStylePseudoInitialScenarioSingleColon() var style = window.GetComputedStyle(element, ":after"); Assert.IsNotNull(style); - Assert.AreEqual(2, style.Length); + // 3, not 2: `color` (the rule's own) + `font-weight` (inherited from .bold) + `display` + // (the UA stylesheet's `*:before, *:after { display: inline }` default, giving generated + // content its spec-correct display instead of none at all). + Assert.AreEqual(3, style.Length); } [Test] @@ -219,7 +222,9 @@ public void GetComputedStylePseudoInitialScenarioDoubleColon() var style = window.GetComputedStyle(element, "::after"); Assert.IsNotNull(style); - Assert.AreEqual(2, style.Length); + // See GetComputedStylePseudoInitialScenarioSingleColon's comment above - same rule, `::` + // syntax instead of `:`. + Assert.AreEqual(3, style.Length); } [Test] @@ -243,7 +248,9 @@ public void GetComputedStyleMixedTrivialAndPseudoScenario() var stylePseudo = window.GetComputedStyle(element, ":before"); Assert.IsNotNull(stylePseudo); - Assert.AreEqual(3, stylePseudo.Length); + // 4, not 3: `color` + `content` (the rule's own) + `font-weight` (inherited) + `display` + // (the UA stylesheet's `*:before, *:after { display: inline }` default). + Assert.AreEqual(4, stylePseudo.Length); } [Test] diff --git a/src/AngleSharp.Css.Tests/Extensions/PseudoElementRenderTests.cs b/src/AngleSharp.Css.Tests/Extensions/PseudoElementRenderTests.cs new file mode 100644 index 0000000..22ca4e9 --- /dev/null +++ b/src/AngleSharp.Css.Tests/Extensions/PseudoElementRenderTests.cs @@ -0,0 +1,182 @@ +#nullable disable +namespace AngleSharp.Css.Tests.Extensions +{ + using System.Linq; + using AngleSharp.Css.Dom; + using AngleSharp.Css.RenderTree; + using AngleSharp.Css.Tests.Mocks; + using AngleSharp.Dom; + using NUnit.Framework; + using static CssConstructionFunctions; + + /// + /// `::before`/`::after` were entirely absent from the render tree `WindowExtensions.Render`/ + /// `RenderTreeBuilder.RenderElement` produce - the tree only ever walked `element.ChildNodes` + /// for real DOM elements/text, with no awareness of `IElement.Pseudo(...)` at all, even though + /// `window.GetPseudoElements` (a separate, independent API) already resolved `::before`/`::after` + /// selector matching and `content` correctly. Found while adding pseudo-element support to a + /// downstream renderer. + /// + [TestFixture] + public class PseudoElementRenderTests + { + private static ElementRenderNode Render(string html) + { + var document = ParseDocument(html); + var window = document.DefaultView; + return (ElementRenderNode)window.Render(new PlainRenderDevice()); + } + + private static ElementRenderNode FindElement(ElementRenderNode node, string id) + { + if (node.Ref.Id == id) + { + return node; + } + + foreach (var child in node.Children.OfType()) + { + var found = FindElement(child, id); + + if (found is not null) + { + return found; + } + } + + return null; + } + + [Test] + public void BeforePseudoElementIsTheFirstChildWithItsGeneratedTextContent() + { + var root = Render("" + + "
middle
"); + var target = FindElement(root, "target"); + var children = target.Children.ToArray(); + + Assert.AreEqual(2, children.Length); + var before = (ElementRenderNode)children[0]; + Assert.IsInstanceOf(before.Ref); + Assert.AreEqual("before", ((IPseudoElement)before.Ref).PseudoName); + var beforeText = (TextRenderNode)before.Children.Single(); + Assert.AreEqual("PREFIX-", beforeText.Ref.Data); + var realText = (TextRenderNode)children[1]; + Assert.AreEqual("middle", realText.Ref.Data); + } + + [Test] + public void AfterPseudoElementIsTheLastChildWithItsGeneratedTextContent() + { + var root = Render("" + + "
middle
"); + var target = FindElement(root, "target"); + var children = target.Children.ToArray(); + + Assert.AreEqual(2, children.Length); + var realText = (TextRenderNode)children[0]; + Assert.AreEqual("middle", realText.Ref.Data); + var after = (ElementRenderNode)children[1]; + Assert.AreEqual("after", ((IPseudoElement)after.Ref).PseudoName); + var afterText = (TextRenderNode)after.Children.Single(); + Assert.AreEqual("-SUFFIX", afterText.Ref.Data); + } + + [Test] + public void PseudoElementWithNoContentDeclarationGeneratesNoNode() + { + var root = Render("
middle
"); + var target = FindElement(root, "target"); + + Assert.AreEqual(1, target.Children.Count()); + Assert.IsInstanceOf(target.Children.Single()); + } + + [Test] + public void ExplicitContentNoneGeneratesNoNode() + { + var root = Render("" + + "
middle
"); + var target = FindElement(root, "target"); + + Assert.AreEqual(1, target.Children.Count()); + Assert.IsInstanceOf(target.Children.Single()); + } + + [Test] + public void ContentAttrResolvesTheHostsOwnAttribute() + { + var root = Render("" + + "
middle
"); + var target = FindElement(root, "target"); + var before = (ElementRenderNode)target.Children.First(); + var beforeText = (TextRenderNode)before.Children.Single(); + + Assert.AreEqual("Hello", beforeText.Ref.Data); + } + + [Test] + public void MultiplePartsConcatenateInDeclaredOrder() + { + var root = Render("" + + "
middle
"); + var target = FindElement(root, "target"); + var before = (ElementRenderNode)target.Children.First(); + var beforeText = (TextRenderNode)before.Children.Single(); + + Assert.AreEqual("[X]", beforeText.Ref.Data); + } + + [Test] + public void PseudoElementInheritsFromItsHostWhenNotItselfSet() + { + var root = Render("
middle
"); + var target = FindElement(root, "target"); + var before = (ElementRenderNode)target.Children.First(); + + Assert.AreEqual("rgba(10, 20, 30, 1)", before.ComputedStyle.GetPropertyValue("color")); + } + + [Test] + public void BothBeforeAndAfterCanCoexistBracketingRealContent() + { + var root = Render("
middle
"); + var target = FindElement(root, "target"); + var children = target.Children.ToArray(); + + Assert.AreEqual(3, children.Length); + Assert.AreEqual("before", ((IPseudoElement)((ElementRenderNode)children[0]).Ref).PseudoName); + Assert.IsInstanceOf(children[1]); + Assert.AreEqual("after", ((IPseudoElement)((ElementRenderNode)children[2]).Ref).PseudoName); + } + + /// + /// A generated-content pseudo-element defaults to `display: inline` per spec (the same as a + /// real browser's own UA stylesheet) unless the author overrides it - AngleSharp.Css's UA + /// stylesheet had no rule for `::before`/`::after` at all, so an unstyled one computed + /// `display: block` (this project's general "unset display defaults to block" fallback, + /// correct for most real elements but wrong for generated content specifically), which would + /// have made every `::before`/`::after` start its own new block line instead of flowing + /// inline with the rest of its host's content - the overwhelmingly common real-world case + /// (an icon, a required-field marker, a breadcrumb separator, ...). Fixed with a targeted + /// `*:before, *:after { display: inline }` UA-stylesheet addition, the same "give the right + /// initial value via a UA rule" precedent already used for `list-style-type`. + /// + [Test] + public void PseudoElementDefaultsToInlineDisplay() + { + var root = Render("" + + "
middle
"); + var target = FindElement(root, "target"); + var before = (ElementRenderNode)target.Children.First(); + + Assert.AreEqual("inline", before.ComputedStyle.GetDisplay()); + } + } +} diff --git a/src/AngleSharp.Css.Tests/Styling/Configuration.cs b/src/AngleSharp.Css.Tests/Styling/Configuration.cs index 4d3b257..0b81506 100644 --- a/src/AngleSharp.Css.Tests/Styling/Configuration.cs +++ b/src/AngleSharp.Css.Tests/Styling/Configuration.cs @@ -34,7 +34,11 @@ public void ObtainDefaultSheet() Assert.IsNotNull(service.Default); var sheet = service.Default; Assert.IsNotNull(sheet); - Assert.AreEqual(49, sheet.Rules.Length); + // 50, not 49: the UA stylesheet now also carries a `*:before, *:after { display: inline }` + // rule, giving generated-content pseudo-elements their spec-correct default display + // (previously they had none at all, so this renderer's general "unset display defaults + // to block" fallback made every ::before/::after start its own new block line). + Assert.AreEqual(50, sheet.Rules.Length); } } } diff --git a/src/AngleSharp.Css.Tests/Styling/GapShorthandComputedStyleTests.cs b/src/AngleSharp.Css.Tests/Styling/GapShorthandComputedStyleTests.cs new file mode 100644 index 0000000..a005924 --- /dev/null +++ b/src/AngleSharp.Css.Tests/Styling/GapShorthandComputedStyleTests.cs @@ -0,0 +1,41 @@ +#nullable disable +namespace AngleSharp.Css.Tests.Styling +{ + using AngleSharp.Dom; + using NUnit.Framework; + using static CssConstructionFunctions; + + /// + /// `gap`/`grid-gap`'s two-value form is `<row-gap> <column-gap>` per + /// https://drafts.csswg.org/css-align-3/#gap-shorthand - `GapDeclaration`/`GridGapDeclaration`'s + /// own `Longhands` arrays listed `column-gap` before `row-gap`, the reverse of what their own + /// `Merge()`/`Split()` methods already assumed (`values[0]`/`Items[0]` is always the row value), + /// so the two longhands' computed values came out swapped. Found while adding CSS Grid track + /// sizing support to a downstream renderer. + /// + [TestFixture] + public class GapShorthandComputedStyleTests + { + [Test] + public void GapShorthandDecomposesRowFirstColumnSecond() + { + var document = ParseDocument("
"); + var target = document.GetElementById("target"); + var style = target.ComputeCurrentStyle(); + + Assert.AreEqual("10px", style.GetPropertyValue("row-gap")); + Assert.AreEqual("20px", style.GetPropertyValue("column-gap")); + } + + [Test] + public void GridGapShorthandDecomposesRowFirstColumnSecond() + { + var document = ParseDocument("
"); + var target = document.GetElementById("target"); + var style = target.ComputeCurrentStyle(); + + Assert.AreEqual("10px", style.GetPropertyValue("grid-row-gap")); + Assert.AreEqual("20px", style.GetPropertyValue("grid-column-gap")); + } + } +} diff --git a/src/AngleSharp.Css.Tests/Styling/TextOverflowComputedStyleTests.cs b/src/AngleSharp.Css.Tests/Styling/TextOverflowComputedStyleTests.cs new file mode 100644 index 0000000..8d99675 --- /dev/null +++ b/src/AngleSharp.Css.Tests/Styling/TextOverflowComputedStyleTests.cs @@ -0,0 +1,52 @@ +#nullable disable +namespace AngleSharp.Css.Tests.Styling +{ + using AngleSharp.Dom; + using NUnit.Framework; + using static CssConstructionFunctions; + + /// + /// `text-overflow` had no registered declaration at all (no `TextOverflowDeclaration`, never + /// wired into `DefaultDeclarationFactory`) - `ComputeCurrentStyle().GetPropertyValue("text-overflow")` + /// always reported an empty string, even for an explicitly authored `text-overflow: ellipsis`. + /// Its own placeholder initial-value constant (`InitialValues.TextOverflowDecl`) also used the + /// wrong enum entirely (`OverflowMode`, `visible`/`hidden`/`scroll`/`auto`/`clip`) paired with + /// the wrong keyword (`auto`) for a property whose real initial value is the keyword `clip`. + /// Found while adding `text-overflow: ellipsis` support to a downstream renderer. + /// + [TestFixture] + public class TextOverflowComputedStyleTests + { + [Test] + public void TextOverflowEllipsisIsRecognizedAndComputed() + { + var document = ParseDocument("
"); + var target = document.GetElementById("target"); + + Assert.AreEqual("ellipsis", target.ComputeCurrentStyle().GetPropertyValue("text-overflow")); + } + + [Test] + public void TextOverflowClipIsRecognizedAndComputed() + { + var document = ParseDocument("
"); + var target = document.GetElementById("target"); + + Assert.AreEqual("clip", target.ComputeCurrentStyle().GetPropertyValue("text-overflow")); + } + + [Test] + public void UnsetTextOverflowComputesToEmptyNotItsInitialValue() + { + // Matches the same "never serialized when nothing in the cascade set it explicitly" + // behavior already established for white-space/list-style-type elsewhere in this + // project - an unset property reports empty rather than resolving to its own CSS + // initial value, so a consumer must supply that default itself (see + // AngleSharp.Renderer's ParseTextOverflow). + var document = ParseDocument("
"); + var target = document.GetElementById("target"); + + Assert.AreEqual("", target.ComputeCurrentStyle().GetPropertyValue("text-overflow")); + } + } +} diff --git a/src/AngleSharp.Css.Tests/Styling/UnregisteredPropertiesComputedStyleTests.cs b/src/AngleSharp.Css.Tests/Styling/UnregisteredPropertiesComputedStyleTests.cs new file mode 100644 index 0000000..48c3e69 --- /dev/null +++ b/src/AngleSharp.Css.Tests/Styling/UnregisteredPropertiesComputedStyleTests.cs @@ -0,0 +1,128 @@ +#nullable disable +namespace AngleSharp.Css.Tests.Styling +{ + using AngleSharp.Dom; + using NUnit.Framework; + using System; + using static CssConstructionFunctions; + + /// + /// A diff between Constants/PropertyNames.cs (455 constants) and every property name + /// actually registered in Factories/DefaultDeclarationFactory.cs (419 registrations) + /// turned up 37 gaps. Of those, the 14 covered here also have a public, misleading + /// Get<X>() accessor in Dom/StyleDeclarationExtensions.cs that promises a + /// real value but - with no backing Declaration class and no factory registration - + /// always silently returns an empty string regardless of what was authored, mirroring the + /// exact text-overflow gap already found and fixed (see + /// ). Found while auditing AngleSharp.Css for a + /// downstream renderer after that fix landed. + /// + /// The remaining 23 of the 37 gaps are deliberately not covered here: max-zoom/ + /// min-zoom/orientation/scroll-snap-align/user-zoom have no + /// accessor at all (nothing publicly promises support, so there is no misleading API to fix), + /// and accelerator/behavior/ime-mode/layout-grid (and its four + /// sub-properties)/zoom/clip-top/clip-right/clip-bottom/ + /// clip-left/glyph-orientation-horizontal/glyph-orientation-vertical are + /// legacy Trident-only extensions or deprecated SVG 1.1 properties with no modern-browser + /// relevance - not worth a registered declaration. + /// + /// Each test asserts the value a real declaration would compute, so each currently fails + /// (the actual computed value is empty) until that property gets a real + /// XDeclaration.cs registered in DefaultDeclarationFactory, mirroring + /// TextOverflowDeclaration's own shape. + /// + [TestFixture] + public class UnregisteredPropertiesComputedStyleTests + { + private static String Compute(String declarationCss, String propertyName) + { + var document = ParseDocument($"
"); + var target = document.GetElementById("target"); + return target.ComputeCurrentStyle().GetPropertyValue(propertyName); + } + + [Test] + public void WritingModeIsRecognizedAndComputed() + { + Assert.AreEqual("vertical-rl", Compute("writing-mode: vertical-rl;", "writing-mode")); + } + + [Test] + public void ClipPathIsRecognizedAndComputed() + { + Assert.AreEqual("circle(50%)", Compute("clip-path: circle(50%);", "clip-path")); + } + + [Test] + public void MaskIsRecognizedAndComputed() + { + Assert.AreEqual("url(\"#m\")", Compute("mask: url(#m);", "mask")); + } + + [Test] + public void FillRuleIsRecognizedAndComputed() + { + Assert.AreEqual("evenodd", Compute("fill-rule: evenodd;", "fill-rule")); + } + + [Test] + public void FillOpacityIsRecognizedAndComputed() + { + Assert.AreEqual("0.5", Compute("fill-opacity: 0.5;", "fill-opacity")); + } + + [Test] + public void ClipRuleIsRecognizedAndComputed() + { + Assert.AreEqual("evenodd", Compute("clip-rule: evenodd;", "clip-rule")); + } + + [Test] + public void MarkerStartIsRecognizedAndComputed() + { + Assert.AreEqual("url(\"#a\")", Compute("marker-start: url(#a);", "marker-start")); + } + + [Test] + public void MarkerMidIsRecognizedAndComputed() + { + Assert.AreEqual("url(\"#a\")", Compute("marker-mid: url(#a);", "marker-mid")); + } + + [Test] + public void MarkerEndIsRecognizedAndComputed() + { + Assert.AreEqual("url(\"#a\")", Compute("marker-end: url(#a);", "marker-end")); + } + + [Test] + public void TextUnderlinePositionIsRecognizedAndComputed() + { + Assert.AreEqual("under", Compute("text-underline-position: under;", "text-underline-position")); + } + + [Test] + public void BaselineShiftIsRecognizedAndComputed() + { + Assert.AreEqual("sub", Compute("baseline-shift: sub;", "baseline-shift")); + } + + [Test] + public void DominantBaselineIsRecognizedAndComputed() + { + Assert.AreEqual("middle", Compute("dominant-baseline: middle;", "dominant-baseline")); + } + + [Test] + public void AlignmentBaselineIsRecognizedAndComputed() + { + Assert.AreEqual("middle", Compute("alignment-baseline: middle;", "alignment-baseline")); + } + + [Test] + public void ColorInterpolationFiltersIsRecognizedAndComputed() + { + Assert.AreEqual("sRGB", Compute("color-interpolation-filters: sRGB;", "color-interpolation-filters")); + } + } +} diff --git a/src/AngleSharp.Css.Tests/Values/BorderImageComputation.cs b/src/AngleSharp.Css.Tests/Values/BorderImageComputation.cs new file mode 100644 index 0000000..37b27cb --- /dev/null +++ b/src/AngleSharp.Css.Tests/Values/BorderImageComputation.cs @@ -0,0 +1,52 @@ +#nullable disable +namespace AngleSharp.Css.Tests.Values +{ + using AngleSharp.Css.Tests.Mocks; + using AngleSharp.Dom; + using NUnit.Framework; + + /// + /// Regression test for a confirmed bug in CssBorderImageSliceValue, found while + /// auditing AngleSharp.Css for a downstream renderer: its own + /// ICssValue.Compute(ICssComputeContext) hard-casts every one of its four corners with + /// (CssLengthValue)(...).Compute(context), but a border-image-slice component's own + /// grammar is <number> | <percentage>, not <length> - so an + /// ordinary, spec-valid slice value like the bare number 30 in + /// border-image: url(x.png) 30; computes to a CssPercentageValue/ + /// CssNumberValue, not a CssLengthValue, and the cast throws + /// InvalidCastException instead of computing normally - crashing the entire render + /// (RenderTreeBuilder computes an element's whole style declaration eagerly), not just + /// whatever reads border-image. + /// + /// Two related, narrower hypotheses were also checked here and ruled out, rather than assumed: + /// + /// - CssBorderImageValue (the shorthand's own composite type) has the identical + /// "unconditional .Compute() on a field that can legitimately be null" shape already + /// fixed for the three gradient value types (see ) and + /// CssTupleValue<T> (see GridComputation.cs) - but it is never actually + /// reached this way: border-image's PropertyFlags.Shorthand flag makes it + /// decompose into its five longhands at parse time, so CssBorderImageValue.Compute() + /// itself is not invoked by ordinary eager rendering (confirmed empirically - a + /// border-image: url(x.png); with no slice/width/outset/repeat clause at all, which + /// would leave those fields genuinely null, does not throw). + /// + /// - CssShapeValue (the other candidate originally flagged alongside this one, for the + /// same reason) was also ruled out: ShapeParser.ParseShape - its only production call + /// site - requires all four of top/right/bottom/left to have + /// parsed successfully before ever constructing one, so its own unconditional + /// .Compute() calls, while equally unguarded in the source, are never actually + /// reachable with a null field today. + /// + [TestFixture] + public class BorderImageComputationTests + { + [Test] + public void BorderImageSliceAcceptsAPlainNumberWithoutThrowingWhenComputed() + { + var document = "
".ToHtmlDocument(Configuration.Default.WithRenderDevice().WithCss()); + var window = document.DefaultView; + + Assert.DoesNotThrow(() => window.Render(new PlainRenderDevice())); + } + } +} diff --git a/src/AngleSharp.Css.Tests/Values/Gradient.cs b/src/AngleSharp.Css.Tests/Values/Gradient.cs index 59ef376..75de88d 100644 --- a/src/AngleSharp.Css.Tests/Values/Gradient.cs +++ b/src/AngleSharp.Css.Tests/Values/Gradient.cs @@ -471,5 +471,46 @@ public void BackgroundImageRepeatingRadialGradientFunky() Assert.AreEqual(CssColorValue.FromName("yellow").Value, ((CssGradientStopValue)stops[3]).Color); Assert.AreEqual(CssColorValue.FromName("red").Value, ((CssGradientStopValue)stops[4]).Color); } + + [Test] + public void ConicGradientDefaultAngleIsZeroNotHalfCircle() + { + // https://drafts.csswg.org/css-images-4/#conic-gradients: "from " defaults to + // 0deg when omitted. CssConicGradientValue.Angle instead falls back to + // CssAngleValue.Half (180deg) - correct for CssLinearGradientValue.Angle's own default + // ("to bottom" is 180deg), but wrong for conic-gradient, which does not share linear + // gradient's default direction. Confirmed via Arguments.Length == Stops.Length (proving + // no angle/center was actually parsed - the leading-argument insertion in + // CssConicGradientValue.Arguments only happens for a value that was really authored), + // so this is the property's own fallback being wrong, not a parsing gap. + var source = "conic-gradient(red, blue)"; + var gradient = GradientConverter.Convert(source) as CssConicGradientValue; + + Assert.IsNotNull(gradient); + Assert.AreEqual(gradient.Stops.Length, gradient.Arguments.Length, "no angle/center was actually authored"); + Assert.AreEqual(CssAngleValue.Zero, gradient.Angle, "an omitted `from` should default to 0deg per spec, not 180deg"); + } + + [Test] + public void ConicGradientAcceptsAngleBasedStopPositions() + { + // A conic-gradient stop is naturally positioned with an angle ("red 0deg"), not just a + // - GradientParser.ParseGradientStop only ever tried + // ParseDistanceOrCalc for a stop's position (shared, unparameterized, across linear/ + // radial/conic), which does not accept "0deg" at all. That left the source mid-token + // instead of at the following comma/close-paren, which made the *entire* gradient fail + // to parse (GradientConverter.Convert returning null) rather than just that one stop's + // position - confirmed by first reproducing with GradientParser.ParseGradient directly + // before tracing it to this one call site. + var source = "conic-gradient(red 0deg, blue 90deg, green 1turn)"; + var gradient = GradientConverter.Convert(source) as CssConicGradientValue; + + Assert.IsNotNull(gradient); + var stops = gradient.Stops.OfType().ToArray(); + Assert.AreEqual(3, stops.Length); + Assert.AreEqual(CssAngleValue.Zero, stops[0].Location); + Assert.AreEqual(CssAngleValue.Quarter, stops[1].Location); + Assert.AreEqual(new CssAngleValue(360.0, CssAngleValue.Unit.Deg), stops[2].Location); + } } } diff --git a/src/AngleSharp.Css.Tests/Values/GradientComputation.cs b/src/AngleSharp.Css.Tests/Values/GradientComputation.cs new file mode 100644 index 0000000..ac07594 --- /dev/null +++ b/src/AngleSharp.Css.Tests/Values/GradientComputation.cs @@ -0,0 +1,59 @@ +#nullable disable +namespace AngleSharp.Css.Tests.Values +{ + using AngleSharp.Css.Tests.Mocks; + using AngleSharp.Dom; + using NUnit.Framework; + using static CssConstructionFunctions; + + /// + /// Regression tests for a confirmed bug shared by all three gradient function values + /// (`CssLinearGradientValue`/`CssRadialGradientValue`/`CssConicGradientValue`), found while + /// integrating this library's gradient support into a downstream renderer - the same class of + /// bug already pins down for `transform`: each gradient's + /// own `ICssValue.Compute(ICssComputeContext)` unconditionally calls `.Compute(context)` on a + /// field that is legitimately `null` whenever the corresponding clause was not authored (the + /// single most common way each gradient function is actually written - no explicit angle/size/ + /// center at all), throwing a `NullReferenceException` instead of computing normally. + /// RenderTreeBuilder computes an element's entire style declaration eagerly while constructing + /// the render tree, so this crashes the whole render, not just whatever reads the gradient. + /// + [TestFixture] + public class GradientComputationTests + { + [Test] + public void LinearGradientWithNoAngleDoesNotThrowWhenComputed() + { + // CssLinearGradientValue.Compute() calls _angle.Compute(context) with no null check; + // _angle is null for the default "to bottom" direction (no `to `/angle authored). + var document = "
".ToHtmlDocument(Configuration.Default.WithRenderDevice().WithCss()); + var window = document.DefaultView; + + Assert.DoesNotThrow(() => window.Render(new PlainRenderDevice())); + } + + [Test] + public void RadialGradientWithNoSizeDoesNotThrowWhenComputed() + { + // CssRadialGradientValue.Compute() calls _width.Compute(context)/_height.Compute(context) + // with no null check; both are null for the default ellipse/farthest-corner sizing (no + // explicit shape/size clause authored at all). + var document = "
".ToHtmlDocument(Configuration.Default.WithRenderDevice().WithCss()); + var window = document.DefaultView; + + Assert.DoesNotThrow(() => window.Render(new PlainRenderDevice())); + } + + [Test] + public void ConicGradientWithNoAngleOrCenterDoesNotThrowWhenComputed() + { + // CssConicGradientValue.Compute() calls _angle.Compute(context)/_center.Compute(context) + // with no null check; both are null for the default 0deg/center (no `from`/`at` clause + // authored at all). + var document = "
".ToHtmlDocument(Configuration.Default.WithRenderDevice().WithCss()); + var window = document.DefaultView; + + Assert.DoesNotThrow(() => window.Render(new PlainRenderDevice())); + } + } +} diff --git a/src/AngleSharp.Css.Tests/Values/GridComputation.cs b/src/AngleSharp.Css.Tests/Values/GridComputation.cs new file mode 100644 index 0000000..2b1f0be --- /dev/null +++ b/src/AngleSharp.Css.Tests/Values/GridComputation.cs @@ -0,0 +1,41 @@ +#nullable disable +namespace AngleSharp.Css.Tests.Values +{ + using AngleSharp.Css.Tests.Mocks; + using AngleSharp.Dom; + using NUnit.Framework; + using static CssConstructionFunctions; + + /// + /// Regression tests for a confirmed bug in CssTupleValue<T>.ICssValue.Compute + /// (Values/Multiples/CssTupleValue.cs), found while integrating this library's CSS Grid track + /// sizing support into a downstream renderer - the same class of bug already pinned down for + /// `transform`/gradients elsewhere in this project: it unconditionally called `.Compute(context)` + /// on every tuple item with no null check, but `grid-column`/`grid-row` legitimately represent an + /// omitted end line as a null item (`2 / span 2` has no explicit end line - only a start line and + /// a span) - the single most common way a spanning grid item is actually placed. This crashed the + /// *entire* render tree (RenderTreeBuilder computes an element's whole style declaration eagerly), + /// not just whatever read the placement. + /// + [TestFixture] + public class GridComputationTests + { + [Test] + public void GridColumnWithOnlyAStartLineAndSpanDoesNotThrowWhenComputed() + { + var document = "
".ToHtmlDocument(Configuration.Default.WithRenderDevice().WithCss()); + var window = document.DefaultView; + + Assert.DoesNotThrow(() => window.Render(new PlainRenderDevice())); + } + + [Test] + public void GridRowWithOnlyASpanDoesNotThrowWhenComputed() + { + var document = "
".ToHtmlDocument(Configuration.Default.WithRenderDevice().WithCss()); + var window = document.DefaultView; + + Assert.DoesNotThrow(() => window.Render(new PlainRenderDevice())); + } + } +} diff --git a/src/AngleSharp.Css.Tests/Values/Point2DComputation.cs b/src/AngleSharp.Css.Tests/Values/Point2DComputation.cs new file mode 100644 index 0000000..20aff06 --- /dev/null +++ b/src/AngleSharp.Css.Tests/Values/Point2DComputation.cs @@ -0,0 +1,58 @@ +#nullable disable +namespace AngleSharp.Css.Tests.Values +{ + using AngleSharp.Dom; + using AngleSharp.Html.Parser; + using NUnit.Framework; + + /// + /// Regression test for a confirmed bug in CssPoint2D.ICssValue.Compute(ICssComputeContext) + /// (Values/Composites/CssPoint2D.cs): its y local is assigned from + /// _x.Compute(context) instead of _y.Compute(context) - a copy-paste typo that + /// makes the computed Y coordinate track the X component's own value instead of its own, + /// whenever the two axes' raw values actually differ (a symmetric point like the default + /// "center center" happens to hide this, since X and Y start out equal). Found while + /// integrating a downstream renderer's CSS gradient support (a radial/conic gradient's `at + /// ` is a `CssPoint2D`), but this affects any consumer computing a point with + /// unequal axes - `background-position` reproduces it just as directly. + /// + [TestFixture] + public class Point2DComputationTests + { + private static IDocument ParseWithRenderDevice(string html, int viewPortWidth, int viewPortHeight) + { + var config = Configuration.Default + .WithCss() + .WithRenderDevice(new DefaultRenderDevice { ViewPortWidth = viewPortWidth, ViewPortHeight = viewPortHeight }); + var browsingContext = BrowsingContext.New(config); + var htmlParser = browsingContext.GetService(); + return htmlParser.ParseDocument(html); + } + + [Test] + public void ComputedYCoordinateTracksItsOwnValueNotX() + { + // A radial-gradient's `at ` is a CssPoint2D. Before the fix, Y always came + // out equal to X's own computed value regardless of what Y itself was authored as - + // 20% 80% computed to "200px 200px" (both from X's 20%), not "200px 800px". + var document = ParseWithRenderDevice( + "
", 1000, 1000); + var target = document.GetElementById("target"); + + var computed = target.ComputeCurrentStyle().GetPropertyValue("background-image"); + + StringAssert.Contains("200px 800px", computed); + } + + // A separate, still-open, narrower issue surfaced alongside this fix: Y always resolves + // its own percentage/length against the viewport's *width*, never its height (confirmed: + // "20% 80%" resolves to "200px 800px" regardless of viewport height, i.e. 80% of the 1000px + // *width* - matching a coincidence in this test's own numbers, not genuine height-tracking). + // That is a materially different, deeper problem (percentage resolution mode not threaded + // per-axis through a point's own Compute() at all) than the X-value-copied-into-Y typo this + // test pins down, and is not fixed here - a downstream renderer relying on correct Y-axis + // percentage resolution for a computed point still needs to read the pre-Compute() specified + // value instead (as AngleSharp.Renderer's own ResolveExplicitBackgroundImage now does for + // exactly this reason), not the computed one. + } +} diff --git a/src/AngleSharp.Css/Constants/CssKeywords.cs b/src/AngleSharp.Css/Constants/CssKeywords.cs index 7b03468..790cdbc 100644 --- a/src/AngleSharp.Css/Constants/CssKeywords.cs +++ b/src/AngleSharp.Css/Constants/CssKeywords.cs @@ -27,6 +27,11 @@ public static class CssKeywords /// public static readonly String Clip = "clip"; + /// + /// The ellipsis keyword. + /// + public static readonly String Ellipsis = "ellipsis"; + /// /// The cyclic keyword. /// diff --git a/src/AngleSharp.Css/Constants/InitialValues.cs b/src/AngleSharp.Css/Constants/InitialValues.cs index a6d9b59..5f6247c 100644 --- a/src/AngleSharp.Css/Constants/InitialValues.cs +++ b/src/AngleSharp.Css/Constants/InitialValues.cs @@ -191,7 +191,7 @@ static class InitialValues public static readonly ICssValue TextTransformDecl = new CssConstantValue(CssKeywords.None, null); public static readonly ICssValue TextShadowDecl = new CssConstantValue(CssKeywords.None, null); public static readonly ICssValue TextRenderingDecl = new CssConstantValue(CssKeywords.Auto, null); - public static readonly ICssValue TextOverflowDecl = new CssConstantValue(CssKeywords.Auto, OverflowMode.Clip); + public static readonly ICssValue TextOverflowDecl = new CssConstantValue(CssKeywords.Clip, TextOverflow.Clip); public static readonly ICssValue TextOrientationDecl = new CssConstantValue(CssKeywords.Mixed, null); public static readonly ICssValue TextJustifyDecl = new CssConstantValue(CssKeywords.Auto, TextJustify.Auto); public static readonly ICssValue TextIndentDecl = CssLengthValue.Zero; @@ -331,6 +331,17 @@ static class InitialValues public static readonly ICssValue PositionTryOrderDecl = new CssIdentifierValue(CssKeywords.Normal); public static readonly ICssValue PositionVisibilityDecl = new CssIdentifierValue(CssKeywords.Auto); public static readonly ICssValue TextUnderlineOffsetDecl = CssLengthValue.Zero; + public static readonly ICssValue TextUnderlinePositionDecl = new CssIdentifierValue(CssKeywords.Auto); + public static readonly ICssValue WritingModeDecl = new CssIdentifierValue("horizontal-tb"); + public static readonly ICssValue ClipPathDecl = new CssIdentifierValue(CssKeywords.None); + public static readonly ICssValue FillRuleDecl = new CssIdentifierValue("nonzero"); + public static readonly ICssValue FillOpacityDecl = new CssNumberValue(1.0); + public static readonly ICssValue ClipRuleDecl = new CssIdentifierValue("nonzero"); + public static readonly ICssValue MarkerDecl = new CssIdentifierValue(CssKeywords.None); + public static readonly ICssValue BaselineShiftDecl = new CssIdentifierValue("baseline"); + public static readonly ICssValue DominantBaselineDecl = new CssIdentifierValue(CssKeywords.Auto); + public static readonly ICssValue AlignmentBaselineDecl = new CssIdentifierValue("baseline"); + public static readonly ICssValue ColorInterpolationFiltersDecl = new CssIdentifierValue("linearRGB"); public static readonly ICssValue TextDecorationThicknessDecl = new CssIdentifierValue(CssKeywords.Auto); public static readonly ICssValue TextDecorationSkipInkDecl = new CssIdentifierValue(CssKeywords.Auto); public static readonly ICssValue TextWrapDecl = new CssIdentifierValue(CssKeywords.Wrap); diff --git a/src/AngleSharp.Css/Constants/Map.cs b/src/AngleSharp.Css/Constants/Map.cs index f08ea82..3051f8c 100644 --- a/src/AngleSharp.Css/Constants/Map.cs +++ b/src/AngleSharp.Css/Constants/Map.cs @@ -779,6 +779,15 @@ static class Map { CssKeywords.BreakWord, OverflowWrap.BreakWord }, }; + /// + /// Contains the string-TextOverflow mapping. + /// + public static readonly Dictionary TextOverflows = new(StringComparer.OrdinalIgnoreCase) + { + { CssKeywords.Clip, TextOverflow.Clip }, + { CssKeywords.Ellipsis, TextOverflow.Ellipsis }, + }; + /// /// Contains the string-ResizeMode mapping. /// diff --git a/src/AngleSharp.Css/CssDefaultStyleSheetProvider.cs b/src/AngleSharp.Css/CssDefaultStyleSheetProvider.cs index f52d5da..621fe97 100644 --- a/src/AngleSharp.Css/CssDefaultStyleSheetProvider.cs +++ b/src/AngleSharp.Css/CssDefaultStyleSheetProvider.cs @@ -102,7 +102,8 @@ private static ICssStyleSheet Parse(String source) ol ul, ul ol, ul ul, ol ol { margin-top: 0; margin-bottom: 0 } u, ins { text-decoration: underline } -br:before { content: '\A'; white-space: pre-line } +br::before { content: '\A'; white-space: pre-line } +*::before, *::after { display: inline } center { text-align: center } :link, :visited { text-decoration: underline } :focus { outline: thin dotted invert } diff --git a/src/AngleSharp.Css/Declarations/AlignmentBaselineDeclaration.cs b/src/AngleSharp.Css/Declarations/AlignmentBaselineDeclaration.cs new file mode 100644 index 0000000..6f91ed7 --- /dev/null +++ b/src/AngleSharp.Css/Declarations/AlignmentBaselineDeclaration.cs @@ -0,0 +1,17 @@ +namespace AngleSharp.Css.Declarations +{ + using AngleSharp.Css.Dom; + using System; + using static ValueConverters; + + static class AlignmentBaselineDeclaration + { + public static String Name = PropertyNames.AlignBaseline; + + public static IValueConverter Converter = IdentifierConverter; + + public static ICssValue InitialValue = InitialValues.AlignmentBaselineDecl; + + public static PropertyFlags Flags = PropertyFlags.Inherited; + } +} \ No newline at end of file diff --git a/src/AngleSharp.Css/Declarations/BaselineShiftDeclaration.cs b/src/AngleSharp.Css/Declarations/BaselineShiftDeclaration.cs new file mode 100644 index 0000000..8346a56 --- /dev/null +++ b/src/AngleSharp.Css/Declarations/BaselineShiftDeclaration.cs @@ -0,0 +1,21 @@ +namespace AngleSharp.Css.Declarations +{ + using AngleSharp.Css.Dom; + using System; + using static ValueConverters; + + static class BaselineShiftDeclaration + { + public static String Name = PropertyNames.BaselineShift; + + public static IValueConverter Converter = Or( + LengthOrPercentConverter, + Assign("baseline", "baseline"), + Assign(CssKeywords.Sub, CssKeywords.Sub), + Assign(CssKeywords.Super, CssKeywords.Super)); + + public static ICssValue InitialValue = InitialValues.BaselineShiftDecl; + + public static PropertyFlags Flags = PropertyFlags.Inherited | PropertyFlags.Animatable; + } +} \ No newline at end of file diff --git a/src/AngleSharp.Css/Declarations/ClipPathDeclaration.cs b/src/AngleSharp.Css/Declarations/ClipPathDeclaration.cs new file mode 100644 index 0000000..5aee2ed --- /dev/null +++ b/src/AngleSharp.Css/Declarations/ClipPathDeclaration.cs @@ -0,0 +1,17 @@ +namespace AngleSharp.Css.Declarations +{ + using AngleSharp.Css.Dom; + using System; + using static ValueConverters; + + static class ClipPathDeclaration + { + public static String Name = PropertyNames.ClipPath; + + public static IValueConverter Converter = Any; + + public static ICssValue InitialValue = InitialValues.ClipPathDecl; + + public static PropertyFlags Flags = PropertyFlags.Animatable; + } +} \ No newline at end of file diff --git a/src/AngleSharp.Css/Declarations/ClipRuleDeclaration.cs b/src/AngleSharp.Css/Declarations/ClipRuleDeclaration.cs new file mode 100644 index 0000000..7b39e23 --- /dev/null +++ b/src/AngleSharp.Css/Declarations/ClipRuleDeclaration.cs @@ -0,0 +1,19 @@ +namespace AngleSharp.Css.Declarations +{ + using AngleSharp.Css.Dom; + using System; + using static ValueConverters; + + static class ClipRuleDeclaration + { + public static String Name = PropertyNames.ClipRule; + + public static IValueConverter Converter = Or( + Assign("nonzero", "nonzero"), + Assign("evenodd", "evenodd")); + + public static ICssValue InitialValue = InitialValues.ClipRuleDecl; + + public static PropertyFlags Flags = PropertyFlags.Inherited | PropertyFlags.Animatable; + } +} \ No newline at end of file diff --git a/src/AngleSharp.Css/Declarations/ColorInterpolationFiltersDeclaration.cs b/src/AngleSharp.Css/Declarations/ColorInterpolationFiltersDeclaration.cs new file mode 100644 index 0000000..0e44bdb --- /dev/null +++ b/src/AngleSharp.Css/Declarations/ColorInterpolationFiltersDeclaration.cs @@ -0,0 +1,20 @@ +namespace AngleSharp.Css.Declarations +{ + using AngleSharp.Css.Dom; + using System; + using static ValueConverters; + + static class ColorInterpolationFiltersDeclaration + { + public static String Name = PropertyNames.ColorInterpolationFilters; + + public static IValueConverter Converter = Or( + Assign(CssKeywords.Auto, CssKeywords.Auto), + Assign("sRGB", "sRGB"), + Assign("linearRGB", "linearRGB")); + + public static ICssValue InitialValue = InitialValues.ColorInterpolationFiltersDecl; + + public static PropertyFlags Flags = PropertyFlags.Inherited | PropertyFlags.Animatable; + } +} \ No newline at end of file diff --git a/src/AngleSharp.Css/Declarations/ContentDeclaration.cs b/src/AngleSharp.Css/Declarations/ContentDeclaration.cs index 6ebc16a..e5e0fba 100644 --- a/src/AngleSharp.Css/Declarations/ContentDeclaration.cs +++ b/src/AngleSharp.Css/Declarations/ContentDeclaration.cs @@ -21,6 +21,27 @@ static class ContentDeclaration public static PropertyFlags Flags = PropertyFlags.None; + /// + /// Whether 's own computed content actually generates content + /// - used by to decide whether a ::before/ + /// ::after pseudo-element gets a render-tree node at all (per spec, none and the + /// initial/unset normal - the only values parses to + /// zero modes for - generate no box). + /// + public static Boolean HasContent(ICssStyleDeclaration style) => + (style as CssStyleDeclaration)?.GetProperty(Name)?.RawValue is ContentValueConverter.ContentValue value && value.HasContent; + + /// + /// Resolves 's own computed content into its final text against + /// (e.g. resolving attr() against a live attribute) - used by + /// to synthesize a ::before/::after + /// pseudo-element's single generated-content child. Returns an empty string for anything not + /// itself a parsed (unset, none, or a + /// value that failed to parse), matching 's own "no box" gate. + /// + public static String Stringify(ICssStyleDeclaration style, IElement element) => + (style as CssStyleDeclaration)?.GetProperty(Name)?.RawValue is ContentValueConverter.ContentValue value ? value.Stringify(element) : String.Empty; + sealed class ContentValueConverter : IValueConverter { private static readonly Dictionary ContentModes = new(StringComparer.OrdinalIgnoreCase) @@ -109,7 +130,7 @@ public ICssValue Convert(StringSource source) return null; } - private sealed class ContentValue : ICssValue, IEquatable + internal sealed class ContentValue : ICssValue, IEquatable { private readonly ICssValue[] _modes; @@ -118,6 +139,20 @@ public ContentValue(ICssValue[] modes) _modes = modes; } + /// + /// Whether this value actually generates content (i.e. was not none, nor the + /// initial/unset normal - both of which parse to zero modes). + /// + public Boolean HasContent => _modes.Length > 0; + + /// + /// Resolves every mode's own text contribution against and + /// concatenates them - the same per-mode + /// this converter's modes already implement (e.g. + /// reading a live attribute), just not previously reachable from outside this type. + /// + public String Stringify(IElement element) => String.Concat(_modes.OfType().Select(mode => mode.Stringify(element))); + public String CssText => _modes.Length == 0 ? CssKeywords.None : _modes.Join(" "); public ICssValue Compute(ICssComputeContext context) diff --git a/src/AngleSharp.Css/Declarations/DominantBaselineDeclaration.cs b/src/AngleSharp.Css/Declarations/DominantBaselineDeclaration.cs new file mode 100644 index 0000000..9a93a13 --- /dev/null +++ b/src/AngleSharp.Css/Declarations/DominantBaselineDeclaration.cs @@ -0,0 +1,17 @@ +namespace AngleSharp.Css.Declarations +{ + using AngleSharp.Css.Dom; + using System; + using static ValueConverters; + + static class DominantBaselineDeclaration + { + public static String Name = PropertyNames.DominantBaseline; + + public static IValueConverter Converter = IdentifierConverter; + + public static ICssValue InitialValue = InitialValues.DominantBaselineDecl; + + public static PropertyFlags Flags = PropertyFlags.Inherited; + } +} \ No newline at end of file diff --git a/src/AngleSharp.Css/Declarations/FillOpacityDeclaration.cs b/src/AngleSharp.Css/Declarations/FillOpacityDeclaration.cs new file mode 100644 index 0000000..9090a32 --- /dev/null +++ b/src/AngleSharp.Css/Declarations/FillOpacityDeclaration.cs @@ -0,0 +1,17 @@ +namespace AngleSharp.Css.Declarations +{ + using AngleSharp.Css.Dom; + using System; + using static ValueConverters; + + static class FillOpacityDeclaration + { + public static String Name = PropertyNames.FillOpacity; + + public static IValueConverter Converter = NumberConverter; + + public static ICssValue InitialValue = InitialValues.FillOpacityDecl; + + public static PropertyFlags Flags = PropertyFlags.Inherited | PropertyFlags.Animatable; + } +} \ No newline at end of file diff --git a/src/AngleSharp.Css/Declarations/FillRuleDeclaration.cs b/src/AngleSharp.Css/Declarations/FillRuleDeclaration.cs new file mode 100644 index 0000000..f10475c --- /dev/null +++ b/src/AngleSharp.Css/Declarations/FillRuleDeclaration.cs @@ -0,0 +1,19 @@ +namespace AngleSharp.Css.Declarations +{ + using AngleSharp.Css.Dom; + using System; + using static ValueConverters; + + static class FillRuleDeclaration + { + public static String Name = PropertyNames.FillRule; + + public static IValueConverter Converter = Or( + Assign("nonzero", "nonzero"), + Assign("evenodd", "evenodd")); + + public static ICssValue InitialValue = InitialValues.FillRuleDecl; + + public static PropertyFlags Flags = PropertyFlags.Inherited | PropertyFlags.Animatable; + } +} \ No newline at end of file diff --git a/src/AngleSharp.Css/Declarations/GapDeclaration.cs b/src/AngleSharp.Css/Declarations/GapDeclaration.cs index ac2376a..8f5cc9f 100644 --- a/src/AngleSharp.Css/Declarations/GapDeclaration.cs +++ b/src/AngleSharp.Css/Declarations/GapDeclaration.cs @@ -11,10 +11,14 @@ static class GapDeclaration { public static readonly String Name = PropertyNames.Gap; + // Order must match GapAggregagtor.Split()'s own [row, col] convention (values[0]/Items[0] + // is always treated as the row value by both Merge() and Split() below) - this used to list + // ColumnGap first, pairing it with the row value and vice versa, so `gap: 10px 20px` + // (row-gap 10px, column-gap 20px per spec) computed row-gap as 20px and column-gap as 10px. public static readonly String[] Longhands = new[] { - PropertyNames.ColumnGap, PropertyNames.RowGap, + PropertyNames.ColumnGap, }; public static readonly IValueConverter Converter = new GapAggregagtor(); diff --git a/src/AngleSharp.Css/Declarations/GridGapDeclaration.cs b/src/AngleSharp.Css/Declarations/GridGapDeclaration.cs index 0f39ba4..0766fac 100644 --- a/src/AngleSharp.Css/Declarations/GridGapDeclaration.cs +++ b/src/AngleSharp.Css/Declarations/GridGapDeclaration.cs @@ -11,10 +11,12 @@ static class GridGapDeclaration { public static readonly String Name = PropertyNames.GridGap; + // Same fix, same reasoning as GapDeclaration.Longhands - order must match + // GridGapAggregagtor.Split()'s own [row, col] convention. public static readonly String[] Longhands = new[] { - PropertyNames.GridColumnGap, PropertyNames.GridRowGap, + PropertyNames.GridColumnGap, }; public static readonly IValueConverter Converter = new GridGapAggregagtor(); diff --git a/src/AngleSharp.Css/Declarations/MarkerEndDeclaration.cs b/src/AngleSharp.Css/Declarations/MarkerEndDeclaration.cs new file mode 100644 index 0000000..0197b04 --- /dev/null +++ b/src/AngleSharp.Css/Declarations/MarkerEndDeclaration.cs @@ -0,0 +1,17 @@ +namespace AngleSharp.Css.Declarations +{ + using AngleSharp.Css.Dom; + using System; + using static ValueConverters; + + static class MarkerEndDeclaration + { + public static String Name = PropertyNames.MarkerEnd; + + public static IValueConverter Converter = Or(None, UrlConverter); + + public static ICssValue InitialValue = InitialValues.MarkerDecl; + + public static PropertyFlags Flags = PropertyFlags.Inherited | PropertyFlags.Animatable; + } +} \ No newline at end of file diff --git a/src/AngleSharp.Css/Declarations/MarkerMidDeclaration.cs b/src/AngleSharp.Css/Declarations/MarkerMidDeclaration.cs new file mode 100644 index 0000000..647148f --- /dev/null +++ b/src/AngleSharp.Css/Declarations/MarkerMidDeclaration.cs @@ -0,0 +1,17 @@ +namespace AngleSharp.Css.Declarations +{ + using AngleSharp.Css.Dom; + using System; + using static ValueConverters; + + static class MarkerMidDeclaration + { + public static String Name = PropertyNames.MarkerMid; + + public static IValueConverter Converter = Or(None, UrlConverter); + + public static ICssValue InitialValue = InitialValues.MarkerDecl; + + public static PropertyFlags Flags = PropertyFlags.Inherited | PropertyFlags.Animatable; + } +} \ No newline at end of file diff --git a/src/AngleSharp.Css/Declarations/MarkerStartDeclaration.cs b/src/AngleSharp.Css/Declarations/MarkerStartDeclaration.cs new file mode 100644 index 0000000..92a58a4 --- /dev/null +++ b/src/AngleSharp.Css/Declarations/MarkerStartDeclaration.cs @@ -0,0 +1,17 @@ +namespace AngleSharp.Css.Declarations +{ + using AngleSharp.Css.Dom; + using System; + using static ValueConverters; + + static class MarkerStartDeclaration + { + public static String Name = PropertyNames.MarkerStart; + + public static IValueConverter Converter = Or(None, UrlConverter); + + public static ICssValue InitialValue = InitialValues.MarkerDecl; + + public static PropertyFlags Flags = PropertyFlags.Inherited | PropertyFlags.Animatable; + } +} \ No newline at end of file diff --git a/src/AngleSharp.Css/Declarations/MaskDeclaration.cs b/src/AngleSharp.Css/Declarations/MaskDeclaration.cs new file mode 100644 index 0000000..9c3f66c --- /dev/null +++ b/src/AngleSharp.Css/Declarations/MaskDeclaration.cs @@ -0,0 +1,17 @@ +namespace AngleSharp.Css.Declarations +{ + using AngleSharp.Css.Dom; + using System; + using static ValueConverters; + + static class MaskDeclaration + { + public static String Name = PropertyNames.Mask; + + public static IValueConverter Converter = MaskImageConverter; + + public static ICssValue InitialValue = InitialValues.MaskImageDecl; + + public static PropertyFlags Flags = PropertyFlags.None; + } +} \ No newline at end of file diff --git a/src/AngleSharp.Css/Declarations/TextOverflowDeclaration.cs b/src/AngleSharp.Css/Declarations/TextOverflowDeclaration.cs new file mode 100644 index 0000000..b057f69 --- /dev/null +++ b/src/AngleSharp.Css/Declarations/TextOverflowDeclaration.cs @@ -0,0 +1,17 @@ +namespace AngleSharp.Css.Declarations +{ + using AngleSharp.Css.Dom; + using System; + using static ValueConverters; + + static class TextOverflowDeclaration + { + public static String Name = PropertyNames.TextOverflow; + + public static IValueConverter Converter = TextOverflowConverter; + + public static ICssValue InitialValue = InitialValues.TextOverflowDecl; + + public static PropertyFlags Flags = PropertyFlags.None; + } +} diff --git a/src/AngleSharp.Css/Declarations/TextUnderlinePositionDeclaration.cs b/src/AngleSharp.Css/Declarations/TextUnderlinePositionDeclaration.cs new file mode 100644 index 0000000..fe96426 --- /dev/null +++ b/src/AngleSharp.Css/Declarations/TextUnderlinePositionDeclaration.cs @@ -0,0 +1,22 @@ +namespace AngleSharp.Css.Declarations +{ + using AngleSharp.Css.Dom; + using System; + using static ValueConverters; + + static class TextUnderlinePositionDeclaration + { + public static String Name = PropertyNames.TextUnderlinePosition; + + public static IValueConverter Converter = Or( + Assign(CssKeywords.Auto, CssKeywords.Auto), + Assign("from-font", "from-font"), + Assign(CssKeywords.Under, CssKeywords.Under), + Assign("left", "left"), + Assign("right", "right")); + + public static ICssValue InitialValue = InitialValues.TextUnderlinePositionDecl; + + public static PropertyFlags Flags = PropertyFlags.Inherited; + } +} \ No newline at end of file diff --git a/src/AngleSharp.Css/Declarations/WritingModeDeclaration.cs b/src/AngleSharp.Css/Declarations/WritingModeDeclaration.cs new file mode 100644 index 0000000..b417e7d --- /dev/null +++ b/src/AngleSharp.Css/Declarations/WritingModeDeclaration.cs @@ -0,0 +1,22 @@ +namespace AngleSharp.Css.Declarations +{ + using AngleSharp.Css.Dom; + using System; + using static ValueConverters; + + static class WritingModeDeclaration + { + public static String Name = PropertyNames.WritingMode; + + public static IValueConverter Converter = Or( + Assign("horizontal-tb", "horizontal-tb"), + Assign("vertical-rl", "vertical-rl"), + Assign("vertical-lr", "vertical-lr"), + Assign("sideways-rl", "sideways-rl"), + Assign("sideways-lr", "sideways-lr")); + + public static ICssValue InitialValue = InitialValues.WritingModeDecl; + + public static PropertyFlags Flags = PropertyFlags.Inherited; + } +} \ No newline at end of file diff --git a/src/AngleSharp.Css/Dom/TextOverflow.cs b/src/AngleSharp.Css/Dom/TextOverflow.cs new file mode 100644 index 0000000..1e38b62 --- /dev/null +++ b/src/AngleSharp.Css/Dom/TextOverflow.cs @@ -0,0 +1,17 @@ +namespace AngleSharp.Css.Dom +{ + /// + /// An enumeration with all possible Text Overflow options. + /// + public enum TextOverflow : byte + { + /// + /// Indicates that clipped content is fully clipped, with no readable indication. + /// + Clip, + /// + /// Indicates that clipped content is represented by an ellipsis ("…"). + /// + Ellipsis + } +} diff --git a/src/AngleSharp.Css/Factories/DefaultDeclarationFactory.cs b/src/AngleSharp.Css/Factories/DefaultDeclarationFactory.cs index ceb185d..83b2f9c 100644 --- a/src/AngleSharp.Css/Factories/DefaultDeclarationFactory.cs +++ b/src/AngleSharp.Css/Factories/DefaultDeclarationFactory.cs @@ -452,6 +452,111 @@ public class DefaultDeclarationFactory : IDeclarationFactory initialValue: OverflowWrapDeclaration.InitialValue, flags: OverflowWrapDeclaration.Flags) }, + { + TextOverflowDeclaration.Name, new DeclarationInfo( + name: TextOverflowDeclaration.Name, + converter: TextOverflowDeclaration.Converter, + initialValue: TextOverflowDeclaration.InitialValue, + flags: TextOverflowDeclaration.Flags) + }, + { + WritingModeDeclaration.Name, new DeclarationInfo( + name: WritingModeDeclaration.Name, + converter: WritingModeDeclaration.Converter, + initialValue: WritingModeDeclaration.InitialValue, + flags: WritingModeDeclaration.Flags) + }, + { + ClipPathDeclaration.Name, new DeclarationInfo( + name: ClipPathDeclaration.Name, + converter: ClipPathDeclaration.Converter, + initialValue: ClipPathDeclaration.InitialValue, + flags: ClipPathDeclaration.Flags) + }, + { + MaskDeclaration.Name, new DeclarationInfo( + name: MaskDeclaration.Name, + converter: MaskDeclaration.Converter, + initialValue: MaskDeclaration.InitialValue, + flags: MaskDeclaration.Flags) + }, + { + FillRuleDeclaration.Name, new DeclarationInfo( + name: FillRuleDeclaration.Name, + converter: FillRuleDeclaration.Converter, + initialValue: FillRuleDeclaration.InitialValue, + flags: FillRuleDeclaration.Flags) + }, + { + FillOpacityDeclaration.Name, new DeclarationInfo( + name: FillOpacityDeclaration.Name, + converter: FillOpacityDeclaration.Converter, + initialValue: FillOpacityDeclaration.InitialValue, + flags: FillOpacityDeclaration.Flags) + }, + { + ClipRuleDeclaration.Name, new DeclarationInfo( + name: ClipRuleDeclaration.Name, + converter: ClipRuleDeclaration.Converter, + initialValue: ClipRuleDeclaration.InitialValue, + flags: ClipRuleDeclaration.Flags) + }, + { + MarkerStartDeclaration.Name, new DeclarationInfo( + name: MarkerStartDeclaration.Name, + converter: MarkerStartDeclaration.Converter, + initialValue: MarkerStartDeclaration.InitialValue, + flags: MarkerStartDeclaration.Flags) + }, + { + MarkerMidDeclaration.Name, new DeclarationInfo( + name: MarkerMidDeclaration.Name, + converter: MarkerMidDeclaration.Converter, + initialValue: MarkerMidDeclaration.InitialValue, + flags: MarkerMidDeclaration.Flags) + }, + { + MarkerEndDeclaration.Name, new DeclarationInfo( + name: MarkerEndDeclaration.Name, + converter: MarkerEndDeclaration.Converter, + initialValue: MarkerEndDeclaration.InitialValue, + flags: MarkerEndDeclaration.Flags) + }, + { + TextUnderlinePositionDeclaration.Name, new DeclarationInfo( + name: TextUnderlinePositionDeclaration.Name, + converter: TextUnderlinePositionDeclaration.Converter, + initialValue: TextUnderlinePositionDeclaration.InitialValue, + flags: TextUnderlinePositionDeclaration.Flags) + }, + { + BaselineShiftDeclaration.Name, new DeclarationInfo( + name: BaselineShiftDeclaration.Name, + converter: BaselineShiftDeclaration.Converter, + initialValue: BaselineShiftDeclaration.InitialValue, + flags: BaselineShiftDeclaration.Flags) + }, + { + DominantBaselineDeclaration.Name, new DeclarationInfo( + name: DominantBaselineDeclaration.Name, + converter: DominantBaselineDeclaration.Converter, + initialValue: DominantBaselineDeclaration.InitialValue, + flags: DominantBaselineDeclaration.Flags) + }, + { + AlignmentBaselineDeclaration.Name, new DeclarationInfo( + name: AlignmentBaselineDeclaration.Name, + converter: AlignmentBaselineDeclaration.Converter, + initialValue: AlignmentBaselineDeclaration.InitialValue, + flags: AlignmentBaselineDeclaration.Flags) + }, + { + ColorInterpolationFiltersDeclaration.Name, new DeclarationInfo( + name: ColorInterpolationFiltersDeclaration.Name, + converter: ColorInterpolationFiltersDeclaration.Converter, + initialValue: ColorInterpolationFiltersDeclaration.InitialValue, + flags: ColorInterpolationFiltersDeclaration.Flags) + }, { WordWrapDeclaration.Name, new DeclarationInfo( name: WordWrapDeclaration.Name, diff --git a/src/AngleSharp.Css/Parser/Micro/GradientParser.cs b/src/AngleSharp.Css/Parser/Micro/GradientParser.cs index 4244900..a29d4b5 100644 --- a/src/AngleSharp.Css/Parser/Micro/GradientParser.cs +++ b/src/AngleSharp.Css/Parser/Micro/GradientParser.cs @@ -236,7 +236,14 @@ private static CssGradientStopValue ParseGradientStop(StringSource source) { var color = source.ParseColor(); source.SkipSpacesAndComments(); - var position = source.ParseDistanceOrCalc(); + // A conic-gradient's stops are naturally positioned with angles (e.g. "red 0deg"), not + // just - this same stop parser is shared across linear/radial/conic + // (no gradient-kind context is threaded through), so distance/percent is tried first + // (the common case for linear/radial) and angle only as a fallback once that fails and + // backtracks. Without this, any angle-positioned stop left the source mid-token instead + // of at the following comma/close-paren, which made the *entire* gradient fail to parse + // rather than just that one stop's position. + var position = source.ParseDistanceOrCalc() ?? source.ParseAngleOrCalc(); if (color.HasValue) { diff --git a/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs b/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs index bdbc907..f5c54fe 100644 --- a/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs +++ b/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs @@ -1,6 +1,7 @@ namespace AngleSharp.Css.RenderTree { using AngleSharp.Css; + using AngleSharp.Css.Declarations; using AngleSharp.Css.Dom; using AngleSharp.Css.Values; using AngleSharp.Dom; @@ -99,6 +100,33 @@ private ElementRenderNode RenderElement( _cascadedStyles[element] = specifiedStyle; + if (element is IPseudoElement) + { + // A ::before/::after pseudo-element has no DOM children of its own - IElement.ChildNodes + // on the wrapper aliases its host's real children, which the generic walk below would + // otherwise duplicate into the render tree under the pseudo. Its only "child" is the + // text its own computed `content` generates (attr()/literal strings resolved against + // this same element, since a pseudo-element has no attributes of its own to differ from + // its host's). + children.Add(new TextRenderNode(_window.Document.CreateTextNode(ContentDeclaration.Stringify(computedStyle, element)), node)); + return node; + } + + var before = element.Pseudo("before"); + + if (before is not null) + { + var beforeNode = RenderElement(before, collection, node, specifiedStyle, computedStyle); + + // Per spec, `content: none` (and the initial/unset `normal`, equivalent for these two + // pseudo-elements specifically) means no box is generated at all - not merely an empty + // one - so a content-less ::before/::after is simply left out of the tree entirely. + if (ContentDeclaration.HasContent(beforeNode.ComputedStyle)) + { + children.Add(beforeNode); + } + } + foreach (var child in element.ChildNodes) { if (child is IElement childElement) @@ -111,6 +139,18 @@ private ElementRenderNode RenderElement( } } + var after = element.Pseudo("after"); + + if (after is not null) + { + var afterNode = RenderElement(after, collection, node, specifiedStyle, computedStyle); + + if (ContentDeclaration.HasContent(afterNode.ComputedStyle)) + { + children.Add(afterNode); + } + } + return node; } } diff --git a/src/AngleSharp.Css/ValueConverters.cs b/src/AngleSharp.Css/ValueConverters.cs index 987d7a8..3c1726f 100644 --- a/src/AngleSharp.Css/ValueConverters.cs +++ b/src/AngleSharp.Css/ValueConverters.cs @@ -1254,6 +1254,11 @@ static class ValueConverters /// public static readonly IValueConverter OverflowWrapConverter = Map.OverflowWraps.ToConverter(); + /// + /// Represents a converter for the TextOverflow enumeration. + /// + public static readonly IValueConverter TextOverflowConverter = Map.TextOverflows.ToConverter(); + /// /// Represents a converter for the BorderImageRepeat property. /// diff --git a/src/AngleSharp.Css/Values/Composites/CssBorderImageSliceValue.cs b/src/AngleSharp.Css/Values/Composites/CssBorderImageSliceValue.cs index 4fdf865..f60a694 100644 --- a/src/AngleSharp.Css/Values/Composites/CssBorderImageSliceValue.cs +++ b/src/AngleSharp.Css/Values/Composites/CssBorderImageSliceValue.cs @@ -139,10 +139,10 @@ public Boolean Equals(CssBorderImageSliceValue other) ICssValue ICssValue.Compute(ICssComputeContext context) { - var bottom = (CssLengthValue)((ICssValue)_bottom).Compute(context); - var left = (CssLengthValue)((ICssValue)_left).Compute(context); - var right = (CssLengthValue)((ICssValue)_right).Compute(context); - var top = (CssLengthValue)((ICssValue)_top).Compute(context); + var bottom = _bottom.Compute(context); + var left = _left.Compute(context); + var right = _right.Compute(context); + var top = _top.Compute(context); return new CssBorderImageSliceValue(top, right, bottom, left, _filled); } diff --git a/src/AngleSharp.Css/Values/Composites/CssPoint2D.cs b/src/AngleSharp.Css/Values/Composites/CssPoint2D.cs index 4325a72..5644b8a 100644 --- a/src/AngleSharp.Css/Values/Composites/CssPoint2D.cs +++ b/src/AngleSharp.Css/Values/Composites/CssPoint2D.cs @@ -163,7 +163,7 @@ public Boolean Equals(CssPoint2D other) ICssValue ICssValue.Compute(ICssComputeContext context) { var x = _x.Compute(context); - var y = _x.Compute(context); + var y = _y.Compute(context); if (x != _x || y != _y) { diff --git a/src/AngleSharp.Css/Values/Functions/CssConicGradientValue.cs b/src/AngleSharp.Css/Values/Functions/CssConicGradientValue.cs index 3833843..07d48a7 100644 --- a/src/AngleSharp.Css/Values/Functions/CssConicGradientValue.cs +++ b/src/AngleSharp.Css/Values/Functions/CssConicGradientValue.cs @@ -103,9 +103,11 @@ public String CssText } /// - /// Gets the angle of the conic gradient. + /// Gets the angle of the conic gradient. Defaults to 0deg when no "from" clause was + /// authored, per https://drafts.csswg.org/css-images-4/#conic-gradients - unlike + /// CssLinearGradientValue.Angle, whose own "to bottom" default really is 180deg. /// - public ICssValue Angle => _angle ?? Values.CssAngleValue.Half; + public ICssValue Angle => _angle ?? Values.CssAngleValue.Zero; /// /// Gets the position of the conic gradient. @@ -162,8 +164,12 @@ public Boolean Equals(CssConicGradientValue other) ICssValue ICssValue.Compute(ICssComputeContext context) { - var center = _center.Compute(context); - var angle = _angle.Compute(context); + // _center/_angle are null whenever no "at"/"from" clause was authored (the default + // center/0deg case, the most common way conic-gradient is actually written) - calling + // .Compute() on them unconditionally threw a NullReferenceException for that ordinary + // case. + var center = _center?.Compute(context); + var angle = _angle?.Compute(context); var stops = _stops.Select(m => (CssGradientStopValue)((ICssValue)m).Compute(context)).ToArray(); return new CssConicGradientValue(angle, center, stops, _repeating); } diff --git a/src/AngleSharp.Css/Values/Functions/CssLinearGradientValue.cs b/src/AngleSharp.Css/Values/Functions/CssLinearGradientValue.cs index 9e3ce10..3b0478a 100644 --- a/src/AngleSharp.Css/Values/Functions/CssLinearGradientValue.cs +++ b/src/AngleSharp.Css/Values/Functions/CssLinearGradientValue.cs @@ -154,7 +154,10 @@ public Boolean Equals(CssLinearGradientValue other) ICssValue ICssValue.Compute(ICssComputeContext context) { - var angle = _angle.Compute(context); + // _angle is null whenever no direction was authored (the default "to bottom" case, + // the most common way linear-gradient is actually written) - calling .Compute() on it + // unconditionally threw a NullReferenceException for that ordinary case. + var angle = _angle?.Compute(context); var stops = _stops.Select(m => (CssGradientStopValue)((ICssValue)m).Compute(context)).ToArray(); return new CssLinearGradientValue(angle, stops, _repeating); } diff --git a/src/AngleSharp.Css/Values/Functions/CssRadialGradientValue.cs b/src/AngleSharp.Css/Values/Functions/CssRadialGradientValue.cs index e65b7bb..204e04a 100644 --- a/src/AngleSharp.Css/Values/Functions/CssRadialGradientValue.cs +++ b/src/AngleSharp.Css/Values/Functions/CssRadialGradientValue.cs @@ -196,8 +196,12 @@ public Boolean Equals(CssRadialGradientValue other) ICssValue ICssValue.Compute(ICssComputeContext context) { var center = (CssPoint2D)((ICssValue)_center).Compute(context); - var width = _width.Compute(context); - var height = _height.Compute(context); + // _width/_height are null whenever no explicit size/radius was authored (the default + // ellipse/farthest-corner case, the most common way radial-gradient is actually + // written) - calling .Compute() on them unconditionally threw a NullReferenceException + // for that ordinary case. + var width = _width?.Compute(context); + var height = _height?.Compute(context); var stops = _stops.Select(m => (CssGradientStopValue)((ICssValue)m).Compute(context)).ToArray(); return new CssRadialGradientValue(_circle, center, width, height, _sizeMode, stops, _repeating); } diff --git a/src/AngleSharp.Css/Values/Multiples/CssTupleValue.cs b/src/AngleSharp.Css/Values/Multiples/CssTupleValue.cs index 0ed26fd..b57aa82 100644 --- a/src/AngleSharp.Css/Values/Multiples/CssTupleValue.cs +++ b/src/AngleSharp.Css/Values/Multiples/CssTupleValue.cs @@ -98,7 +98,10 @@ IEnumerator IEnumerable.GetEnumerator() => ICssValue ICssValue.Compute(ICssComputeContext context) { - var items = _items.Select(v => (T)v.Compute(context)).ToArray(); + // An item can legitimately be null - e.g. `grid-column: 2 / span 2` (the omitted end + // line) - calling .Compute() on it unconditionally threw a NullReferenceException for + // that ordinary case. + var items = _items.Select(v => v == null ? v : (T)v.Compute(context)).ToArray(); return new CssTupleValue(items, _separator); } diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 60b7046..beb3e0d 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.1.1 + 1.1.2 enable latest true