Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,24 @@
# 1.3.0

Released on Wednesday, October 28 2026.

- Improved the render tree to inherit declarations from the parent's style instead of copying them to every element (e.g. thousands of custom properties on `:root`)
- Improved merging declarations in the cascade, which was quadratic in the number of declarations
- Improved blockifying (e.g. `position: absolute` or `float`) to not merge all inherited declarations into the element's computed style
- Improved the cascade to also group rules by an attribute their subject requires (e.g. `[data-color-mode]`) and by the arguments of `:is()`, `:where()`, and `:matches()`, and to skip rules with an invalid selector
- Improved substituting variables by reusing the parsed shorthand (e.g. `border: var(--w) solid var(--c)`) and the tokens of custom properties across elements
- Improved matching `::before` and `::after` by grouping the rules targeting pseudo-elements by the id, class, or tag name of the element they belong to, instead of matching all of them against every element
- Improved reading a property of a computed style that is neither declared nor inherited (e.g. `border-bottom-width`), which looked through the declarations of all ancestors, and of the shorthands containing it, only to discard what it found
- Improved serializing lists of a single entry or two (e.g. one background layer), which needed no buffer
- Improved evaluating media queries, which serialized and parsed the value of every feature (e.g. `(min-width: 768px)`) again for each style computation
- Improved the cascade to leave out a style sheet linked again later (e.g. dev.to links each of its style sheets twice), which the later copy overrides anyway
- Fixed a declaration starting with `*` (the IE7 hack `*zoom: 1`) being taken for a nested rule that swallowed the following declarations or the rest of the style sheet; it is dropped up to its semicolon
- Fixed a `background` shorthand with a single position keyword followed by another value (e.g. `background: url(i.svg) center no-repeat` or `center red`) being dropped as invalid
- Fixed forms having a vertical margin (`1.12em`), which only quirks mode gives them
- Added `CssParserOptions.IsNotResolvingRelativeUrls` to prevent resolving the relative URLs in linked and imported style sheets
- Added `font-variant-east-asian` (`normal`, `jis78`/`jis83`/`jis90`/`jis04`/`simplified`/`traditional`, `full-width`/`proportional-width`, `ruby`), which used to be dropped as an unknown property
- Added `innerWidth`, `innerHeight`, and `devicePixelRatio` to the window, taken from the render device

# 1.2.2

Released on Sunday, October 25 2026.
Expand Down
2 changes: 1 addition & 1 deletion src/AngleSharp.Css.Docs/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@anglesharp/css",
"version": "1.2.2",
"version": "1.3.0",
"preview": true,
"description": "The doclet for the AngleSharp.Css documentation.",
"keywords": [
Expand Down
13 changes: 13 additions & 0 deletions src/AngleSharp.Css.Tests/Declarations/CssBackgroundProperty.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,19 @@ public void CssBackgroundWithCurrentColorLegal(string snippet, string expected)
Assert.AreEqual(expected, property.Value);
}

[TestCase("background: center no-repeat", "center", "no-repeat")]
[TestCase("background: red center no-repeat", "center", "no-repeat")]
[TestCase("background: center red", "center", "initial")]
[TestCase("background: url(i.svg) center center no-repeat", "center", "no-repeat")]
[TestCase("background: left top repeat-x", "left top", "repeat-x")]
public void CssBackgroundPositionFollowedByOtherValuesLegal(string snippet, string position, string repeat)
{
// a keyword after a single position keyword used to be taken for its second component
var style = ParseDeclarations(snippet);
Assert.AreEqual(position, style.GetPropertyValue("background-position"));
Assert.AreEqual(repeat, style.GetPropertyValue("background-repeat"));
}

[Test]
public void CssBackgroundSizeCoverTest()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ private static void AssertValue(string declaration, string expected)
[TestCase("font-variant-numeric: slashed-zero oldstyle-nums", "oldstyle-nums slashed-zero")]
[TestCase("font-variant-numeric: lining-nums oldstyle-nums", null)]
[TestCase("font-variant-ligatures: no-common-ligatures contextual", "no-common-ligatures contextual")]
[TestCase("font-variant-east-asian: ruby full-width jis04", "jis04 full-width ruby")]
[TestCase("font-variant-east-asian: simplified", "simplified")]
[TestCase("font-variant-east-asian: normal", "normal")]
[TestCase("font-variant-east-asian: jis78 traditional", null)]
[TestCase("font-variant-east-asian: full-width proportional-width", null)]
[TestCase("text-indent: each-line 5% hanging", "5% hanging each-line")]
[TestCase("text-indent: hanging", null)]
[TestCase("text-overflow: clip ellipsis", "clip ellipsis")]
Expand Down
15 changes: 15 additions & 0 deletions src/AngleSharp.Css.Tests/Extensions/Nesting.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,5 +135,20 @@ public void ReversedSelectorNesting()

Assert.AreEqual("rgba(0, 128, 0, 1)", style.GetColor());
}

// the IE hacks of old style sheets, found on www.florian-rappl.de: a declaration starting
// with `*` looks like a nested rule, whose prelude ends at `;` or `}` and is dropped,
// instead of taking the rest of the style sheet with it
[TestCase("a{color:red;*zoom:1}b{color:blue}", "a { color: rgba(255, 0, 0, 1) }", "b { color: rgba(0, 0, 255, 1) }")]
[TestCase("a{*zoom:1;color:red}b{color:blue}", "a { color: rgba(255, 0, 0, 1) }", "b { color: rgba(0, 0, 255, 1) }")]
[TestCase("a{*display:inline;*zoom:1;color:red;}b{color:blue}", "a { color: rgba(255, 0, 0, 1) }", "b { color: rgba(0, 0, 255, 1) }")]
public void InvalidNestedRuleIsDroppedUpToItsSemicolon(string css, string first, string second)
{
var sheet = ParseStyleSheet(css);

Assert.AreEqual(2, sheet.Rules.Length);
Assert.AreEqual(first, sheet.Rules[0].CssText);
Assert.AreEqual(second, sheet.Rules[1].CssText);
}
}
}
35 changes: 35 additions & 0 deletions src/AngleSharp.Css.Tests/Extensions/ViewportWindow.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#nullable disable
namespace AngleSharp.Css.Tests.Extensions
{
using AngleSharp.Dom;
using AngleSharp.Html.Parser;
using NUnit.Framework;

[TestFixture]
public class ViewportWindowTests
{
[Test]
public void InnerSizeIsTheViewportOfTheRenderDevice()
{
var window = CreateWindow(new DefaultRenderDevice { ViewPortWidth = 1280, ViewPortHeight = 800 });
Assert.AreEqual(1280, window.InnerWidth());
Assert.AreEqual(800, window.InnerHeight());
}

[Test]
public void DevicePixelRatioFollowsTheResolutionOfTheRenderDevice()
{
Assert.AreEqual(1.0, CreateWindow(new DefaultRenderDevice()).DevicePixelRatio());
Assert.AreEqual(2.0, CreateWindow(new DefaultRenderDevice { Resolution = 192 }).DevicePixelRatio());
}

private static IWindow CreateWindow(IRenderDevice device)
{
var config = Configuration.Default.WithCss().WithRenderDevice(device);
var context = BrowsingContext.New(config);
var parser = context.GetService<IHtmlParser>();
var document = parser.ParseDocument("<!doctype html><title>Example</title>");
return document.DefaultView;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ public void MarkIsHighlighted(string property, string expected)
Assert.AreEqual(expected, Computed("<p><mark id=target>x</mark></p>", property));
}

[TestCase("margin-top")]
[TestCase("margin-bottom")]
public void FormHasNoMargins(string property)
{
// only a quirks mode form has a bottom margin; forms in grids (e.g. dev.to's sign-in
// buttons) were spaced apart
Assert.AreEqual(Computed("<!doctype html><div id=target>x</div>", property), Computed("<!doctype html><form id=target>x</form>", property));
}

[TestCase("margin-top")]
[TestCase("margin-bottom")]
public void PreHasBlockMargins(string property)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ public void PaintValuesAreKept(string property, string value, string expected) =
[TestCase("font-style", "oblique 10deg", "oblique 10deg")]
[TestCase("font-variant-numeric", "tabular-nums", "tabular-nums")]
[TestCase("font-variant-ligatures", "none", "none")]
[TestCase("font-variant-east-asian", "proportional-width", "proportional-width")]
[TestCase("overflow-wrap", "anywhere", "anywhere")]
[TestCase("word-wrap", "anywhere", "anywhere")]
[TestCase("white-space", "break-spaces", "break-spaces")]
Expand Down
76 changes: 76 additions & 0 deletions src/AngleSharp.Css.Tests/Styling/DuplicateStyleSheetTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#nullable disable
namespace AngleSharp.Css.Tests.Styling
{
using AngleSharp.Css.Dom;
using AngleSharp.Css.Tests.Mocks;
using AngleSharp.Dom;
using NUnit.Framework;
using System.Linq;
using System.Threading.Tasks;

/// <summary>
/// Pages like dev.to link their style sheets twice. The cascade leaves out the earlier copy of
/// a sheet linked again, which the later copy overrides anyway, instead of matching every
/// element against both.
/// </summary>
[TestFixture]
public class DuplicateStyleSheetTests
{
private static Task<IDocument> LoadAsync(string body)
{
var requester = new MockRequester();
requester.BuildResponse(request =>
{
if (request.Address.Path.EndsWith("a.css"))
{
return "p { color: red } .x { color: blue }";
}
else if (request.Address.Path.EndsWith("b.css"))
{
return "p { color: green } p.x { color: green }";
}
else if (request.Address.Path.EndsWith("layers.css"))
{
return "@layer one { p { color: red } }";
}

return null;
});
var config = Configuration.Default.WithCss().WithMockRequester(requester);
return BrowsingContext.New(config).OpenAsync(m => m.Content("<!doctype html>" + body).Address("http://localhost/"));
}

private static string ColorOf(IDocument document, string id) =>
document.DefaultView.GetComputedStyle(document.GetElementById(id)).GetColor();

[Test]
public async Task SheetLinkedAgainOverridesTheSheetsInBetween()
{
var document = await LoadAsync("<link rel=stylesheet href=a.css><link rel=stylesheet href=b.css><link rel=stylesheet href=a.css><p id=t>A</p><p id=u class=x>B</p>");

Assert.AreEqual("rgba(255, 0, 0, 1)", ColorOf(document, "t"));
// a higher specificity in between still wins
Assert.AreEqual("rgba(0, 128, 0, 1)", ColorOf(document, "u"));
}

[Test]
public async Task SheetLinkedTwiceIsMatchedOnce()
{
var document = await LoadAsync("<link rel=stylesheet href=a.css><link rel=stylesheet href=b.css><link rel=stylesheet href=a.css><link rel=stylesheet href=b.css><p id=t>A</p>");
var rules = document.DefaultView.GetStyleCollection(new DefaultRenderDevice()).OfType<ICssStyleRule>().Count(m => m.Owner?.Href is not null);

Assert.AreEqual(4, rules);
Assert.AreEqual("rgba(0, 128, 0, 1)", ColorOf(document, "t"));
}

[Test]
public async Task SheetWithLayersLinkedTwiceIsKept()
{
var document = await LoadAsync("<link rel=stylesheet href=layers.css><link rel=stylesheet href=layers.css><p id=t>A</p>");
var rules = document.DefaultView.GetStyleCollection(new DefaultRenderDevice()).OfType<ICssStyleRule>().Count(m => m.Owner?.Href is not null);

Assert.AreEqual(2, rules);
Assert.AreEqual("rgba(255, 0, 0, 1)", ColorOf(document, "t"));
}
}
}
42 changes: 42 additions & 0 deletions src/AngleSharp.Css.Tests/Styling/HtmlCssIntegration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -523,5 +523,47 @@ public void MediaRuleIsNotCalculatedIfScreenIsNotWideEnough()
var style = document.Body.ComputeCurrentStyle();
Assert.AreEqual("rgba(255, 0, 0, 1)", style.GetColor());
}

[Test]
public async Task RelativeUrlsOfLinkedStyleSheetsResolveAgainstTheirAddress()
{
// found on www.florian-rappl.de: /Content/style refers to img/title.png in /Content
var files = new Dictionary<String, String>
{
{ "index.html", "<link rel=stylesheet href='/Content/style'><style>p { background-image: url(img/inline.png) }</style><div id=t></div><p></p>" },
{ "Content/style", "#t { background-image: url(img/title.png), url('/abs.png'), url(data:image/png;base64,AA==) } @font-face { font-family: X; src: url(\"fonts/x.ttf\") }" },
};
var config = Configuration.Default
.With(new TestServerRequester(files))
.WithDefaultLoader(new LoaderOptions { IsResourceLoadingEnabled = true })
.WithCss();
var document = await BrowsingContext.New(config).OpenAsync("http://localhost/index.html");
await Task.Delay(100);
var linked = document.QuerySelector<IHtmlLinkElement>("link").Sheet as ICssStyleSheet;
var inline = document.QuerySelector<IHtmlStyleElement>("style").Sheet as ICssStyleSheet;

Assert.AreEqual("url(\"http://localhost/Content/img/title.png\"), url(\"http://localhost/abs.png\"), url(\"data:image/png;base64,AA==\")", ((ICssStyleRule)linked.Rules[0]).Style.GetPropertyValue("background-image"));
Assert.AreEqual("url(\"http://localhost/Content/fonts/x.ttf\")", ((ICssFontFaceRule)linked.Rules[1]).Source);
Assert.AreEqual("url(\"img/inline.png\")", ((ICssStyleRule)inline.Rules[0]).Style.GetPropertyValue("background-image"));
}

[Test]
public async Task RelativeUrlsOfLinkedStyleSheetsStayAsWrittenWhenNotResolving()
{
var files = new Dictionary<String, String>
{
{ "index.html", "<link rel=stylesheet href='/Content/style'>" },
{ "Content/style", "#t { background-image: url(img/title.png) }" },
};
var config = Configuration.Default
.With(new TestServerRequester(files))
.WithDefaultLoader(new LoaderOptions { IsResourceLoadingEnabled = true })
.WithCss(new CssParserOptions { IsNotResolvingRelativeUrls = true });
var document = await BrowsingContext.New(config).OpenAsync("http://localhost/index.html");
await Task.Delay(100);
var linked = document.QuerySelector<IHtmlLinkElement>("link").Sheet as ICssStyleSheet;

Assert.AreEqual("url(\"img/title.png\")", ((ICssStyleRule)linked.Rules[0]).Style.GetPropertyValue("background-image"));
}
}
}
Loading
Loading