diff --git a/CHANGELOG.md b/CHANGELOG.md index ba40ff49..886d05bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,37 @@ +# 1.1.4 + +Released on Tuesday, October 20 2026. + +- Improved default stylesheet with HTML5 elements, non-rendered elements, and form control styles +- Updated computed zero lengths to serialize with their unit (e.g., `0px`) +- Updated cascade to always order author rules after default stylesheet rules +- Updated `font-variant` to be a shorthand for `font-variant-caps` +- Fixed rules matching the host being applied to its `::before` / `::after` pseudo-elements +- Fixed computed `font-weight` to resolve `bolder` / `lighter` against the parent's weight +- Fixed computed `border-*-radius` to keep percentages instead of resolving them to pixels +- Fixed computed `background-position` and `mask-position` to keep percentages instead of resolving them against the viewport +- Fixed computed `background-position` and `background-repeat` being empty +- Fixed computed `border-image-slice` serializing missing sides as `NaNpx` +- Fixed computing a unitless `border-image-width` or `border-image-outset` throwing +- Fixed `border-image-outset` dropping unitless numbers +- Fixed `text-align-last` not being inherited +- Added support for `display: flow-root` +- Added support for double-position color stops in gradients (e.g., `red 0 50%`) +- Added support for filter functions in `backdrop-filter` +- Added support for multiple layers in `mask-size`, `mask-repeat`, and `mask-position` +- Added support for percentages in `font-stretch` +- Added `font-variant-caps` property + # 1.1.3 -Released on Thursday, October 15 2026 +Released on Thursday, October 15 2026. - Improved pseudo-class matching to skip a per-element `ConditionalWeakTable` probe when no state has ever been forced via `SetPseudoClass` - Fixed box-shadow serialization dropping zero blur before nonzero spread (#249) @PrzemyslawKlys # 1.1.2 -Released on Friday, September 11 2026 +Released on Friday, September 11 2026. - Fixed issue with calculation of CSS grid style - Fixed default value of CSS gradients @@ -28,7 +52,7 @@ Released on Friday, September 11 2026 # 1.1.1 -Released on Wednesday, September 9 2026 +Released on Wednesday, September 9 2026. - Fixed unresolved converter re-entry (#243) @sebastienros - Fixed issue with computation of `transform` functions @@ -41,7 +65,7 @@ Released on Wednesday, September 9 2026 # 1.1.0 -Released on Saturday, September 5 2026 +Released on Saturday, September 5 2026. - Improved evaluation of comma-separated media queries (#230) - Fixed wrong media feature used for scripting (#233) diff --git a/src/AngleSharp.Css.Docs/package.json b/src/AngleSharp.Css.Docs/package.json index 14593084..d12d0289 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.3", + "version": "1.1.4", "preview": true, "description": "The doclet for the AngleSharp.Css documentation.", "keywords": [ diff --git a/src/AngleSharp.Css.Tests/Declarations/CssTextProperty.cs b/src/AngleSharp.Css.Tests/Declarations/CssTextProperty.cs index 23ddd058..fc26edf2 100644 --- a/src/AngleSharp.Css.Tests/Declarations/CssTextProperty.cs +++ b/src/AngleSharp.Css.Tests/Declarations/CssTextProperty.cs @@ -451,7 +451,7 @@ public void CssTextAlignLastNoneIllegal() var snippet = "text-align-last: none"; var property = ParseDeclaration(snippet); Assert.AreEqual("text-align-last", property.Name); - Assert.IsFalse(property.IsInherited); + Assert.IsTrue(property.IsInherited); Assert.IsFalse(property.IsImportant); Assert.IsFalse(property.HasValue); } diff --git a/src/AngleSharp.Css.Tests/Declarations/SetPropertyOverwriteTests.cs b/src/AngleSharp.Css.Tests/Declarations/SetPropertyOverwriteTests.cs index 9846940e..a3b2b8b4 100644 --- a/src/AngleSharp.Css.Tests/Declarations/SetPropertyOverwriteTests.cs +++ b/src/AngleSharp.Css.Tests/Declarations/SetPropertyOverwriteTests.cs @@ -42,8 +42,8 @@ private static readonly (String Input, String Expected)[] Cases = ("grid-area:a;grid-row:2", "grid-column-end: a; grid-column-start: a; grid-row-end: auto; grid-row-start: 2"), ("grid-row:2;grid-area:a", "grid-column-end: a; grid-column-start: a; grid-row-end: a; grid-row-start: a"), ("transition:all .3s;transition-duration:1s", "transition-delay: initial; transition-duration: 1s; transition-property: all; transition-timing-function: initial"), - ("font:bold 14px/1.2 Arial;font-size:20px", "font-family: Arial; font-size: 20px; font-stretch: ; font-style: ; font-variant: ; font-weight: bold; line-height: 1.2"), - ("font-size:20px;font:bold 14px/1.2 Arial", "font-family: Arial; font-size: 14px; font-stretch: ; font-style: ; font-variant: ; font-weight: bold; line-height: 1.2"), + ("font:bold 14px/1.2 Arial;font-size:20px", "font-family: Arial; font-size: 20px; font-stretch: ; font-style: ; font-variant-caps: ; font-weight: bold; line-height: 1.2"), + ("font-size:20px;font:bold 14px/1.2 Arial", "font-family: Arial; font-size: 14px; font-stretch: ; font-style: ; font-variant-caps: ; font-weight: bold; line-height: 1.2"), ("border:1px solid red;border-width:3px", "border-bottom-color: rgba(255, 0, 0, 1); border-bottom-style: solid; border-bottom-width: 3px; border-left-color: rgba(255, 0, 0, 1); border-left-style: solid; border-left-width: 3px; border-right-color: rgba(255, 0, 0, 1); border-right-style: solid; border-right-width: 3px; border-top-color: rgba(255, 0, 0, 1); border-top-style: solid; border-top-width: 3px"), ("border-width:3px;border:1px solid red", "border-bottom-color: rgba(255, 0, 0, 1); border-bottom-style: solid; border-bottom-width: 1px; border-left-color: rgba(255, 0, 0, 1); border-left-style: solid; border-left-width: 1px; border-right-color: rgba(255, 0, 0, 1); border-right-style: solid; border-right-width: 1px; border-top-color: rgba(255, 0, 0, 1); border-top-style: solid; border-top-width: 1px"), ("background:red;background:blue", "background-attachment: initial; background-clip: initial; background-color: rgba(0, 0, 255, 1); background-image: initial; background-origin: initial; background-position-x: initial; background-position-y: initial; background-repeat-x: initial; background-repeat-y: initial; background-size: initial"), diff --git a/src/AngleSharp.Css.Tests/Extensions/AnalysisWindow.cs b/src/AngleSharp.Css.Tests/Extensions/AnalysisWindow.cs index 9f68a3f5..db71f93b 100644 --- a/src/AngleSharp.Css.Tests/Extensions/AnalysisWindow.cs +++ b/src/AngleSharp.Css.Tests/Extensions/AnalysisWindow.cs @@ -175,7 +175,7 @@ public void GetComputedStyleComplexScenario() Assert.IsNotNull(style); Assert.AreEqual(8, style.Length); - Assert.AreEqual("0", style.GetMargin()); + Assert.AreEqual("0px", style.GetMargin()); Assert.AreEqual("rgba(255, 0, 0, 1)", style.GetColor()); Assert.AreEqual("bold", style.GetFontWeight()); Assert.AreEqual("italic", style.GetFontStyle()); diff --git a/src/AngleSharp.Css.Tests/Styling/BorderRadiusPercentageComputedValueTests.cs b/src/AngleSharp.Css.Tests/Styling/BorderRadiusPercentageComputedValueTests.cs new file mode 100644 index 00000000..00a1aa2f --- /dev/null +++ b/src/AngleSharp.Css.Tests/Styling/BorderRadiusPercentageComputedValueTests.cs @@ -0,0 +1,52 @@ +#nullable disable +namespace AngleSharp.Css.Tests.Styling +{ + using AngleSharp.Dom; + using NUnit.Framework; + using static CssConstructionFunctions; + + /// + /// The computed value of a `border-*-radius` is "a pair of computed <length-percentage> + /// values" (https://www.w3.org/TR/css-backgrounds-3/#border-radius): a percentage stays a + /// percentage, because it refers to the border box, which only layout knows. Resolving it to + /// pixels at style-computation time only works for a box whose width and height are both + /// authored; for an auto-sized box, or a form control whose size is intrinsic, the percentage + /// collapses to `0`. Found in a downstream renderer (AngleSharp.Renderer): a radio button + /// styled `border-radius: 50%` (as every UA stylesheet does) rendered square. Note that + /// `BorderRadiusPercentageResolutionTests` currently expects pixel values for explicitly sized + /// boxes; it needs to change along with this fix. + /// + [TestFixture] + public class BorderRadiusPercentageComputedValueTests + { + private static string ComputedRadius(string html) + { + var document = ParseDocument($"{html}"); + return document.GetElementById("target").ComputeCurrentStyle().GetPropertyValue("border-top-left-radius"); + } + + [Test] + public void PercentageRadiusOfAnAutoSizedBoxStaysAPercentage() + { + Assert.AreEqual("50%", ComputedRadius("
x
")); + } + + [Test] + public void PercentageRadiusOfAnIntrinsicallySizedControlStaysAPercentage() + { + Assert.AreEqual("50%", ComputedRadius("")); + } + + [Test] + public void EllipticalPercentageRadiusKeepsBothComponents() + { + Assert.AreEqual("10% 30%", ComputedRadius("
x
")); + } + + [Test] + public void LengthRadiusIsStillComputedToPixels() + { + Assert.AreEqual("8px", ComputedRadius("
x
")); + } + } +} diff --git a/src/AngleSharp.Css.Tests/Styling/BorderRadiusPercentageResolutionTests.cs b/src/AngleSharp.Css.Tests/Styling/BorderRadiusPercentageResolutionTests.cs index cdeceba8..87f08cef 100644 --- a/src/AngleSharp.Css.Tests/Styling/BorderRadiusPercentageResolutionTests.cs +++ b/src/AngleSharp.Css.Tests/Styling/BorderRadiusPercentageResolutionTests.cs @@ -8,11 +8,9 @@ namespace AngleSharp.Css.Tests.Styling /// /// Per https://www.w3.org/TR/css-backgrounds-3/#corner-overlap, a percentage `border-radius` /// resolves its horizontal component against the border box's own *width* and its vertical - /// component against the border box's own *height* - independently. Confirmed empirically - /// while building `border-radius` support in a downstream renderer (AngleSharp.Renderer) that - /// this does not happen: both components resolve against the containing block's/element's - /// *width* alone, so on a box whose width and height differ, the vertical radius comes out - /// wrong (tracking the wrong axis's dimension entirely, not merely imprecise). + /// component against the border box's own *height* - independently. Since the border box is + /// only known during layout, the computed value keeps both components as percentages (see + /// `BorderRadiusPercentageComputedValueTests`) instead of resolving them prematurely. /// [TestFixture] public class BorderRadiusPercentageResolutionTests @@ -28,36 +26,32 @@ private static IDocument ParseWithRenderDevice(string html, int viewPortWidth = } [Test] - public void VerticalPercentageComponentResolvesAgainstTheElementsOwnHeight() + public void PercentageComponentsOfAnExplicitlySizedBoxStayPercentages() { - // 200x100 box, `border-radius: 10% / 30%` - the horizontal component (10%) should - // resolve against the 200px width (20px); the vertical component (30%) should resolve - // against the 100px height (30px), not against the 200px width (which would give the - // wrong value, 60px). + // 200x100 box, `border-radius: 10% / 30%` - the computed value is a pair of + // values (https://www.w3.org/TR/css-backgrounds-3/#border-radius), + // so both components stay percentages. Resolving them (the horizontal one against the + // 200px width, the vertical one against the 100px height) is up to layout. var document = ParseWithRenderDevice("
"); var target = document.GetElementById("target"); var style = target.ComputeCurrentStyle(); - var horizontal = style.GetPropertyValue("border-top-left-radius"); - Assert.IsTrue(horizontal.Contains("30px"), $"expected the vertical 30% component to resolve to 30px (30% of the 100px height); got '{horizontal}'"); + Assert.AreEqual("10% 30%", style.GetPropertyValue("border-top-left-radius")); } [Test] - public void VerticalPercentageComponentTracksHeightAcrossDifferentElementHeights() + public void VerticalPercentageComponentIsNotResolvedAgainstTheWidth() { - // The same vertical percentage against two different heights (but the same width) - // must resolve to two different pixel values if it is genuinely tracking height - if - // it were (incorrectly) tracking width instead, both would resolve identically despite - // the different heights. + // The vertical component must not be turned into a pixel value derived from the + // box's width; it is kept as authored, independent of the box's dimensions. var shortDocument = ParseWithRenderDevice("
"); - var shortTarget = shortDocument.GetElementById("target"); - var shortRadius = shortTarget.ComputeCurrentStyle().GetPropertyValue("border-top-left-radius"); + var shortRadius = shortDocument.GetElementById("target").ComputeCurrentStyle().GetPropertyValue("border-top-left-radius"); var tallDocument = ParseWithRenderDevice("
"); - var tallTarget = tallDocument.GetElementById("target"); - var tallRadius = tallTarget.ComputeCurrentStyle().GetPropertyValue("border-top-left-radius"); + var tallRadius = tallDocument.GetElementById("target").ComputeCurrentStyle().GetPropertyValue("border-top-left-radius"); - Assert.AreNotEqual(shortRadius, tallRadius, "a 30% vertical radius against a 100px-tall box and a 200px-tall box must resolve to different pixel values."); + Assert.AreEqual("0px 30%", shortRadius); + Assert.AreEqual("0px 30%", tallRadius); } } } diff --git a/src/AngleSharp.Css.Tests/Styling/Configuration.cs b/src/AngleSharp.Css.Tests/Styling/Configuration.cs index 0b815069..2ffdc73b 100644 --- a/src/AngleSharp.Css.Tests/Styling/Configuration.cs +++ b/src/AngleSharp.Css.Tests/Styling/Configuration.cs @@ -38,7 +38,10 @@ public void ObtainDefaultSheet() // 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); + // 60, not 50: the UA stylesheet now also carries the rules for elements that are + // never rendered (`[hidden]`, `input[type=hidden]`) and the default look of form + // controls (text fields, buttons, checkboxes/radios, color and range inputs). + Assert.AreEqual(60, sheet.Rules.Length); } } } diff --git a/src/AngleSharp.Css.Tests/Styling/CssBlendFilterProperties.cs b/src/AngleSharp.Css.Tests/Styling/CssBlendFilterProperties.cs index 1ce0b04d..8e89d30e 100644 --- a/src/AngleSharp.Css.Tests/Styling/CssBlendFilterProperties.cs +++ b/src/AngleSharp.Css.Tests/Styling/CssBlendFilterProperties.cs @@ -229,11 +229,11 @@ public void BackdropFilterNone() } [Test] - public void BackdropFilterInvalid() + public void BackdropFilterAcceptsFilterFunctions() { var element = _document.CreateElement("div") as IHtmlElement; element.GetStyle().SetProperty("backdrop-filter", "blur(5px)"); - Assert.AreEqual("", element.GetStyle().GetPropertyValue("backdrop-filter")); + Assert.AreEqual("blur(5px)", element.GetStyle().GetPropertyValue("backdrop-filter")); } [Test] diff --git a/src/AngleSharp.Css.Tests/Styling/CustomPropertyCycles.cs b/src/AngleSharp.Css.Tests/Styling/CustomPropertyCycles.cs index 0d9a912d..2a0e3e43 100644 --- a/src/AngleSharp.Css.Tests/Styling/CustomPropertyCycles.cs +++ b/src/AngleSharp.Css.Tests/Styling/CustomPropertyCycles.cs @@ -88,7 +88,7 @@ public void ResolvedTokensAreNotConvertedUsingTheCustomPropertyConverter() var style = document.QuerySelector("div").ComputeCurrentStyle(); Assert.AreEqual("0", style.GetPropertyValue("--a")); Assert.AreEqual("0", style.GetPropertyValue("opacity")); - Assert.AreEqual("0", style.GetPropertyValue("width")); + Assert.AreEqual("0px", style.GetPropertyValue("width")); } [TestCase("--a:red;--b:var(--a);--c:var(--a);--d:var(--b) var(--c)", "red red")] @@ -178,7 +178,7 @@ public void OnlyTheWinningDeclarationParticipatesInTheGraph(String text, String 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)", "0px", "0px")] [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")] diff --git a/src/AngleSharp.Css.Tests/Styling/DefaultStyleSheetFormControlTests.cs b/src/AngleSharp.Css.Tests/Styling/DefaultStyleSheetFormControlTests.cs new file mode 100644 index 00000000..6a859b28 --- /dev/null +++ b/src/AngleSharp.Css.Tests/Styling/DefaultStyleSheetFormControlTests.cs @@ -0,0 +1,139 @@ +#nullable disable +namespace AngleSharp.Css.Tests.Styling +{ + using AngleSharp.Css.Dom; + using AngleSharp.Dom; + using NUnit.Framework; + using static CssConstructionFunctions; + + /// + /// The default (UA) stylesheet gives form controls `display: inline-block` but none of the + /// default look every browser's UA stylesheet gives them (border, padding, background, the + /// checkbox/radio margin, the round radio). A downstream renderer (AngleSharp.Renderer) that + /// takes every style from the computed style therefore draws an unstyled `<input>` as + /// an invisible box, unless it injects that look itself based on tag names - which also makes + /// it impossible for an author's own `input { ... }` rules to reliably override it through + /// the cascade. The expected values match the look AngleSharp.Renderer used to inject. + /// + [TestFixture] + public class DefaultStyleSheetFormControlTests + { + private static ICssStyleDeclaration ComputedStyle(string html) + { + var document = ParseDocument($"{html}"); + return document.GetElementById("target").ComputeCurrentStyle(); + } + + private static void AssertBorder(ICssStyleDeclaration style, string width, string lineStyle, string color) + { + foreach (var side in new[] { "top", "right", "bottom", "left" }) + { + Assert.AreEqual(width, style.GetPropertyValue($"border-{side}-width"), side); + Assert.AreEqual(lineStyle, style.GetPropertyValue($"border-{side}-style"), side); + Assert.AreEqual(color, style.GetPropertyValue($"border-{side}-color"), side); + } + } + + private static void AssertPadding(ICssStyleDeclaration style, string vertical, string horizontal) + { + Assert.AreEqual(vertical, style.GetPropertyValue("padding-top")); + Assert.AreEqual(horizontal, style.GetPropertyValue("padding-right")); + Assert.AreEqual(vertical, style.GetPropertyValue("padding-bottom")); + Assert.AreEqual(horizontal, style.GetPropertyValue("padding-left")); + } + + [TestCase("")] + [TestCase("")] + [TestCase("")] + [TestCase("")] + [TestCase("")] + [TestCase("")] + public void TextFieldsHaveABorderPaddingAndAWhiteBackground(string html) + { + var style = ComputedStyle(html); + + AssertBorder(style, "1px", "solid", "rgba(118, 118, 118, 1)"); + AssertPadding(style, "2px", "4px"); + Assert.AreEqual("rgba(255, 255, 255, 1)", style.GetPropertyValue("background-color")); + } + + [Test] + public void SelectLooksLikeAButtonWithTextPadding() + { + var style = ComputedStyle(""); + + AssertBorder(style, "1px", "solid", "rgba(118, 118, 118, 1)"); + AssertPadding(style, "2px", "4px"); + Assert.AreEqual("rgba(232, 232, 232, 1)", style.GetPropertyValue("background-color")); + } + + [TestCase("")] + [TestCase("")] + [TestCase("")] + [TestCase("")] + [TestCase("")] + public void ButtonsHaveABorderWidePaddingAndAGrayBackground(string html) + { + var style = ComputedStyle(html); + + AssertBorder(style, "1px", "solid", "rgba(118, 118, 118, 1)"); + AssertPadding(style, "2px", "10px"); + Assert.AreEqual("rgba(232, 232, 232, 1)", style.GetPropertyValue("background-color")); + } + + [Test] + public void ColorInputHasANarrowPadding() + { + var style = ComputedStyle(""); + + AssertBorder(style, "1px", "solid", "rgba(118, 118, 118, 1)"); + AssertPadding(style, "2px", "2px"); + Assert.AreEqual("rgba(255, 255, 255, 1)", style.GetPropertyValue("background-color")); + } + + [TestCase("checkbox")] + [TestCase("radio")] + public void CheckboxesAndRadiosHaveABorderAndTheirOwnMargin(string type) + { + var style = ComputedStyle($""); + + AssertBorder(style, "1px", "solid", "rgba(118, 118, 118, 1)"); + AssertPadding(style, "0px", "0px"); + Assert.AreEqual("rgba(255, 255, 255, 1)", style.GetPropertyValue("background-color")); + Assert.AreEqual("3px", style.GetPropertyValue("margin-top")); + Assert.AreEqual("3px", style.GetPropertyValue("margin-right")); + Assert.AreEqual("3px", style.GetPropertyValue("margin-bottom")); + Assert.AreEqual("4px", style.GetPropertyValue("margin-left")); + } + + [Test] + public void RadioIsRound() + { + var style = ComputedStyle(""); + var radius = style.GetPropertyValue("border-top-left-radius"); + + Assert.IsNotEmpty(radius); + Assert.AreNotEqual("0px", radius); + } + + [Test] + public void RangeHasNoBoxOfItsOwn() + { + var style = ComputedStyle(""); + + Assert.AreEqual("none", style.GetPropertyValue("border-top-style")); + Assert.AreEqual("rgba(0, 0, 0, 0)", style.GetPropertyValue("background-color")); + } + + [Test] + public void AuthorRulesOverrideTheFormControlLook() + { + var document = ParseDocument(""); + var style = document.GetElementById("target").ComputeCurrentStyle(); + + AssertBorder(style, "3px", "dashed", "rgba(255, 0, 0, 1)"); + AssertPadding(style, "1px", "1px"); + Assert.AreEqual("rgba(0, 0, 0, 1)", style.GetPropertyValue("background-color")); + } + } +} diff --git a/src/AngleSharp.Css.Tests/Styling/DefaultStyleSheetHtmlElementsTests.cs b/src/AngleSharp.Css.Tests/Styling/DefaultStyleSheetHtmlElementsTests.cs new file mode 100644 index 00000000..08c739df --- /dev/null +++ b/src/AngleSharp.Css.Tests/Styling/DefaultStyleSheetHtmlElementsTests.cs @@ -0,0 +1,60 @@ +#nullable disable +namespace AngleSharp.Css.Tests.Styling +{ + using AngleSharp.Dom; + using NUnit.Framework; + using static CssConstructionFunctions; + + /// + /// The default (UA) stylesheet is the CSS 2.2 sample stylesheet, which predates HTML5: it has + /// no rules for the sectioning and grouping elements, the elements that are never rendered, + /// or form-control text handling that the HTML Standard's rendering section + /// (https://html.spec.whatwg.org/multipage/rendering.html) requires. A downstream renderer + /// (AngleSharp.Renderer) that takes every style from the computed style therefore lays out a + /// `<section>` or `<footer>` as `inline` (the initial value of `display`) and + /// would render the text of a `<script>` in the body, unless it guesses from tag names. + /// + [TestFixture] + public class DefaultStyleSheetHtmlElementsTests + { + private static string ComputedValue(string html, string property) + { + var document = ParseDocument(html); + return document.GetElementById("target").ComputeCurrentStyle().GetPropertyValue(property); + } + + [TestCase("article")] + [TestCase("aside")] + [TestCase("details")] + [TestCase("figcaption")] + [TestCase("figure")] + [TestCase("footer")] + [TestCase("header")] + [TestCase("hgroup")] + [TestCase("main")] + [TestCase("nav")] + [TestCase("search")] + [TestCase("section")] + [TestCase("legend")] + public void Html5BlockElementsAreDisplayBlock(string tagName) + { + Assert.AreEqual("block", ComputedValue($"<{tagName} id=target>x", "display")); + } + + [TestCase("")] + [TestCase("")] + [TestCase("")] + [TestCase("")] + [TestCase("")] + public void NonRenderedElementsAreDisplayNone(string html) + { + Assert.AreEqual("none", ComputedValue(html, "display")); + } + + [Test] + public void TextareaPreservesWhiteSpace() + { + Assert.AreEqual("pre-wrap", ComputedValue("", "white-space")); + } + } +} diff --git a/src/AngleSharp.Css.Tests/Styling/DisplayFlowRootTests.cs b/src/AngleSharp.Css.Tests/Styling/DisplayFlowRootTests.cs new file mode 100644 index 00000000..5266b98a --- /dev/null +++ b/src/AngleSharp.Css.Tests/Styling/DisplayFlowRootTests.cs @@ -0,0 +1,37 @@ +#nullable disable +namespace AngleSharp.Css.Tests.Styling +{ + using AngleSharp.Css.Dom; + using AngleSharp.Dom; + using NUnit.Framework; + using static CssConstructionFunctions; + + /// + /// `display: flow-root` (CSS Display 3) is a block box that establishes a new block formatting + /// context - the standard way to make a container enclose its floats. The value is not in the + /// list of recognized `display` keywords, so the declaration is dropped and the element keeps + /// whatever `display` the rest of the cascade gives it (`block` for a `div`). Found in a downstream renderer (AngleSharp.Renderer) while implementing + /// floats: a `flow-root` container did not grow to contain its floated children. + /// + [TestFixture] + public class DisplayFlowRootTests + { + [Test] + public void FlowRootIsAValidDisplayValue() + { + var document = ParseDocument("
"); + var target = document.GetElementById("target"); + + Assert.AreEqual("flow-root", target.ComputeCurrentStyle().GetPropertyValue("display")); + } + + [Test] + public void FlowRootIsKeptOnTheDeclaration() + { + var document = ParseDocument("
"); + var target = document.GetElementById("target"); + + Assert.AreEqual("flow-root", target.GetStyle().GetPropertyValue("display")); + } + } +} diff --git a/src/AngleSharp.Css.Tests/Styling/FontWeightComputedStyleTests.cs b/src/AngleSharp.Css.Tests/Styling/FontWeightComputedStyleTests.cs new file mode 100644 index 00000000..8ff1f1d5 --- /dev/null +++ b/src/AngleSharp.Css.Tests/Styling/FontWeightComputedStyleTests.cs @@ -0,0 +1,81 @@ +#nullable disable +namespace AngleSharp.Css.Tests.Styling +{ + using AngleSharp.Dom; + using NUnit.Framework; + using static CssConstructionFunctions; + + /// + /// The computed value of `font-weight` is a number: the relative keywords `bolder` and + /// `lighter` resolve against the parent's computed weight (CSS Fonts 4, section 2.2.1), and + /// descendants inherit that number, not the keyword. Found while rendering `<b>` and + /// `<strong>` (whose UA rule is `font-weight: bolder`) in a downstream renderer + /// (AngleSharp.Renderer): the computed style reports the literal keyword `bolder` for the + /// element itself and for every descendant, so a consumer cannot know the actual weight + /// without re-implementing the cascade, and a nested element applies `bolder` once more. + /// + [TestFixture] + public class FontWeightComputedStyleTests + { + private static string ComputedFontWeight(string html, string id) + { + var document = ParseDocument(html); + return document.GetElementById(id).ComputeCurrentStyle().GetPropertyValue("font-weight"); + } + + [Test] + public void UaBolderOnBoldElementResolvesAgainstNormalParent() + { + Assert.AreEqual("700", ComputedFontWeight("

Bold

", "target")); + } + + [Test] + public void UaBolderOnStrongElementResolvesAgainstNormalParent() + { + Assert.AreEqual("700", ComputedFontWeight("

Bold

", "target")); + } + + [Test] + public void ChildOfBoldElementInheritsTheResolvedWeight() + { + Assert.AreEqual("700", ComputedFontWeight("

Bold bold italic

", "target")); + } + + [Test] + public void BolderResolvesAgainstABoldParent() + { + Assert.AreEqual("900", ComputedFontWeight("

x

", "target")); + } + + [Test] + public void BolderResolvesAgainstALightParent() + { + Assert.AreEqual("400", ComputedFontWeight("

x

", "target")); + } + + [Test] + public void LighterResolvesAgainstABoldParent() + { + Assert.AreEqual("400", ComputedFontWeight("

x

", "target")); + } + + [Test] + public void LighterResolvesAgainstANormalParent() + { + Assert.AreEqual("100", ComputedFontWeight("

x

", "target")); + } + + [Test] + public void NestedBoldElementsResolveAgainstEachOtherOnlyOnce() + { + // in : the inner one resolves `bolder` against its parent's 700, giving 900. + Assert.AreEqual("900", ComputedFontWeight("

outer inner

", "target")); + } + + [Test] + public void HeadingUaBolderResolvesToBold() + { + Assert.AreEqual("700", ComputedFontWeight("

Title

", "target")); + } + } +} diff --git a/src/AngleSharp.Css.Tests/Styling/HtmlCssIntegration.cs b/src/AngleSharp.Css.Tests/Styling/HtmlCssIntegration.cs index d95101a4..f5e2810d 100644 --- a/src/AngleSharp.Css.Tests/Styling/HtmlCssIntegration.cs +++ b/src/AngleSharp.Css.Tests/Styling/HtmlCssIntegration.cs @@ -467,7 +467,8 @@ public void DefaultStyleSheetTest_Issue21() var htmlParser = browsingContext.GetService(); var document = htmlParser.ParseDocument("Hello, World!"); var boldStyle = document.Body.FirstElementChild.ComputeCurrentStyle(); - Assert.AreEqual("bolder", boldStyle.GetFontWeight()); + // The computed value of `bolder` is resolved against the parent's weight (normal). + Assert.AreEqual("700", boldStyle.GetFontWeight()); } [Test] diff --git a/src/AngleSharp.Css.Tests/Styling/PaintPropertiesComputedStyleTests.cs b/src/AngleSharp.Css.Tests/Styling/PaintPropertiesComputedStyleTests.cs new file mode 100644 index 00000000..05798278 --- /dev/null +++ b/src/AngleSharp.Css.Tests/Styling/PaintPropertiesComputedStyleTests.cs @@ -0,0 +1,96 @@ +#nullable disable +namespace AngleSharp.Css.Tests.Styling +{ + using AngleSharp.Css.Dom; + using AngleSharp.Dom; + using AngleSharp.Html.Parser; + using NUnit.Framework; + + /// + /// Computed values of paint-related properties that a downstream renderer + /// (AngleSharp.Renderer) needs while implementing `border-image`, CSS masks, `backdrop-filter` + /// and multiple background layers: + /// - `border-image-slice` serializes a single number as `30 NaNpx NaNpx NaNpx`. + /// - A unitless `border-image-width`/`border-image-outset` (a multiple of the border width) + /// throws while computing, which aborts the whole render tree. + /// - `backdrop-filter`, `mask-size` and `mask-repeat` are dropped at parse time. + /// - `mask-position` is resolved to pixels against the viewport; like `background-position`, + /// its percentages refer to the element's own box and must stay percentages. + /// - `background-repeat` and `background-position` with more than one layer are parsed but + /// computed as empty, unlike `background-size`, `background-clip` or `background-origin`. + /// + [TestFixture] + public class PaintPropertiesComputedStyleTests + { + private static ICssStyleDeclaration ComputedStyle(string style) + { + var config = Configuration.Default + .WithCss() + .WithRenderDevice(new DefaultRenderDevice { ViewPortWidth = 400, ViewPortHeight = 300, FontSize = 16 }); + var parser = BrowsingContext.New(config).GetService(); + var document = parser.ParseDocument($"
x
"); + return document.GetElementById("target").ComputeCurrentStyle(); + } + + [TestCase("30", "30")] + [TestCase("30%", "30%")] + [TestCase("10 20 30 40", "10 20 30 40")] + [TestCase("25% fill", "25% fill")] + public void BorderImageSliceKeepsItsValues(string slice, string expected) + { + Assert.AreEqual(expected, ComputedStyle($"border-image-slice: {slice}").GetPropertyValue("border-image-slice")); + } + + [TestCase("border-image-width: 2", "border-image-width", "2")] + [TestCase("border-image-width: 1 8px", "border-image-width", "1 8px")] + [TestCase("border-image-outset: 1.5", "border-image-outset", "1.5")] + [TestCase("border-image: linear-gradient(red, blue) 25% / 1 / 4px round", "border-image-width", "1")] + public void BorderImageNumbersAreMultiplesOfTheBorderWidth(string style, string property, string expected) + { + // A unitless number is not a length: computing it currently throws "Unsupported unit + // cannot be converted", which aborts the whole render tree. + Assert.AreEqual(expected, ComputedStyle(style).GetPropertyValue(property)); + } + + [Test] + public void BackdropFilterIsComputed() + { + Assert.AreEqual("blur(4px) grayscale(50%)", ComputedStyle("backdrop-filter: blur(4px) grayscale(50%)").GetPropertyValue("backdrop-filter")); + } + + [Test] + public void MaskSizeIsComputed() + { + Assert.AreEqual("20px 30px, cover", ComputedStyle("mask-image: none, none; mask-size: 20px 30px, cover").GetPropertyValue("mask-size")); + } + + [Test] + public void MaskRepeatIsComputed() + { + Assert.AreEqual("no-repeat, space", ComputedStyle("mask-image: none, none; mask-repeat: no-repeat, space").GetPropertyValue("mask-repeat")); + } + + [Test] + public void MaskPositionPercentagesAreNotResolvedAgainstTheViewport() + { + var position = ComputedStyle("mask-image: none; mask-position: center").GetPropertyValue("mask-position"); + + Assert.That(position, Does.Not.Contain("px")); + } + + [Test] + public void BackgroundRepeatKeepsEveryLayer() + { + Assert.AreEqual("space round, no-repeat", ComputedStyle("background-image: none, none; background-repeat: space round, no-repeat").GetPropertyValue("background-repeat")); + } + + [Test] + public void BackgroundPositionKeepsEveryLayer() + { + var position = ComputedStyle("background-image: none, none; background-position: left top, 30% 70%").GetPropertyValue("background-position"); + + Assert.AreEqual(2, position.Split(',').Length, position); + Assert.That(position, Does.Contain("30% 70%")); + } + } +} diff --git a/src/AngleSharp.Css.Tests/Styling/PseudoElementComputedStyleTests.cs b/src/AngleSharp.Css.Tests/Styling/PseudoElementComputedStyleTests.cs new file mode 100644 index 00000000..ddda6319 --- /dev/null +++ b/src/AngleSharp.Css.Tests/Styling/PseudoElementComputedStyleTests.cs @@ -0,0 +1,61 @@ +#nullable disable +namespace AngleSharp.Css.Tests.Styling +{ + using AngleSharp.Css.Dom; + using AngleSharp.Dom; + using NUnit.Framework; + using static CssConstructionFunctions; + + /// + /// A `::before`/`::after` pseudo-element only gets the declarations of rules whose selector + /// targets that pseudo-element, plus whatever it inherits from its host. A rule that matches + /// the host itself (`div#e { border: ... }`, or the default stylesheet's `input { ... }`) must + /// not apply to the host's pseudo-elements. Found in a downstream renderer + /// (AngleSharp.Renderer): a `::before` on an `<input>` got the input's own border, + /// padding and background, so the control's look was painted twice. + /// + [TestFixture] + public class PseudoElementComputedStyleTests + { + private static ICssStyleDeclaration PseudoStyle(string css, string pseudo) + { + var document = ParseDocument($"
text
"); + var window = document.DefaultView; + return window.GetComputedStyle(document.GetElementById("host"), pseudo); + } + + [Test] + public void HostRuleDoesNotApplyNonInheritedPropertiesToBefore() + { + var style = PseudoStyle("div#host { border: 2px solid red; padding: 5px; background-color: blue } #host::before { content: 'x' }", "::before"); + + Assert.AreNotEqual("2px", style.GetPropertyValue("border-top-width")); + Assert.AreNotEqual("5px", style.GetPropertyValue("padding-left")); + Assert.AreNotEqual("rgba(0, 0, 255, 1)", style.GetPropertyValue("background-color")); + } + + [Test] + public void HostRuleDoesNotApplyNonInheritedPropertiesToAfter() + { + var style = PseudoStyle("div { margin-left: 30px } div::after { content: 'x' }", "::after"); + + Assert.AreNotEqual("30px", style.GetPropertyValue("margin-left")); + } + + [Test] + public void InheritedPropertiesOfTheHostStillReachThePseudoElement() + { + var style = PseudoStyle("div#host { color: red } #host::before { content: 'x' }", "::before"); + + Assert.AreEqual("rgba(255, 0, 0, 1)", style.GetPropertyValue("color")); + } + + [Test] + public void RulesTargetingThePseudoElementStillApply() + { + var style = PseudoStyle("#host::before { content: 'x'; padding-left: 7px }", "::before"); + + Assert.AreEqual("7px", style.GetPropertyValue("padding-left")); + } + } +} diff --git a/src/AngleSharp.Css.Tests/Styling/TextPropertiesComputedStyleTests.cs b/src/AngleSharp.Css.Tests/Styling/TextPropertiesComputedStyleTests.cs new file mode 100644 index 00000000..8b6936b2 --- /dev/null +++ b/src/AngleSharp.Css.Tests/Styling/TextPropertiesComputedStyleTests.cs @@ -0,0 +1,64 @@ +#nullable disable +namespace AngleSharp.Css.Tests.Styling +{ + using AngleSharp.Css.Dom; + using AngleSharp.Dom; + using AngleSharp.Html.Parser; + using NUnit.Framework; + + /// + /// Computed values of text properties a downstream renderer (AngleSharp.Renderer) reads while + /// implementing small caps, font stretch and justification: + /// - `font-variant-caps` is not a known property, so `all-small-caps` is dropped and the + /// `font-variant` shorthand does not set it. + /// - `font-stretch` drops percentage values (`75%`). + /// - `text-align-last` is not inherited, unlike what CSS Text 3 specifies. + /// + [TestFixture] + public class TextPropertiesComputedStyleTests + { + private static ICssStyleDeclaration ComputedStyle(string html, string id = "target") + { + var config = Configuration.Default + .WithCss() + .WithRenderDevice(new DefaultRenderDevice { ViewPortWidth = 400, ViewPortHeight = 300, FontSize = 16 }); + var parser = BrowsingContext.New(config).GetService(); + var document = parser.ParseDocument(html); + return document.GetElementById(id).ComputeCurrentStyle(); + } + + [TestCase("small-caps")] + [TestCase("all-small-caps")] + [TestCase("petite-caps")] + [TestCase("unicase")] + public void FontVariantCapsIsComputed(string value) + { + Assert.AreEqual(value, ComputedStyle($"

x

").GetPropertyValue("font-variant-caps")); + } + + [Test] + public void FontVariantShorthandSetsFontVariantCaps() + { + Assert.AreEqual("small-caps", ComputedStyle("

x

").GetPropertyValue("font-variant-caps")); + } + + [Test] + public void FontVariantCapsIsInherited() + { + Assert.AreEqual("small-caps", ComputedStyle("

x

").GetPropertyValue("font-variant-caps")); + } + + [TestCase("75%", "75%")] + [TestCase("condensed", "condensed")] + public void FontStretchAcceptsPercentagesAndKeywords(string value, string expected) + { + Assert.AreEqual(expected, ComputedStyle($"

x

").GetPropertyValue("font-stretch")); + } + + [Test] + public void TextAlignLastIsInherited() + { + Assert.AreEqual("center", ComputedStyle("

x

").GetPropertyValue("text-align-last")); + } + } +} diff --git a/src/AngleSharp.Css.Tests/Values/GradientDoublePositionStops.cs b/src/AngleSharp.Css.Tests/Values/GradientDoublePositionStops.cs new file mode 100644 index 00000000..3de61918 --- /dev/null +++ b/src/AngleSharp.Css.Tests/Values/GradientDoublePositionStops.cs @@ -0,0 +1,42 @@ +#nullable disable +namespace AngleSharp.Css.Tests.Values +{ + using AngleSharp.Css.Converters; + using AngleSharp.Css.Values; + using NUnit.Framework; + using System.Linq; + using static CssConstructionFunctions; + using static ValueConverters; + + /// + /// CSS Images 4 allows a color stop to carry two positions (`red 0 50%`), shorthand for the + /// same color at both positions - the usual way to write hard-edged stripes. The gradient + /// parser does not accept the second position, so the whole gradient fails to parse and, + /// as a `background-image` or `border-image-source`, silently paints nothing. Found in a + /// downstream renderer (AngleSharp.Renderer) while implementing `border-image`. + /// + [TestFixture] + public class GradientDoublePositionStopsTests + { + [TestCase("linear-gradient(red 0 50%, blue 50% 100%)")] + [TestCase("repeating-linear-gradient(45deg, red 0 6px, yellow 6px 12px)")] + [TestCase("radial-gradient(red 0 20%, blue 20%)")] + [TestCase("conic-gradient(red 0deg 90deg, blue 90deg 180deg)")] + public void DoublePositionColorStopsParse(string source) + { + Assert.IsNotNull(GradientConverter.Convert(source), source); + } + + [Test] + public void DoublePositionColorStopExpandsIntoTwoStops() + { + var gradient = GradientConverter.Convert("linear-gradient(red 0 50%, blue 50% 100%)") as CssLinearGradientValue; + + Assert.IsNotNull(gradient); + var stops = gradient.Stops.OfType().ToArray(); + Assert.AreEqual(4, stops.Length); + Assert.AreEqual(stops[0].Color, stops[1].Color); + Assert.AreEqual(stops[2].Color, stops[3].Color); + } + } +} diff --git a/src/AngleSharp.Css/Constants/CssKeywords.cs b/src/AngleSharp.Css/Constants/CssKeywords.cs index 790cdbc6..aa31d40b 100644 --- a/src/AngleSharp.Css/Constants/CssKeywords.cs +++ b/src/AngleSharp.Css/Constants/CssKeywords.cs @@ -522,6 +522,11 @@ public static class CssKeywords /// public static readonly String InlineBlock = "inline-block"; + /// + /// The flow-root keyword. + /// + public static readonly String FlowRoot = "flow-root"; + /// /// The list-item keyword. /// @@ -1452,6 +1457,31 @@ public static class CssKeywords /// public static readonly String SmallCaps = "small-caps"; + /// + /// The all-small-caps keyword. + /// + public static readonly String AllSmallCaps = "all-small-caps"; + + /// + /// The petite-caps keyword. + /// + public static readonly String PetiteCaps = "petite-caps"; + + /// + /// The all-petite-caps keyword. + /// + public static readonly String AllPetiteCaps = "all-petite-caps"; + + /// + /// The unicase keyword. + /// + public static readonly String Unicase = "unicase"; + + /// + /// The titling-caps keyword. + /// + public static readonly String TitlingCaps = "titling-caps"; + /// /// The weight keyword (font-synthesis). /// diff --git a/src/AngleSharp.Css/Constants/Map.cs b/src/AngleSharp.Css/Constants/Map.cs index 3051f8c8..06af8866 100644 --- a/src/AngleSharp.Css/Constants/Map.cs +++ b/src/AngleSharp.Css/Constants/Map.cs @@ -606,6 +606,7 @@ static class Map { CssKeywords.InlineFlex, DisplayMode.InlineFlex }, { CssKeywords.Grid, DisplayMode.Grid }, { CssKeywords.InlineGrid, DisplayMode.InlineGrid }, + { CssKeywords.FlowRoot, DisplayMode.FlowRoot }, }; /// diff --git a/src/AngleSharp.Css/Constants/PropertyNames.cs b/src/AngleSharp.Css/Constants/PropertyNames.cs index 340bcf27..241ceabb 100644 --- a/src/AngleSharp.Css/Constants/PropertyNames.cs +++ b/src/AngleSharp.Css/Constants/PropertyNames.cs @@ -887,6 +887,11 @@ public static class PropertyNames /// public static readonly String FontVariant = "font-variant"; + /// + /// The font-variant-caps declaration. + /// + public static readonly String FontVariantCaps = "font-variant-caps"; + /// /// The font-weight declaration. /// diff --git a/src/AngleSharp.Css/Converters/BoxRelativeValueConverter.cs b/src/AngleSharp.Css/Converters/BoxRelativeValueConverter.cs new file mode 100644 index 00000000..f135b5f0 --- /dev/null +++ b/src/AngleSharp.Css/Converters/BoxRelativeValueConverter.cs @@ -0,0 +1,23 @@ +#nullable disable +namespace AngleSharp.Css.Converters +{ + using AngleSharp.Css.Dom; + using AngleSharp.Text; + + /// + /// Marks the values of a property as relative to the element's own box, + /// e.g., the percentages of a background or mask position. Such percentages + /// are only resolved during layout, i.e., they stay percentages when computed. + /// + sealed class BoxRelativeValueConverter : IValueConverter + { + private readonly IValueConverter _converter; + + public BoxRelativeValueConverter(IValueConverter converter) + { + _converter = converter; + } + + public ICssValue Convert(StringSource source) => _converter.Convert(source); + } +} diff --git a/src/AngleSharp.Css/Converters/FontWeightValueConverter.cs b/src/AngleSharp.Css/Converters/FontWeightValueConverter.cs new file mode 100644 index 00000000..1b0bf78c --- /dev/null +++ b/src/AngleSharp.Css/Converters/FontWeightValueConverter.cs @@ -0,0 +1,24 @@ +#nullable enable +namespace AngleSharp.Css.Converters +{ + using AngleSharp.Css.Dom; + using AngleSharp.Css.Values; + using AngleSharp.Text; + + sealed class FontWeightValueConverter : IValueConverter + { + private readonly IValueConverter _keywords = Map.FontWeights.ToConverter(); + + public ICssValue? Convert(StringSource source) + { + var value = _keywords.Convert(source); + + if (value is CssConstantValue keyword && (keyword.Value == FontWeight.Bolder || keyword.Value == FontWeight.Lighter)) + { + return new CssRelativeFontWeightValue(keyword); + } + + return value; + } + } +} diff --git a/src/AngleSharp.Css/CssDefaultStyleSheetProvider.cs b/src/AngleSharp.Css/CssDefaultStyleSheetProvider.cs index 621fe972..84a69717 100644 --- a/src/AngleSharp.Css/CssDefaultStyleSheetProvider.cs +++ b/src/AngleSharp.Css/CssDefaultStyleSheetProvider.cs @@ -39,7 +39,9 @@ private static ICssStyleSheet Parse(String source) /// /// Gets the source code for the by default used base stylesheet. - /// Taken from https://www.w3.org/TR/CSS22/sample.html. + /// Taken from https://www.w3.org/TR/CSS22/sample.html, extended with + /// the rules for HTML5 elements and form controls from + /// https://html.spec.whatwg.org/multipage/rendering.html. /// public static readonly String DefaultSource = @" html, address, @@ -50,9 +52,22 @@ private static ICssStyleSheet Parse(String source) h1, h2, h3, h4, h5, h6, noframes, ol, p, ul, center, -dir, hr, menu, pre { display: block; unicode-bidi: embed } +dir, hr, menu, pre, +article, aside, +details, figcaption, +figure, footer, +header, hgroup, +legend, main, nav, +search, section, +summary { display: block; unicode-bidi: embed } li { display: list-item } -head { display: none } +head, area, base, +basefont, datalist, +link, meta, noembed, +param, rp, script, +style, template, +title { display: none } +[hidden] { display: none } table { display: table } tr { display: table-row } thead { display: table-header-group } @@ -86,6 +101,25 @@ private static ICssStyleSheet Parse(String source) pre { white-space: pre } button, textarea, input, select { display: inline-block } +input, textarea, +select, button { border: 1px solid rgb(118, 118, 118); padding: 2px 4px; background-color: rgb(255, 255, 255) } +select, button, +input[type=button], +input[type=submit], +input[type=reset], +input[type=file] { background-color: rgb(232, 232, 232) } +button, +input[type=button], +input[type=submit], +input[type=reset], +input[type=file] { padding: 2px 10px } +input[type=color] { padding: 2px } +input[type=checkbox], +input[type=radio] { padding: 0px; margin: 3px 3px 3px 4px } +input[type=radio] { border-radius: 50% } +input[type=range] { border: none; padding: 0px; background-color: transparent } +input[type=hidden] { display: none } +textarea { white-space: pre-wrap } big { font-size: 1.17em } small, sub, sup { font-size: .83em } sub { vertical-align: sub } diff --git a/src/AngleSharp.Css/DeclarationInfo.cs b/src/AngleSharp.Css/DeclarationInfo.cs index 840f4411..832f9b68 100644 --- a/src/AngleSharp.Css/DeclarationInfo.cs +++ b/src/AngleSharp.Css/DeclarationInfo.cs @@ -1,6 +1,7 @@ #nullable enable namespace AngleSharp.Css { + using AngleSharp.Css.Converters; using AngleSharp.Css.Dom; using System; using static ValueConverters; @@ -23,6 +24,13 @@ public DeclarationInfo(String name, IValueConverter converter, PropertyFlags fla { Name = name; Converter = initialValue is not null || longhands?.Length > 0 ? Or(AssignInitial(initialValue), converter) : converter; + + if (converter is BoxRelativeValueConverter && Converter != converter) + { + // The marker needs to stay visible to the computation of the values. + Converter = Converter.BoxRelative(); + } + Aggregator = converter as IValueAggregator; Flags = flags; InitialValue = initialValue; diff --git a/src/AngleSharp.Css/Declarations/BackgroundPositionXDeclaration.cs b/src/AngleSharp.Css/Declarations/BackgroundPositionXDeclaration.cs index 3c68ba0e..26f33716 100644 --- a/src/AngleSharp.Css/Declarations/BackgroundPositionXDeclaration.cs +++ b/src/AngleSharp.Css/Declarations/BackgroundPositionXDeclaration.cs @@ -14,7 +14,7 @@ static class BackgroundPositionXDeclaration PropertyNames.BackgroundPosition, }; - public static IValueConverter Converter = PointXConverter.FromList(); + public static IValueConverter Converter = PointXConverter.FromList().BoxRelative(); public static ICssValue InitialValue = InitialValues.BackgroundPositionXDecl; diff --git a/src/AngleSharp.Css/Declarations/BackgroundPositionYDeclaration.cs b/src/AngleSharp.Css/Declarations/BackgroundPositionYDeclaration.cs index 1c6aa99d..91863da9 100644 --- a/src/AngleSharp.Css/Declarations/BackgroundPositionYDeclaration.cs +++ b/src/AngleSharp.Css/Declarations/BackgroundPositionYDeclaration.cs @@ -14,7 +14,7 @@ static class BackgroundPositionYDeclaration PropertyNames.BackgroundPosition, }; - public static IValueConverter Converter = PointYConverter.FromList(); + public static IValueConverter Converter = PointYConverter.FromList().BoxRelative(); public static ICssValue InitialValue = InitialValues.BackgroundPositionYDecl; diff --git a/src/AngleSharp.Css/Declarations/BorderImageDeclaration.cs b/src/AngleSharp.Css/Declarations/BorderImageDeclaration.cs index 636076f0..bb893228 100644 --- a/src/AngleSharp.Css/Declarations/BorderImageDeclaration.cs +++ b/src/AngleSharp.Css/Declarations/BorderImageDeclaration.cs @@ -63,7 +63,7 @@ public ICssValue Convert(StringSource source) if (widths != null && c == Symbols.Solidus) { source.SkipCurrentAndSpaces(); - outsets = source.ParsePeriodic(UnitParser.ParseDistanceOrCalc); + outsets = source.ParsePeriodic(m => m.ParseDistanceOrCalc() ?? m.ParseNumber()); if (outsets == null) { diff --git a/src/AngleSharp.Css/Declarations/BorderImageOutsetDeclaration.cs b/src/AngleSharp.Css/Declarations/BorderImageOutsetDeclaration.cs index 18cf281a..4ed6202d 100644 --- a/src/AngleSharp.Css/Declarations/BorderImageOutsetDeclaration.cs +++ b/src/AngleSharp.Css/Declarations/BorderImageOutsetDeclaration.cs @@ -14,7 +14,7 @@ static class BorderImageOutsetDeclaration PropertyNames.BorderImage, }; - public static IValueConverter Converter = LengthOrPercentConverter.Periodic(); + public static IValueConverter Converter = Or(LengthOrPercentConverter, NumberConverter).Periodic(); public static ICssValue InitialValue = InitialValues.BorderImageOutsetDecl; diff --git a/src/AngleSharp.Css/Declarations/BorderImageWidthDeclaration.cs b/src/AngleSharp.Css/Declarations/BorderImageWidthDeclaration.cs index 6721f784..f5b5a9bf 100644 --- a/src/AngleSharp.Css/Declarations/BorderImageWidthDeclaration.cs +++ b/src/AngleSharp.Css/Declarations/BorderImageWidthDeclaration.cs @@ -1,5 +1,6 @@ namespace AngleSharp.Css.Declarations { + using AngleSharp.Css.Converters; using AngleSharp.Css.Dom; using System; using static ValueConverters; @@ -13,7 +14,7 @@ static class BorderImageWidthDeclaration PropertyNames.BorderImage, }; - public static IValueConverter Converter = BorderImageWidthConverter; + public static IValueConverter Converter = BorderImageWidthConverter.BoxRelative(); public static ICssValue InitialValue = InitialValues.BorderImageWidthDecl; diff --git a/src/AngleSharp.Css/Declarations/FontVariantCapsDeclaration.cs b/src/AngleSharp.Css/Declarations/FontVariantCapsDeclaration.cs new file mode 100644 index 00000000..ea4217ec --- /dev/null +++ b/src/AngleSharp.Css/Declarations/FontVariantCapsDeclaration.cs @@ -0,0 +1,23 @@ +namespace AngleSharp.Css.Declarations +{ + using AngleSharp.Css.Dom; + using System; + using static ValueConverters; + + static class FontVariantCapsDeclaration + { + public static String Name = PropertyNames.FontVariantCaps; + + public static String[] Shorthands = new[] + { + PropertyNames.FontVariant, + PropertyNames.Font, + }; + + public static IValueConverter Converter = FontVariantCapsConverter; + + public static ICssValue InitialValue = InitialValues.FontVariantDecl; + + public static PropertyFlags Flags = PropertyFlags.Inherited; + } +} diff --git a/src/AngleSharp.Css/Declarations/FontVariantDeclaration.cs b/src/AngleSharp.Css/Declarations/FontVariantDeclaration.cs index a18367c9..fd2dffea 100644 --- a/src/AngleSharp.Css/Declarations/FontVariantDeclaration.cs +++ b/src/AngleSharp.Css/Declarations/FontVariantDeclaration.cs @@ -1,6 +1,8 @@ +#nullable disable namespace AngleSharp.Css.Declarations { using AngleSharp.Css.Dom; + using AngleSharp.Text; using System; using static ValueConverters; @@ -13,10 +15,24 @@ static class FontVariantDeclaration PropertyNames.Font, }; - public static IValueConverter Converter = FontVariantConverter; + public static String[] Longhands = new[] + { + PropertyNames.FontVariantCaps, + }; + + public static IValueConverter Converter = new FontVariantAggregator(); public static ICssValue InitialValue = InitialValues.FontVariantDecl; - public static PropertyFlags Flags = PropertyFlags.Inherited; + public static PropertyFlags Flags = PropertyFlags.Inherited | PropertyFlags.Shorthand; + + sealed class FontVariantAggregator : IValueAggregator, IValueConverter + { + public ICssValue Convert(StringSource source) => FontVariantCapsConverter.Convert(source); + + public ICssValue Merge(ICssValue[] values) => values[0]; + + public ICssValue[] Split(ICssValue value) => new[] { value }; + } } } diff --git a/src/AngleSharp.Css/Declarations/TextAlignLastDeclaration.cs b/src/AngleSharp.Css/Declarations/TextAlignLastDeclaration.cs index 31b73a8d..c90f4633 100644 --- a/src/AngleSharp.Css/Declarations/TextAlignLastDeclaration.cs +++ b/src/AngleSharp.Css/Declarations/TextAlignLastDeclaration.cs @@ -12,6 +12,6 @@ static class TextAlignLastDeclaration public static ICssValue InitialValue = InitialValues.TextAlignLastDecl; - public static PropertyFlags Flags = PropertyFlags.None; + public static PropertyFlags Flags = PropertyFlags.Inherited; } } diff --git a/src/AngleSharp.Css/Dom/DisplayMode.cs b/src/AngleSharp.Css/Dom/DisplayMode.cs index 0a0f8e36..f9de9ea1 100644 --- a/src/AngleSharp.Css/Dom/DisplayMode.cs +++ b/src/AngleSharp.Css/Dom/DisplayMode.cs @@ -91,6 +91,11 @@ public enum DisplayMode : byte /// The element behaves like an inline element and lay out its content /// according to the grid model. /// - InlineGrid + InlineGrid, + /// + /// The element generates a block box that establishes a new block + /// formatting context, i.e., it contains its floats. + /// + FlowRoot, } } diff --git a/src/AngleSharp.Css/Dom/Internal/CssProperty.cs b/src/AngleSharp.Css/Dom/Internal/CssProperty.cs index f9241baf..4404315a 100644 --- a/src/AngleSharp.Css/Dom/Internal/CssProperty.cs +++ b/src/AngleSharp.Css/Dom/Internal/CssProperty.cs @@ -128,7 +128,7 @@ public ICssProperty Compute(ICssComputeContext context) #region Compute Context - sealed class PropertyComputeContext : ICssComputeContext, ILocalComputeContext + sealed class PropertyComputeContext : ICssComputeContext, ILocalComputeContext, IInheritingComputeContext { private readonly ICssComputeContext _parent; private readonly IValueConverter _converter; @@ -148,6 +148,8 @@ public PropertyComputeContext(ICssComputeContext parent, IValueConverter convert public IValueConverter Converter => _converter; public ICssValue Resolve(String name) =>_parent.Resolve(name); + + public ICssValue InheritedValue(String name) => (_parent as IInheritingComputeContext)?.InheritedValue(name); } #endregion diff --git a/src/AngleSharp.Css/Dom/Internal/PseudoElementSubjectVisitor.cs b/src/AngleSharp.Css/Dom/Internal/PseudoElementSubjectVisitor.cs new file mode 100644 index 00000000..054136da --- /dev/null +++ b/src/AngleSharp.Css/Dom/Internal/PseudoElementSubjectVisitor.cs @@ -0,0 +1,72 @@ +#nullable disable +namespace AngleSharp.Css.Dom +{ + using AngleSharp.Dom; + using System.Collections.Generic; + using System.Linq; + + /// + /// Determines if the subject of a selector (i.e., its rightmost compound) + /// targets a pseudo-element. Only such selectors may apply to a pseudo-element, + /// otherwise the rules of the host would also be applied to its pseudo-elements. + /// + sealed class PseudoElementSubjectVisitor : ISelectorVisitor + { + private bool _result; + + private PseudoElementSubjectVisitor() + { + } + + public static bool TargetsPseudoElement(ISelector selector) + { + var visitor = new PseudoElementSubjectVisitor(); + selector?.Accept(visitor); + return visitor._result; + } + + void ISelectorVisitor.Attribute(string name, string op, string value) + { + } + + void ISelectorVisitor.Type(string name) + { + } + + void ISelectorVisitor.Id(string value) + { + } + + void ISelectorVisitor.Child(string name, int step, int offset, ISelector selector) + { + } + + void ISelectorVisitor.Class(string name) + { + } + + void ISelectorVisitor.PseudoClass(string name) + { + } + + void ISelectorVisitor.PseudoElement(string name) + { + _result = true; + } + + void ISelectorVisitor.List(IEnumerable selectors) + { + _result = selectors.Any(TargetsPseudoElement); + } + + void ISelectorVisitor.Combinator(IEnumerable selectors, IEnumerable symbols) + { + _result = TargetsPseudoElement(selectors.LastOrDefault()); + } + + void ISelectorVisitor.Many(IEnumerable selectors) + { + _result = selectors.Any(TargetsPseudoElement); + } + } +} diff --git a/src/AngleSharp.Css/Dom/Internal/Rules/CssStyleRule.cs b/src/AngleSharp.Css/Dom/Internal/Rules/CssStyleRule.cs index 09d5d83d..4e6acd34 100644 --- a/src/AngleSharp.Css/Dom/Internal/Rules/CssStyleRule.cs +++ b/src/AngleSharp.Css/Dom/Internal/Rules/CssStyleRule.cs @@ -22,6 +22,8 @@ sealed class CssStyleRule : CssRule, ICssStyleRule, ISelectorVisitor private readonly CssRuleList _rules; private ISelector _selector; private ISelector[] _selectorList; + private Boolean[] _selectorListTargetsPseudo; + private Boolean _selectorTargetsPseudo; private Boolean _nested; #endregion @@ -96,6 +98,9 @@ public Boolean TryMatch(IElement element, IElement? scope, out Priority specific { specificity = Priority.Zero; scope ??= element?.Owner!.DocumentElement; + // A pseudo-element only gets the rules whose selector targets a pseudo-element; + // a rule matching the host itself must not apply to its pseudo-elements. + var isPseudo = element is IPseudoElement; if (!_nested && Parent is CssStyleRule parent) { @@ -127,15 +132,18 @@ public Boolean TryMatch(IElement element, IElement? scope, out Priority specific { var selector = _selectorList[i]; - if (selector.Match(element, scope)) + if ((!isPseudo || _selectorListTargetsPseudo[i]) && selector.Match(element, scope)) { specificity += selector.Specificity; return true; } } + + // The list as a whole matches exactly if one of its items does. + return false; } - if (_selector is not null && _selector.Match(element, scope)) + if (_selector is not null && (!isPseudo || _selectorTargetsPseudo) && _selector.Match(element, scope)) { specificity += _selector.Specificity; return true; @@ -157,7 +165,9 @@ public override void ToCss(TextWriter writer, IStyleFormatter formatter) internal void ChangeSelector(ISelector value) { _selectorList = null; + _selectorListTargetsPseudo = null; _selector = value; + _selectorTargetsPseudo = PseudoElementSubjectVisitor.TargetsPseudoElement(value); value?.Accept(this); } @@ -194,6 +204,7 @@ void ISelectorVisitor.List(IEnumerable selectors) // OrderByDescending is stable, so selectors of equal specificity keep // their declared order - same as when this ran per match attempt. _selectorList = selectors.OrderByDescending(m => m.Specificity).ToArray(); + _selectorListTargetsPseudo = _selectorList.Select(PseudoElementSubjectVisitor.TargetsPseudoElement).ToArray(); } void ISelectorVisitor.Combinator(IEnumerable selectors, IEnumerable symbols) diff --git a/src/AngleSharp.Css/Dom/Internal/StyleCollection.cs b/src/AngleSharp.Css/Dom/Internal/StyleCollection.cs index 194a4d50..7f2da614 100644 --- a/src/AngleSharp.Css/Dom/Internal/StyleCollection.cs +++ b/src/AngleSharp.Css/Dom/Internal/StyleCollection.cs @@ -1,6 +1,7 @@ namespace AngleSharp.Css.Dom { using AngleSharp.Dom; + using System; using System.Collections; using System.Collections.Generic; using System.Linq; @@ -9,16 +10,24 @@ sealed class StyleCollection : IStyleCollection { #region Fields + private readonly IEnumerable _defaultSheets; private readonly IEnumerable _sheets; private readonly IRenderDevice _device; private List? _rules; + private Int32 _defaultRuleCount; #endregion #region ctor public StyleCollection(IEnumerable sheets, IRenderDevice device) + : this(Enumerable.Empty(), sheets, device) { + } + + public StyleCollection(IEnumerable defaultSheets, IEnumerable sheets, IRenderDevice device) + { + _defaultSheets = defaultSheets; _sheets = sheets; _device = device; } @@ -45,6 +54,17 @@ public StyleCollection(IEnumerable sheets, IRenderDevice device) /// internal List GetRules() => _rules ??= CollectRules(); + /// + /// Gets the number of rules at the beginning of the list from GetRules + /// that stem from the default (user agent) stylesheets. These rules + /// precede all author rules in the cascade, independent of specificity. + /// + internal Int32 GetDefaultRuleCount() + { + GetRules(); + return _defaultRuleCount; + } + #endregion #region Helpers @@ -52,8 +72,15 @@ public StyleCollection(IEnumerable sheets, IRenderDevice device) private List CollectRules() { var rules = new List(); + AddRules(rules, _defaultSheets); + _defaultRuleCount = rules.Count; + AddRules(rules, _sheets); + return rules; + } - foreach (var sheet in _sheets) + private void AddRules(List rules, IEnumerable sheets) + { + foreach (var sheet in sheets) { if (!sheet.IsDisabled && sheet.Media.Validate(_device)) { @@ -63,8 +90,6 @@ private List CollectRules() } } } - - return rules; } IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); diff --git a/src/AngleSharp.Css/Extensions/CssApiExtensions.cs b/src/AngleSharp.Css/Extensions/CssApiExtensions.cs index 403b168c..a07aab2d 100644 --- a/src/AngleSharp.Css/Extensions/CssApiExtensions.cs +++ b/src/AngleSharp.Css/Extensions/CssApiExtensions.cs @@ -32,8 +32,7 @@ public static ICssStyleDeclaration ComputeCurrentStyle(this IElement element) var defaultStyleSheetProvider = ctx?.GetServices() ?? Enumerable.Empty(); var defaultSheets = defaultStyleSheetProvider.Select(m => m.Default).Where(m => m != null); var currentSheets = document?.GetStyleSheets().OfType() ?? Enumerable.Empty(); - var stylesheets = defaultSheets.Concat(currentSheets); - var styleCollection = new StyleCollection(stylesheets, device!); + var styleCollection = new StyleCollection(defaultSheets, currentSheets, device!); return styleCollection.ComputeDeclarations(element); } diff --git a/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs b/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs index 50606462..2660be07 100644 --- a/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs +++ b/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs @@ -31,8 +31,7 @@ public static IStyleCollection GetStyleCollection(this IWindow window, IRenderDe var defaultStyleSheetProvider = ctx.GetServices(); var defaultSheets = defaultStyleSheetProvider.Select(m => m.Default).Where(m => m != null); var currentSheets = document.GetStyleSheets().OfType(); - var stylesheets = defaultSheets.Concat(currentSheets); - return new StyleCollection(stylesheets, renderDevice); + return new StyleCollection(defaultSheets, currentSheets, renderDevice); } /// @@ -181,17 +180,18 @@ private static List SortBySpecificity(this IStyleCollection styles, I if (styles is StyleCollection collection) { var rules = collection.GetRules(); + var defaultRuleCount = collection.GetDefaultRuleCount(); for (var i = 0; i < rules.Count; i++) { - MapPriority(rules[i], element, scope, matches); + MapPriority(rules[i], element, scope, i < defaultRuleCount, matches); } } else { foreach (var rule in styles) { - MapPriority(rule, element, scope, matches); + MapPriority(rule, element, scope, false, matches); } } @@ -201,11 +201,11 @@ private static List SortBySpecificity(this IStyleCollection styles, I return matches; } - private static void MapPriority(ICssStyleRule rule, IElement element, IElement? scope, List matches) + private static void MapPriority(ICssStyleRule rule, IElement element, IElement? scope, Boolean isDefault, List matches) { if (rule.TryMatch(element, scope, out var specificity)) { - matches.Add(new RuleMatch(rule, specificity, matches.Count)); + matches.Add(new RuleMatch(rule, isDefault, specificity, matches.Count)); } var subRules = rule.Rules; @@ -214,22 +214,25 @@ private static void MapPriority(ICssStyleRule rule, IElement element, IElement? { if (subRules[i] is ICssStyleRule style) { - MapPriority(style, element, scope, matches); + MapPriority(style, element, scope, isDefault, matches); } } } private readonly struct RuleMatch { - public RuleMatch(ICssStyleRule rule, Priority priority, Int32 index) + public RuleMatch(ICssStyleRule rule, Boolean isDefault, Priority priority, Int32 index) { Rule = rule; + IsDefault = isDefault; Priority = priority; Index = index; } public ICssStyleRule Rule { get; } + public Boolean IsDefault { get; } + public Priority Priority { get; } public Int32 Index { get; } @@ -241,6 +244,13 @@ private sealed class RuleMatchComparer : IComparer public Int32 Compare(RuleMatch x, RuleMatch y) { + // Rules of the default (user agent) stylesheets always precede author + // rules, i.e., an author rule wins independent of its specificity. + if (x.IsDefault != y.IsDefault) + { + return x.IsDefault ? -1 : 1; + } + var result = Comparer.Default.Compare(x.Priority, y.Priority); return result != 0 ? result : x.Index.CompareTo(y.Index); } diff --git a/src/AngleSharp.Css/Extensions/ValueConverterExtensions.cs b/src/AngleSharp.Css/Extensions/ValueConverterExtensions.cs index 5ae4954e..5e5b0fc7 100644 --- a/src/AngleSharp.Css/Extensions/ValueConverterExtensions.cs +++ b/src/AngleSharp.Css/Extensions/ValueConverterExtensions.cs @@ -38,6 +38,9 @@ public static IValueConverter FromList(this IValueConverter converter) => public static IValueConverter ToConverter(this Dictionary values) => new DictionaryValueConverter(values); + public static IValueConverter BoxRelative(this IValueConverter converter) => + new BoxRelativeValueConverter(converter); + public static IValueConverter FlowRelative(this IValueConverter converter) => new FlowRelativeValueConverter(converter); diff --git a/src/AngleSharp.Css/Factories/DefaultDeclarationFactory.cs b/src/AngleSharp.Css/Factories/DefaultDeclarationFactory.cs index 83b2f9c4..bff400f8 100644 --- a/src/AngleSharp.Css/Factories/DefaultDeclarationFactory.cs +++ b/src/AngleSharp.Css/Factories/DefaultDeclarationFactory.cs @@ -1072,7 +1072,16 @@ public class DefaultDeclarationFactory : IDeclarationFactory converter: FontVariantDeclaration.Converter, initialValue: FontVariantDeclaration.InitialValue, flags: FontVariantDeclaration.Flags, - shorthands: FontVariantDeclaration.Shorthands) + shorthands: FontVariantDeclaration.Shorthands, + longhands: FontVariantDeclaration.Longhands) + }, + { + FontVariantCapsDeclaration.Name, new DeclarationInfo( + name: FontVariantCapsDeclaration.Name, + converter: FontVariantCapsDeclaration.Converter, + initialValue: FontVariantCapsDeclaration.InitialValue, + flags: FontVariantCapsDeclaration.Flags, + shorthands: FontVariantCapsDeclaration.Shorthands) }, { FontWeightDeclaration.Name, new DeclarationInfo( diff --git a/src/AngleSharp.Css/Parser/Micro/GradientParser.cs b/src/AngleSharp.Css/Parser/Micro/GradientParser.cs index a29d4b5a..f29439aa 100644 --- a/src/AngleSharp.Css/Parser/Micro/GradientParser.cs +++ b/src/AngleSharp.Css/Parser/Micro/GradientParser.cs @@ -220,19 +220,16 @@ private static CssGradientStopValue[] ParseGradientStops(StringSource source) source.SkipCurrentAndSpaces(); } - var stop = ParseGradientStop(source); - - if (stop == null) + if (!ParseGradientStop(source, stops)) break; - stops.Add(stop); current = source.SkipSpacesAndComments(); } return stops.ToArray(); } - private static CssGradientStopValue ParseGradientStop(StringSource source) + private static Boolean ParseGradientStop(StringSource source, List stops) { var color = source.ParseColor(); source.SkipSpacesAndComments(); @@ -243,16 +240,34 @@ private static CssGradientStopValue ParseGradientStop(StringSource source) // 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(); + var position = ParseGradientStopPosition(source); if (color.HasValue) { - return new CssGradientStopValue(color.Value, position); + stops.Add(new CssGradientStopValue(color.Value, position)); + + if (position != null) + { + // CSS Images 4 allows a second position ("red 0 50%"), which is + // the same as two stops of the same color at both positions. + source.SkipSpacesAndComments(); + var second = ParseGradientStopPosition(source); + + if (second != null) + { + stops.Add(new CssGradientStopValue(color.Value, second)); + } + } + + return true; } - return null; + return false; } + private static ICssValue ParseGradientStopPosition(StringSource source) => + source.ParseDistanceOrCalc() ?? source.ParseAngleOrCalc(); + private static ICssValue ParseLinearAngle(StringSource source) { if (source.IsIdentifier(CssKeywords.To)) diff --git a/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs b/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs index f5c54feb..f42f1540 100644 --- a/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs +++ b/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs @@ -72,8 +72,7 @@ private StyleCollection CreateStyleCollection(IRenderDevice device) { var document = _window.Document; var currentSheets = document.GetStyleSheets().OfType(); - var stylesheets = _defaultSheets.Concat(currentSheets).ToList(); - return new StyleCollection(stylesheets, device); + return new StyleCollection(_defaultSheets, currentSheets.ToList(), device); } private ElementRenderNode RenderElement( diff --git a/src/AngleSharp.Css/ValueConverters.cs b/src/AngleSharp.Css/ValueConverters.cs index 3c1726f7..45a60cdb 100644 --- a/src/AngleSharp.Css/ValueConverters.cs +++ b/src/AngleSharp.Css/ValueConverters.cs @@ -281,7 +281,7 @@ static class ValueConverters /// /// Represents a converter for the backdrop-filter property. /// - public static readonly IValueConverter BackdropFilterConverter = Assign(CssKeywords.None, CssKeywords.None); + public static IValueConverter BackdropFilterConverter => FilterConverter; /// /// Represents a filter function list or the none keyword. @@ -572,12 +572,12 @@ static class ValueConverters /// /// Represents a converter for the mask-repeat property. /// - public static IValueConverter MaskRepeatConverter => BackgroundRepeatsConverter; + public static IValueConverter MaskRepeatConverter => BackgroundRepeatsConverter.FromList(); /// /// Represents a converter for the mask-position property. /// - public static IValueConverter MaskPositionConverter => PointConverter; + public static IValueConverter MaskPositionConverter => PointConverter.FromList().BoxRelative(); /// /// Represents a converter for the mask-clip property. @@ -604,7 +604,7 @@ static class ValueConverters /// /// Represents a converter for the mask-size property. /// - public static IValueConverter MaskSizeConverter => BackgroundSizeConverter; + public static IValueConverter MaskSizeConverter => BackgroundSizeConverter.FromList(); /// /// Represents a converter for the mask-composite property. @@ -1005,6 +1005,18 @@ static class ValueConverters /// public static readonly IValueConverter FontVariantConverter = Map.FontVariants.ToConverter(); + /// + /// Represents a converter for the font-variant-caps property. + /// https://www.w3.org/TR/css-fonts-4/#font-variant-caps-prop + /// + public static readonly IValueConverter FontVariantCapsConverter = Or( + FontVariantConverter, + Assign(CssKeywords.AllSmallCaps, CssKeywords.AllSmallCaps), + Assign(CssKeywords.PetiteCaps, CssKeywords.PetiteCaps), + Assign(CssKeywords.AllPetiteCaps, CssKeywords.AllPetiteCaps), + Assign(CssKeywords.Unicase, CssKeywords.Unicase), + Assign(CssKeywords.TitlingCaps, CssKeywords.TitlingCaps)); + /// /// Represents a converter for the DirectionMode enumeration. /// @@ -1083,7 +1095,9 @@ static class ValueConverters /// /// Represents a converter for the FontStretch enumeration. /// - public static readonly IValueConverter FontStretchConverter = Map.FontStretches.ToConverter(); + public static readonly IValueConverter FontStretchConverter = Or( + Map.FontStretches.ToConverter(), + PercentConverter); /// /// Represents a converter for the FontStyle enumeration. @@ -1093,7 +1107,7 @@ static class ValueConverters /// /// Represents a converter for the FontWeight enumeration. /// - public static readonly IValueConverter FontWeightConverter = Map.FontWeights.ToConverter(); + public static readonly IValueConverter FontWeightConverter = new FontWeightValueConverter(); /// /// Represents a converter for the font-display descriptor. diff --git a/src/AngleSharp.Css/Values/CssComputeContext.cs b/src/AngleSharp.Css/Values/CssComputeContext.cs index 3d0ea93f..4b27e530 100644 --- a/src/AngleSharp.Css/Values/CssComputeContext.cs +++ b/src/AngleSharp.Css/Values/CssComputeContext.cs @@ -4,7 +4,7 @@ namespace AngleSharp.Css.Values using System; using System.Linq; - sealed class CssComputeContext : ICssComputeContext, ILocalComputeContext + sealed class CssComputeContext : ICssComputeContext, ILocalComputeContext, IInheritingComputeContext { private readonly IRenderDevice _device; private readonly IBrowsingContext? _context; @@ -30,7 +30,7 @@ public CssComputeContext(IRenderDevice device, IBrowsingContext? context, ICssPr public ICssValue? Resolve(String name) => _variables.Resolve(name); - internal ICssValue? InheritedValue(String name) => _parent?.FirstOrDefault(m => m.Name == name)?.RawValue; + public ICssValue? InheritedValue(String name) => _parent?.FirstOrDefault(m => m.Name == name)?.RawValue; } } diff --git a/src/AngleSharp.Css/Values/IInheritingComputeContext.cs b/src/AngleSharp.Css/Values/IInheritingComputeContext.cs new file mode 100644 index 00000000..5d579aea --- /dev/null +++ b/src/AngleSharp.Css/Values/IInheritingComputeContext.cs @@ -0,0 +1,9 @@ +namespace AngleSharp.Css.Values; + +using AngleSharp.Css.Dom; +using System; + +interface IInheritingComputeContext +{ + ICssValue? InheritedValue(String name); +} diff --git a/src/AngleSharp.Css/Values/Multiples/CssListValue.cs b/src/AngleSharp.Css/Values/Multiples/CssListValue.cs index b025f78b..d065aa91 100644 --- a/src/AngleSharp.Css/Values/Multiples/CssListValue.cs +++ b/src/AngleSharp.Css/Values/Multiples/CssListValue.cs @@ -91,6 +91,13 @@ IEnumerator IEnumerable.GetEnumerator() => ICssValue ICssValue.Compute(ICssComputeContext context) { var items = _items.Select(v => (T)v.Compute(context)).ToArray(); + + // Keep the specific type, e.g., such that shorthands can merge the computed list. + if (this is CssListValue) + { + return new CssListValue((ICssValue[])(object)items); + } + return new CssListValue(items); } diff --git a/src/AngleSharp.Css/Values/Multiples/CssRadiusValue.cs b/src/AngleSharp.Css/Values/Multiples/CssRadiusValue.cs index 5fcf9aa6..1300b279 100644 --- a/src/AngleSharp.Css/Values/Multiples/CssRadiusValue.cs +++ b/src/AngleSharp.Css/Values/Multiples/CssRadiusValue.cs @@ -130,47 +130,24 @@ ICssValue ICssValue.Compute(ICssComputeContext context) for (var i = 0; i < _values.Length; i++) { - var mode = i == 1 ? RenderMode.Vertical : RenderMode.Horizontal; - values[i] = (T)ComputeValue(_values[i], context, mode); + values[i] = (T)ComputeValue(_values[i], context); } return new CssRadiusValue(values); } - private static ICssValue ComputeValue(ICssValue value, ICssComputeContext context, RenderMode mode) + private static ICssValue ComputeValue(ICssValue value, ICssComputeContext context) { - if (value is CssLengthValue length && length.Type == CssLengthValue.Unit.Percent) + // The computed value is a pair of values: a percentage refers + // to the border box, which is only known during layout, so it stays a percentage. + if (value is CssLengthValue length && length.Type == CssLengthValue.Unit.Percent || value is CssPercentageValue) { - return new CssLengthValue(length.Value * 0.01 * GetDimension(context, mode), CssLengthValue.Unit.Px); - } - - if (value is CssPercentageValue percentage) - { - return new CssLengthValue(percentage.Value * 0.01 * GetDimension(context, mode), CssLengthValue.Unit.Px); + return value; } return value.Compute(context); } - private static Double GetDimension(ICssComputeContext context, RenderMode mode) - { - var name = mode == RenderMode.Horizontal ? PropertyNames.Width : PropertyNames.Height; - var properties = (context as ILocalComputeContext)?.Properties; - var property = properties?.GetProperty(name); - - if (property?.RawValue is CssLengthValue length && length.Type == CssLengthValue.Unit.Px) - { - return length.Value; - } - - if (CssLengthValue.TryParse(properties?.GetPropertyValue(name), out var parsed)) - { - return parsed.ToPixel(context.Device); - } - - return mode == RenderMode.Horizontal ? context.Device.RenderWidth : context.Device.RenderHeight; - } - Boolean IEquatable.Equals(ICssValue other) => other is CssRadiusValue value && Equals(value); #endregion diff --git a/src/AngleSharp.Css/Values/Primitives/CssLengthValue.cs b/src/AngleSharp.Css/Values/Primitives/CssLengthValue.cs index 6da120a5..b95c62ae 100644 --- a/src/AngleSharp.Css/Values/Primitives/CssLengthValue.cs +++ b/src/AngleSharp.Css/Values/Primitives/CssLengthValue.cs @@ -1,6 +1,7 @@ #nullable disable namespace AngleSharp.Css.Values { + using AngleSharp.Css.Converters; using AngleSharp.Css.Dom; using System; @@ -62,6 +63,7 @@ namespace AngleSharp.Css.Values private readonly Double _value; private readonly Unit _unit; + private readonly Boolean _computed; #endregion @@ -85,6 +87,14 @@ public CssLengthValue(Double value, Unit unit) { _value = value; _unit = unit; + _computed = false; + } + + private CssLengthValue(Double value, Unit unit, Boolean computed) + { + _value = value; + _unit = unit; + _computed = computed; } #endregion @@ -110,7 +120,8 @@ public String CssText { // Per CSS spec, 0 is valid without a unit for lengths, // but 0% is a percentage (relative to context) and must keep its unit. - var unit = _value == 0.0 && _unit != Unit.Percent ? String.Empty : UnitString; + // A computed (resolved) length always keeps its unit, e.g., 0px. + var unit = _value == 0.0 && _unit != Unit.Percent && !_computed ? String.Empty : UnitString; var val = _value.CssStringify(); return String.Concat(val, unit); } @@ -228,13 +239,26 @@ public Int32 CompareTo(CssLengthValue other) ICssValue ICssValue.Compute(ICssComputeContext context) { + if (_unit == Unit.None || Double.IsNaN(_value)) + { + // A plain number (e.g., a multiple of the border width) or a + // keyword (e.g., auto) has no pixel representation. + return this; + } + + if (_unit == Unit.Percent && context.Converter is BoxRelativeValueConverter) + { + // Refers to the element's own box, which is only known during layout. + return this; + } + if (_unit != CssLengthValue.Unit.Px) { var px = ToPixel(context.Device); - return new CssLengthValue(px, CssLengthValue.Unit.Px); + return new CssLengthValue(px, CssLengthValue.Unit.Px, true); } - return this; + return _value == 0.0 && !_computed ? new CssLengthValue(_value, _unit, true) : this; } /// diff --git a/src/AngleSharp.Css/Values/Primitives/CssRelativeFontWeightValue.cs b/src/AngleSharp.Css/Values/Primitives/CssRelativeFontWeightValue.cs new file mode 100644 index 00000000..8ebcc184 --- /dev/null +++ b/src/AngleSharp.Css/Values/Primitives/CssRelativeFontWeightValue.cs @@ -0,0 +1,82 @@ +#nullable enable +namespace AngleSharp.Css.Values +{ + using AngleSharp.Css.Dom; + using System; + using System.Linq; + + /// + /// Represents a relative font weight (bolder or lighter), which computes to + /// a number resolved against the parent's computed weight. + /// See https://www.w3.org/TR/css-fonts-4/#relative-weights. + /// + sealed class CssRelativeFontWeightValue : ICssPrimitiveValue, IEquatable + { + #region Fields + + private readonly CssConstantValue _keyword; + + #endregion + + #region ctor + + public CssRelativeFontWeightValue(CssConstantValue keyword) + { + _keyword = keyword; + } + + #endregion + + #region Properties + + public FontWeight Value => _keyword.Value; + + public String CssText => _keyword.CssText; + + #endregion + + #region Methods + + public Boolean Equals(CssRelativeFontWeightValue? other) => other is not null && _keyword.Equals(other._keyword); + + Boolean IEquatable.Equals(ICssValue? other) => other is CssRelativeFontWeightValue value && Equals(value); + + public override Boolean Equals(Object? obj) => obj is CssRelativeFontWeightValue value && Equals(value); + + public override Int32 GetHashCode() => _keyword.GetHashCode(); + + ICssValue ICssValue.Compute(ICssComputeContext context) + { + var parent = (context as IInheritingComputeContext)?.InheritedValue(PropertyNames.FontWeight); + var weight = GetWeight(parent); + var result = Value == FontWeight.Bolder ? Bolder(weight) : Lighter(weight); + return new CssIntegerValue(result); + } + + private static Int32 Bolder(Int32 weight) => weight switch + { + < 350 => 400, + < 550 => 700, + < 900 => 900, + _ => weight, + }; + + private static Int32 Lighter(Int32 weight) => weight switch + { + < 100 => weight, + < 550 => 100, + < 750 => 400, + _ => 700, + }; + + private static Int32 GetWeight(ICssValue? value) => value switch + { + CssIntegerValue integer => integer.IntValue, + CssConstantValue constant => constant.Value == FontWeight.Bold ? 700 : 400, + ICssMultipleValue multiple when multiple.Count > 0 => GetWeight(multiple.First()), + _ => 400, + }; + + #endregion + } +} diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 04ce2684..2394772d 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.3 + 1.1.4 enable latest true