From 1157089e50462f9d75a4e93c2c36221c9cbd7f46 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Fri, 23 Oct 2026 09:43:50 +0200 Subject: [PATCH] Fixes and improvements --- CHANGELOG.md | 9 + src/AngleSharp.Css.Docs/package.json | 2 +- .../BlockificationAndPlaceholderTests.cs | 176 ++++++++++++++++++ .../Styling/Configuration.cs | 3 +- .../CssConfigurationExtensions.cs | 9 + .../CssDefaultStyleSheetProvider.cs | 1 + .../Dom/Internal/CssPseudoElementSelector.cs | 27 +++ .../Extensions/CssOmExtensions.cs | 90 +++++++++ .../Extensions/StyleCollectionExtensions.cs | 7 +- .../CssPseudoElementSelectorFactory.cs | 39 ++++ .../Factories/DefaultPseudoElementFactory.cs | 13 ++ .../RenderTree/RenderTreeBuilder.cs | 2 +- src/Directory.Build.props | 2 +- 13 files changed, 373 insertions(+), 7 deletions(-) create mode 100644 src/AngleSharp.Css.Tests/Styling/BlockificationAndPlaceholderTests.cs create mode 100644 src/AngleSharp.Css/Dom/Internal/CssPseudoElementSelector.cs create mode 100644 src/AngleSharp.Css/Factories/CssPseudoElementSelectorFactory.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index a600b32e..912c852b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +# 1.2.1 + +Released on Friday, October 23 2026. + +- Fixed computed `display` not blockified for floats +- Fixed computed `display` not blockified for absolutely positioned boxes +- FIxed computed `display` not blockified for the root element +- Added support for `::placeholder` + # 1.2.0 Released on Wednesday, October 21 2026. diff --git a/src/AngleSharp.Css.Docs/package.json b/src/AngleSharp.Css.Docs/package.json index cbb453cb..05b6fdd6 100644 --- a/src/AngleSharp.Css.Docs/package.json +++ b/src/AngleSharp.Css.Docs/package.json @@ -1,6 +1,6 @@ { "name": "@anglesharp/css", - "version": "1.2.0", + "version": "1.2.1", "preview": true, "description": "The doclet for the AngleSharp.Css documentation.", "keywords": [ diff --git a/src/AngleSharp.Css.Tests/Styling/BlockificationAndPlaceholderTests.cs b/src/AngleSharp.Css.Tests/Styling/BlockificationAndPlaceholderTests.cs new file mode 100644 index 00000000..623f2b5c --- /dev/null +++ b/src/AngleSharp.Css.Tests/Styling/BlockificationAndPlaceholderTests.cs @@ -0,0 +1,176 @@ +#nullable disable +namespace AngleSharp.Css.Tests.Styling +{ + using AngleSharp.Css.Dom; + using AngleSharp.Css.Parser; + using AngleSharp.Css.RenderTree; + using AngleSharp.Dom; + using NUnit.Framework; + using System.Linq; + using System.Threading.Tasks; + + /// + /// The computed `display` of a floated box, an absolutely positioned box and the root element + /// is blockified (CSS Display 3 §2.7), and `::placeholder` (CSS Pseudo-Elements 4 §4.3) is + /// styled from CSS. Found in a downstream renderer (AngleSharp.Renderer): a floated inline + /// computed as `inline` and caused unbounded recursion, and placeholder text could not be + /// styled from CSS. + /// + [TestFixture] + public class BlockificationAndPlaceholderTests + { + private static async Task LoadAsync(string html) + { + var context = BrowsingContext.New(Configuration.Default.WithCss()); + return await context.OpenAsync(req => req.Content(html)); + } + + [TestCase("float: left", "inline", "block")] + [TestCase("float: right", "inline-block", "block")] + [TestCase("float: left", "inline-flex", "flex")] + [TestCase("float: left", "inline-grid", "grid")] + [TestCase("float: left", "inline-table", "table")] + [TestCase("float: left", "inline list-item", "list-item")] + [TestCase("float: left", "table-cell", "block")] + [TestCase("float: left", "ruby", "block ruby")] + [TestCase("position: absolute", "inline", "block")] + [TestCase("position: fixed", "inline-flex", "flex")] + [TestCase("float: left", "none", "none")] + [TestCase("float: left", "contents", "contents")] + [TestCase("float: left", "flow-root", "flow-root")] + [TestCase("float: none", "inline", "inline")] + [TestCase("position: relative", "inline", "inline")] + public async Task FloatsAndAbsolutelyPositionedBoxesAreBlockified(string declaration, string display, string expected) + { + var document = await LoadAsync($"A"); + var element = document.GetElementById("s"); + + var computed = document.DefaultView.GetComputedStyle(element); + + Assert.AreEqual(expected, computed.GetDisplay()); + } + + [Test] + public async Task UnsetDisplayOfAFloatIsBlockified() + { + var document = await LoadAsync("A"); + + var computed = document.DefaultView.GetComputedStyle(document.GetElementById("s")); + + Assert.AreEqual("block", computed.GetDisplay()); + } + + [Test] + public async Task FloatedPseudoElementIsBlockified() + { + var document = await LoadAsync("A"); + + var computed = document.DefaultView.GetComputedStyle(document.GetElementById("s"), "::before"); + + Assert.AreEqual("block", computed.GetDisplay()); + } + + [Test] + public async Task RootElementIsBlockified() + { + var document = await LoadAsync(""); + + var computed = document.DefaultView.GetComputedStyle(document.DocumentElement); + + Assert.AreEqual("block", computed.GetDisplay()); + } + + [Test] + public async Task RenderTreeCarriesTheBlockifiedDisplay() + { + var document = await LoadAsync("A"); + + var tree = document.DefaultView.Render(); + var node = Find(tree, document.GetElementById("s")); + + Assert.IsNotNull(node); + Assert.AreEqual("block", node.ComputedStyle.GetDisplay()); + } + + [Test] + public async Task PlaceholderPseudoElementTakesAuthorRules() + { + var document = await LoadAsync(""); + + var computed = document.DefaultView.GetComputedStyle(document.GetElementById("b"), "::placeholder"); + + Assert.AreEqual("rgba(1, 2, 3, 1)", computed.GetColor()); + } + + [Test] + public async Task PlaceholderPseudoElementInheritsFromItsElement() + { + var document = await LoadAsync(""); + + var computed = document.DefaultView.GetComputedStyle(document.GetElementById("b"), "::placeholder"); + + Assert.AreEqual("30px", computed.GetFontSize()); + } + + [Test] + public async Task PlaceholderPseudoElementHasADefaultLook() + { + var document = await LoadAsync(""); + + var placeholder = document.DefaultView.GetComputedStyle(document.GetElementById("b"), "::placeholder"); + var element = document.DefaultView.GetComputedStyle(document.GetElementById("b")); + + Assert.AreNotEqual(element.GetColor(), placeholder.GetColor()); + } + + [Test] + public async Task PlaceholderPseudoElementOfATextareaTakesAuthorRules() + { + var document = await LoadAsync(""); + + var computed = document.DefaultView.GetComputedStyle(document.GetElementById("b"), "::placeholder"); + + Assert.AreEqual("rgba(1, 2, 3, 1)", computed.GetColor()); + } + + [Test] + public async Task PlaceholderRulesDoNotApplyToTheElementItself() + { + var document = await LoadAsync(""); + + var computed = document.DefaultView.GetComputedStyle(document.GetElementById("b")); + + Assert.AreNotEqual("rgba(1, 2, 3, 1)", computed.GetColor()); + } + + [Test] + public async Task PlaceholderPseudoElementDoesNotExistWithoutAPlaceholder() + { + var document = await LoadAsync("
"); + + Assert.IsNull(document.GetElementById("b").Pseudo("placeholder")); + Assert.IsNull(document.GetElementById("d").Pseudo("placeholder")); + } + + [Test] + public void PlaceholderSelectorParses() + { + var parser = new CssParser(); + + var sheet = parser.ParseStyleSheet("input::placeholder { color: red }"); + + Assert.AreEqual(1, sheet.Rules.Length); + Assert.AreEqual("input::placeholder", ((ICssStyleRule)sheet.Rules[0]).SelectorText); + } + + private static ElementRenderNode Find(IRenderNode node, INode target) + { + if (node.Ref == target) + { + return node as ElementRenderNode; + } + + return node.Children.Select(child => Find(child, target)).FirstOrDefault(found => found is not null); + } + } +} diff --git a/src/AngleSharp.Css.Tests/Styling/Configuration.cs b/src/AngleSharp.Css.Tests/Styling/Configuration.cs index 949238cb..75d297d2 100644 --- a/src/AngleSharp.Css.Tests/Styling/Configuration.cs +++ b/src/AngleSharp.Css.Tests/Styling/Configuration.cs @@ -46,7 +46,8 @@ public void ObtainDefaultSheet() // and the block margins / list padding. // 72, not 70: the UA stylesheet now also generates quotes for `q::before` and // `q::after`. - Assert.AreEqual(72, sheet.Rules.Length); + // 73, not 72: the UA stylesheet now also gives `::placeholder` its default look. + Assert.AreEqual(73, sheet.Rules.Length); } } } diff --git a/src/AngleSharp.Css/CssConfigurationExtensions.cs b/src/AngleSharp.Css/CssConfigurationExtensions.cs index da30661d..d6001743 100644 --- a/src/AngleSharp.Css/CssConfigurationExtensions.cs +++ b/src/AngleSharp.Css/CssConfigurationExtensions.cs @@ -63,6 +63,15 @@ public static IConfiguration WithCss(this IConfiguration configuration, CssParse configuration = configuration.WithOnly(new ForcingPseudoClassSelectorFactory(pseudoClassFactory)); } + // Wraps whatever pseudo-element selector factory is registered so that pseudo-elements + // only supported with CSS (e.g. ::placeholder) are recognized as well. + var pseudoElementFactory = configuration.Services.OfType().FirstOrDefault(); + + if (pseudoElementFactory is not null && pseudoElementFactory is not CssPseudoElementSelectorFactory) + { + configuration = configuration.WithOnly(new CssPseudoElementSelectorFactory(pseudoElementFactory)); + } + return configuration .WithOnly(Factory.Observer) .WithOnly(service); diff --git a/src/AngleSharp.Css/CssDefaultStyleSheetProvider.cs b/src/AngleSharp.Css/CssDefaultStyleSheetProvider.cs index 21609244..e45c0cd1 100644 --- a/src/AngleSharp.Css/CssDefaultStyleSheetProvider.cs +++ b/src/AngleSharp.Css/CssDefaultStyleSheetProvider.cs @@ -139,6 +139,7 @@ private static ICssStyleSheet Parse(String source) input[type=range] { border: none; padding: 0px; background-color: transparent } input[type=hidden] { display: none } textarea { white-space: pre-wrap } +::placeholder { color: darkgray } big { font-size: 1.17em } small, sub, sup { font-size: .83em } sub { vertical-align: sub } diff --git a/src/AngleSharp.Css/Dom/Internal/CssPseudoElementSelector.cs b/src/AngleSharp.Css/Dom/Internal/CssPseudoElementSelector.cs new file mode 100644 index 00000000..061af48c --- /dev/null +++ b/src/AngleSharp.Css/Dom/Internal/CssPseudoElementSelector.cs @@ -0,0 +1,27 @@ +#nullable enable +namespace AngleSharp.Css.Dom +{ + using AngleSharp.Dom; + using System; + + /// + /// Matches the pseudo-element with the given name, e.g. `::placeholder`. + /// + sealed class CssPseudoElementSelector : ISelector + { + private readonly String _name; + + public CssPseudoElementSelector(String name) + { + _name = name; + } + + public Priority Specificity => Priority.OneTag; + + public String Text => PseudoElementNames.Separator + CssUtilities.Escape(_name); + + public void Accept(ISelectorVisitor visitor) => visitor.PseudoElement(_name); + + public Boolean Match(IElement element, IElement? scope) => element.IsPseudo(_name); + } +} diff --git a/src/AngleSharp.Css/Extensions/CssOmExtensions.cs b/src/AngleSharp.Css/Extensions/CssOmExtensions.cs index 3ccbc7a1..13815279 100644 --- a/src/AngleSharp.Css/Extensions/CssOmExtensions.cs +++ b/src/AngleSharp.Css/Extensions/CssOmExtensions.cs @@ -162,5 +162,95 @@ internal static CssStyleDeclaration PrepareComputedDeclarations(this ICssStyleDe declarations.UpdateDeclarations(parent); return declarations; } + + /// + /// Blockifies the computed display of a floated box, an absolutely positioned box + /// and the root element, see CSS Display 3 §2.7 and CSS 2.1 §9.7. + /// + /// The computed style of the element. + /// The element the style was computed for. + /// The computed style, adjusted if needed. + internal static ICssStyleDeclaration Blockify(this ICssStyleDeclaration style, IElement element) + { + if (style is CssStyleDeclaration declarations && IsBlockified(declarations, element)) + { + var current = declarations.GetProperty(PropertyNames.Display); + var value = Blockify(current?.RawValue); + + if (value is not null) + { + var info = element.Owner.Context.GetDeclarationInfo(PropertyNames.Display); + + if (current is not null) + { + declarations.RemoveProperty(current); + } + + declarations.AddProperty(new CssProperty(PropertyNames.Display, info.Converter, info.Flags, value, current?.IsImportant ?? false)); + } + } + + return style; + } + + private static Boolean IsBlockified(CssStyleDeclaration style, IElement element) + { + if (element is not IPseudoElement && element.Owner?.DocumentElement == element) + { + return true; + } + + var floating = style.GetPropertyValue(PropertyNames.Float); + var position = style.GetPropertyValue(PropertyNames.Position); + return (!String.IsNullOrEmpty(floating) && !floating.Isi(CssKeywords.None)) || + position.Isi(CssKeywords.Absolute) || position.Isi(CssKeywords.Fixed); + } + + private static ICssValue Blockify(ICssValue display) + { + // A missing display property computes to its initial value, i.e., inline. + if (display is null) + { + return CreateDisplay(CssKeywords.Block, DisplayMode.Block); + } + else if (display is CssConstantValue mode) + { + return Blockify(mode); + } + + return null; + } + + private static ICssValue Blockify(CssConstantValue mode) + { + switch (mode.Value) + { + case DisplayMode.Inline: + case DisplayMode.InlineBlock: + case DisplayMode.TableCaption: + case DisplayMode.TableCell: + case DisplayMode.TableColumn: + case DisplayMode.TableColumnGroup: + case DisplayMode.TableFooterGroup: + case DisplayMode.TableHeaderGroup: + case DisplayMode.TableRow: + case DisplayMode.TableRowGroup: + return CreateDisplay(CssKeywords.Block, DisplayMode.Block); + case DisplayMode.InlineTable: + return CreateDisplay(CssKeywords.Table, DisplayMode.Table); + case DisplayMode.InlineFlex: + return CreateDisplay(CssKeywords.Flex, DisplayMode.Flex); + case DisplayMode.InlineGrid: + return CreateDisplay(CssKeywords.Grid, DisplayMode.Grid); + case DisplayMode.ListItem when mode.CssText.StartsWith(CssKeywords.Inline + " ", StringComparison.OrdinalIgnoreCase): + return CreateDisplay(mode.CssText.Substring(CssKeywords.Inline.Length + 1), DisplayMode.ListItem); + case DisplayMode.Ruby when mode.CssText.Isi(CssKeywords.Ruby): + return CreateDisplay(String.Concat(CssKeywords.Block, " ", CssKeywords.Ruby), DisplayMode.Ruby); + default: + return null; + } + } + + private static ICssValue CreateDisplay(String keyword, DisplayMode mode) => new CssConstantValue(keyword, mode); } } diff --git a/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs b/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs index 2660be07..90b4c4d7 100644 --- a/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs +++ b/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs @@ -99,9 +99,10 @@ private static ICssStyleDeclaration GetComputedDeclarations(IStyleCollection sty while (nodes.Count > 0) { - var explicitStyle = styles.ComputeExplicitStyle(nodes.Pop()); + var node = nodes.Pop(); + var explicitStyle = styles.ComputeExplicitStyle(node); var context = new CssComputeContext(styles.Device, ctx, explicitStyle, parent); - parent = explicitStyle.PrepareComputedDeclarations(parent!, context).Compute(context); + parent = explicitStyle.PrepareComputedDeclarations(parent!, context).Compute(context).Blockify(node); } return parent!; @@ -163,7 +164,7 @@ internal static ICssStyleDeclaration ComputeDeclarationsWithParent(this IStyleCo var ctx = element.Owner?.Context; var explicitStyle = styles.ComputeExplicitStyle(element); var context = new CssComputeContext(styles.Device, ctx, explicitStyle, parentComputedStyle); - return explicitStyle.PrepareComputedDeclarations(parentComputedStyle, context).Compute(context); + return explicitStyle.PrepareComputedDeclarations(parentComputedStyle, context).Compute(context).Blockify(element); } #endregion diff --git a/src/AngleSharp.Css/Factories/CssPseudoElementSelectorFactory.cs b/src/AngleSharp.Css/Factories/CssPseudoElementSelectorFactory.cs new file mode 100644 index 00000000..b96e990a --- /dev/null +++ b/src/AngleSharp.Css/Factories/CssPseudoElementSelectorFactory.cs @@ -0,0 +1,39 @@ +#nullable disable +namespace AngleSharp.Css +{ + using AngleSharp.Css.Dom; + using System; + + /// + /// Decorates another pseudo-element selector factory with the pseudo-elements + /// that are supported by AngleSharp.Css, but not recognized by the decorated one. + /// + sealed class CssPseudoElementSelectorFactory : IPseudoElementSelectorFactory + { + /// + /// The name of the `::placeholder` pseudo-element (CSS Pseudo-Elements 4 §4.3). + /// + public static readonly String Placeholder = "placeholder"; + + private static readonly ISelector PlaceholderSelector = new CssPseudoElementSelector(Placeholder); + + private readonly IPseudoElementSelectorFactory _inner; + + public CssPseudoElementSelectorFactory(IPseudoElementSelectorFactory inner) + { + _inner = inner; + } + + public ISelector Create(String name) + { + var selector = _inner.Create(name); + + if (selector is null && name.Equals(Placeholder, StringComparison.OrdinalIgnoreCase)) + { + return PlaceholderSelector; + } + + return selector; + } + } +} diff --git a/src/AngleSharp.Css/Factories/DefaultPseudoElementFactory.cs b/src/AngleSharp.Css/Factories/DefaultPseudoElementFactory.cs index 54a760b0..93d63253 100644 --- a/src/AngleSharp.Css/Factories/DefaultPseudoElementFactory.cs +++ b/src/AngleSharp.Css/Factories/DefaultPseudoElementFactory.cs @@ -3,6 +3,7 @@ namespace AngleSharp.Css { using AngleSharp.Css.Dom; using AngleSharp.Dom; + using AngleSharp.Html.Dom; using System; using System.Collections.Generic; @@ -23,8 +24,20 @@ public class DefaultPseudoElementFactory : IPseudoElementFactory { PseudoElementNames.Before, element => new PseudoElement(element, PseudoElementNames.Before) }, { PseudoElementNames.After, element => new PseudoElement(element, PseudoElementNames.After) }, { PseudoElementNames.Slotted, element => new PseudoElement(element, PseudoElementNames.Slotted) }, + { CssPseudoElementSelectorFactory.Placeholder, CreatePlaceholder }, }; + private static IPseudoElement CreatePlaceholder(IElement host) + { + // Only an input or a textarea with placeholder text has a placeholder. + if ((host is IHtmlInputElement || host is IHtmlTextAreaElement) && host.HasAttribute(AttributeNames.Placeholder)) + { + return new PseudoElement(host, CssPseudoElementSelectorFactory.Placeholder); + } + + return null; + } + /// /// Registers a new creator for the specified pseudo type. /// Throws an exception if another creator for the given diff --git a/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs b/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs index f42f1540..3dc307ff 100644 --- a/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs +++ b/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs @@ -93,7 +93,7 @@ private ElementRenderNode RenderElement( } var computeContext = new CssComputeContext(collection.Device, _context, explicitStyle, parentComputedStyle); - var computedStyle = explicitStyle.PrepareComputedDeclarations(parentComputedStyle!, computeContext).Compute(computeContext); + var computedStyle = explicitStyle.PrepareComputedDeclarations(parentComputedStyle!, computeContext).Compute(computeContext).Blockify(element); var children = new List(); var node = new ElementRenderNode(element, parent, children, specifiedStyle, computedStyle); diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 627d54f6..c4e1a6fd 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ Extends the CSSOM from the core AngleSharp library. AngleSharp.Css - 1.2.0 + 1.2.1 enable latest true