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
19 changes: 16 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,12 @@ on: [push, pull_request]

env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
DOCS_PATH: ${{ secrets.DOCS_PATH }}
DOCS_BRANCH: ${{ secrets.DOCS_BRANCH }}
# Snapshot baselines are per-platform and committed; a missing one is a gap, never a pass.
ANGLESHARP_SNAPSHOT_STRICT: 1
ANGLESHARP_VERSION: 1.8.6
ANGLESHARP_CSS_VERSION: 1.2.2
ANGLESHARP_VERSION: 1.9.0
ANGLESHARP_CSS_VERSION: 1.3.1

jobs:
can_document:
Expand Down Expand Up @@ -99,6 +98,9 @@ jobs:
package:
needs: [test]
runs-on: windows-2022
permissions:
contents: write # create the GitHub release
id-token: write # request the OIDC token for NuGet trusted publishing

steps:
- uses: actions/checkout@v5
Expand All @@ -110,7 +112,18 @@ jobs:
8.0.x
10.0.x

# Exchanges the GitHub OIDC token for a short-lived (1 hour) NuGet API key.
# Requires a trusted publishing policy on nuget.org for this repository and workflow file.
- name: NuGet login
id: nuget_login
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/devel')
uses: NuGet/login@v1
with:
user: ${{ secrets.NUGET_USER }}

- name: Build
env:
NUGET_API_KEY: ${{ steps.nuget_login.outputs.NUGET_API_KEY }}
run: |
if ($env:GITHUB_REF -eq "refs/heads/main") {
.\build.ps1 -Target Publish -AngleSharpVersion $env:ANGLESHARP_VERSION -AngleSharpCssVersion $env:ANGLESHARP_CSS_VERSION
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/update-snapshots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ permissions:
contents: write

env:
ANGLESHARP_VERSION: 1.8.6
ANGLESHARP_CSS_VERSION: 1.2.2
ANGLESHARP_VERSION: 1.9.0
ANGLESHARP_CSS_VERSION: 1.3.1

jobs:
# Keep this matrix identical to the `test` matrix in ci.yml, images included - a baseline
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,14 @@ Upstream status: `font-variant-east-asian` was unparsed; fixed with tests in the
- **Paint order = layout order.** `OrderChildrenForPainting` decides the order children are *laid out* too (it threads `cursorY`). Do not reorder it to fix paint stacking; relocate emitted commands instead (`MoveRangeToEnd`, as `position: sticky` does).
- **An authored `height` is the height** of block and flex containers; in-flow content overflows it (only `min-height`/`max-height` clamp it). Auto heights come from the laid-out content.
- **`box-sizing`** is converted in exactly one place, `ResolveAuthoredDimension`; everything downstream works in content-box sizes. Flex item sizes are already converted before `ResolveFlexibleContentDimension` reads them - do not convert twice. Tables honor it too (their width goes through the same method); the HTML default `table { box-sizing: border-box }` has to come from the UA sheet.
- **Style maps.** `CreateStyleMap` builds a per-element `StyleMap` (string map plus a typed raw-length side channel used by `ParseLength`). Always go through `GetOrCreateStyleMap` (cached per render call in `LayoutContext.StyleMapCache`; the cache must never outlive one `BuildDisplayList`/`RenderToPng`/`CaptureLayoutMetrics` call or interactive state goes stale). Code writing directly into a length entry must call `StyleMap.ClearRawLength` for that key.
- **Style maps.** `CreateStyleMap` builds a per-element `StyleMap` (string map plus a typed raw-length side channel used by `ParseLength`). Always go through `GetOrCreateStyleMap` (cached per render call in `LayoutContext.StyleMapCache`; the cache must never outlive one `BuildDisplayList`/`RenderToPng`/`CaptureLayoutMetrics` call or interactive state goes stale - the one exception is `LayoutSnapshot`, see below). Code writing directly into a length entry must call `StyleMap.ClearRawLength` for that key.
- **Explicit vs computed values.** `background-image`, `grid-template-*`, gaps and `grid-column`/`grid-row` are read from the cascaded-but-uncomputed declaration (`ResolveExplicitPropertyValue` via the cached `LayoutContext.StyleCollection`), because AngleSharp.Css's `.Compute()` resolves their percentages against the viewport instead of the element's box. `width`/`height`/`min-*`/`max-*` take the explicit value only when it contains a `%`; anything else (`em`, `rem`, `vw`) needs the computed one.
- **The page.** `<html>` is laid out as an ordinary block (so `<body>`'s UA `margin: 8px`, padding, border and width apply); `context.Padding` is 0. `PropagateToViewport` moves the root's background (or `<body>`'s, if the root has none) to the canvas and removes it from that element's style map, and drops their `overflow`. `<html>`'s metrics are re-recorded as the viewport, since it is the scrolling element. Structural tests in `HtmlRendererTests` run with a prepended `body { margin: 0 }` sheet (its `ParseAsync`). The vertical-page path still lays out `<body>`'s children directly.
- **Blockification.** AngleSharp.Css blockifies the computed `display` of floats, absolutely positioned boxes and the root, so they are never inline-level; handing one to an inline formatting context would lay it out again forever. Flex items are not blockified upstream (it does not know the formatting context), so `LayoutElement` forces them to block layout itself.
- **Margin collapsing** through first children is recursive (`TryGetFirstCollapsibleChildTopMargin`); a box whose margin its parent absorbed (`suppressNextBlockTopMargin`) absorbs the whole chain below it too. Floats and out-of-flow boxes are skipped; inline-level content (atomics included) stops the chain.
- **Unset ≠ initial.** AngleSharp.Css often reports an unset property as an empty string rather than its initial value; parsers fall back to the inherited value (inherited properties) or the CSS initial value.
- Adding a new `RenderPaint` (or other) subtype: check every place that pattern-matches on the existing ones (`PaintBackground` once silently dropped `RenderImagePaint`).
- **Incremental metrics** (`HtmlRenderer.Incremental.cs`). `InteractiveHtmlRendererState.GetLayoutMetrics` keeps a `LayoutSnapshot`: the metrics plus the `LayoutContext` of the last full layout, the `LayoutElement` arguments of every out-of-flow or not rendered box (`LayoutEntry`, recorded only by a capture with `Entries`, never by trial layouts) and what each block gave its children (`ContainerEntry`). Mutations confined to such a box's subtree (inline `style`; a class/id/attribute no selector with `+`/`~`/`:has(` refers to or matches differently; child lists and text unless they flip the match of a `:has(` compound on the changed element or its ancestors, recorded per snapshot in `_relationalMatches`) re-render that subtree only (AngleSharp.Css `window.Render(element, parent)`), drop its entries from the context's caches, and lay it out again from its entry; anything else is a full layout. Outside the subtree the context's caches stay valid because nothing there changed. In-flow blocks get entries too (`IsInFlow`, with the incoming margins and the outgoing `FlowResult`), but only along a chain of ordinary block flows from the root element (`IsInFlowEntry`: the parent's `ContainerEntry.IsFlow`, no floats beside it), passed on through flex/grid items of chained containers unless baseline-aligned (`LayoutCapture.Links`); behind such an item or a `*-content` width, an entry `IsSizedByContent` and must keep its `ComputeOuterIntrinsicWidths`. Re-laying out an in-flow block must reproduce its box, its `FlowResult` (cursor, collapsed margin, text-indent, no floats left) and, when the parent took its top margin, its collapsed top margin; otherwise (or for sticky/negative-z/counter content, sticky ancestors, `::first-line`/`::first-letter` pages) `TryUpdate` escalates to the next enclosing root (at most 3 times, never the body). Outside the subtree the context's caches stay valid because nothing there changed. Two full layouts can differ in the last float bits (21.6 vs 21.599998), so equivalence tests compare in thousandths. A frame's layout becomes the snapshot (`LayOut` with the frame's scroll offset; metrics are kept as laid out plus a page-coordinate view, `Metrics`). Scrolling and pointer moves keep the snapshot: `ToViewport` maps page coordinates into the viewport for the current scroll (boxes inside fixed boxes - `IsViewportAnchored` - stay; sticky boxes, recorded as `StickyRecord`s, stick anew via `ComputeStickyOffset`). Resolving a sticky box shifts everything recorded during its layout (`LayoutCapture.RecordOrder`), not only the box. Other scroll containers move their content in the layout itself (`GetContentScrollOffset`: a translation inside the clip, `MoveScrolledContent` for the recorded metrics), so their scrolling drops the snapshot; `scrollHeight`/`scrollIntoView` add the container's scroll back. For `IDomHarness.UpdateFrame` the snapshot of a frame also records each entry's command range (`LayoutCapture.Ranges`: first command by identity plus count, which survives `PaintOrder` moves) and, per re-laid-out root since the frame (`Change`), its frame range and latest commands; `TryPatch` replays the re-layouts since the frame (`_relayouts`: range before, commands after) on a copy of the frame's display list, skipping those inside a root laid out later. A root shown since the frame is inserted where `PaintOrder` would have put it: every flow (and flex/grid container, which also records a `ContainerEntry` for the padding box its out-of-flow children are laid out against) records a `PaintLayer` (its raised children in paint order plus its last command) for its not rendered children (`RecordPaintLayer`). A re-layout inside scroll containers is moved by their scroll (`MoveScrolledContent`) like in the full layout. The snapshot also keeps its `LayoutPass` for re-layouts (dropping the subtree's entries, and the ancestors' `PercentageHeights`).
- **Flow layout.** `LayoutFlowChildren` (used by `LayoutElement` and the page itself) splits children into block-level children and runs of inline-level content. A run of bare text keeps the paragraph path (`LayoutTextNode`/`LayoutWrappedText`/`WrapText`); any run containing an element goes through `LayoutInlineFormattingContext`: the subtree is flattened into `InlinePiece`s (whitespace collapsed across element boundaries, break opportunities per piece), greedily broken into line boxes, and each line is emitted with one baseline (strut + half-leading per piece + atomic margin boxes). Inline boxes paint per line fragment; block-level boxes inside inline content get a line of their own.
- **Atomic inlines and shrink-to-fit.** Inline-blocks (and inline-flex/grid/table, form controls, `<img>`/`<svg>`) are measured by `TrialLayout`, which lays the element out into a scratch list under a temporary `LayoutCapture` (so real metrics capture is untouched) and reads its border box and last text baseline (`RecordTextBaseline`). Auto widths of atomics, floats and absolutely positioned boxes use `ResolveShrinkToFitBorderBoxWidth` (intrinsic widths from `ComputeContentIntrinsicWidths`), then `LayoutNode(..., forcedBorderBoxWidth, isAtomicInline)`. Nested inline-blocks multiply trial layouts, so keep the measurement paths cheap. A percentage `width` counts as `auto` in an intrinsic contribution (`ComputeOuterIntrinsicWidths`), since it would refer to the size being computed.
- **Pseudo-elements** proxy `LocalName`/`GetAttribute` to their host. Guard tag-based special cases (`ResolveFormControlKind`, `TryResolveImage`, `TryResolveInlineSvg`, `<svg>` checks) with `IPseudoElement`; replaced-element checks go through `IsReplacedElement`, which judges a pseudo only by an image in its own `content`. A pseudo's `TextContent` (and its host's) never includes generated content; read text from the render tree, as the inline collector does.
Expand Down
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,30 @@
# 0.9.2

Released on Sunday, November 1 2026.

- Improved geometry reads after a change confined to an out-of-flow box (e.g. a script showing a hidden dropdown with `position: absolute`, measuring it, and hiding it again): only that box is laid out again, not the whole document (dev.to's scroll handler measuring its 60 dropdowns went from 64 s to under 3 s)
- Improved geometry reads to keep the layout when the page scrolls or the pointer moves without changing the hovered element, and `scrollTop`/`scrollLeft`/`scrollY` to not lay out the document
- Improved `IDomHarness.UpdateFrame` to show hidden absolutely positioned children of flex and grid containers without a full layout, too
- Improved geometry reads after a frame of an interactive document, which are answered from the frame's layout instead of laying out the document again
- Improved geometry reads and `IDomHarness.UpdateFrame` after changes of in-flow blocks (e.g. the text of a title, the color of an article): the block is laid out again on its own when it stays the same from outside (its box, its intrinsic widths where a flex or grid item is sized by them, its margins, no floats beside the boxes after it), else the next blocks around it are tried before a full layout (dev.to: 34ms instead of 1.2s for a changed title)
- Improved laying out a box on its own to reuse what the layout resolved from the page (e.g. the page styles of inline SVG icons, which built a style collection of their own every time)
- Improved changes of the content of an element on pages with `:has()` selectors, which needed a full layout; only a change flipping the match of a `:has()` on the element or around it does now
- Fixed `getBoundingClientRect`, `getClientRects`, and `caretPositionFromPoint` ignoring the scroll position of the page; boxes in fixed boxes stay, and sticky boxes stick for it
- Fixed the content of a stuck sticky box (e.g. the links of a sticky header) being hit tested and measured where it would be without sticking
- Fixed geometry reads measuring text with another measurer than painting (e.g. images in a line off by a tenth of a pixel)
- Fixed `right` and `bottom` of fixed boxes being ignored (e.g. a button fixed at the bottom right was placed at the top left)
- Fixed absolutely positioned and fixed boxes of an auto height placed by `bottom` staying at the top; they are measured first
- Fixed scroll containers other than the page (`overflow: auto`, `scroll`, `hidden`) not moving their content when scrolled, in painting, hit testing, and geometry (`getBoundingClientRect`, `scrollIntoView`)
- Fixed a box laid out on its own inside a scrolled container (other than the page) being measured where it would be unscrolled
- Fixed changes of style sheets (the text of a `style` element, adding or removing `style` and `link` elements) inside a box out of the flow being taken for changes of that box only
- Fixed content with a negative `z-index` inside a scroll container (or any box clipping its overflow) being neither clipped nor scrolled by it
- Fixed absolutely positioned children of grid containers being laid out as grid items (taking a cell and sizing the tracks); they are placed against the grid's padding box, or the grid area of their `grid-column`/`grid-row`/`grid-area` lines (CSS Grid 1 §9.1)
- Fixed grid items with a `z-index` painting in document order
- Fixed `scrollHeight` and `scrollWidth` counting the content clipped by scroll containers inside, and boxes in fixed boxes
- Fixed the text of an absolutely positioned box moving the baseline of its inline-block (e.g. a dropdown inside an inline-block pushing the following lines down)
- Fixed checkboxes, radios and other form controls as flex items in a row (e.g. a checkbox beside its label in a `display: flex` label) getting no width, so they were not painted
- Added `IDomHarness.UpdateFrame`, bringing the last frame up to date without a layout of the whole document when the changes since were confined to boxes out of the flow: their commands are replaced, removed, or inserted where a box shown since paints (e.g. on dev.to showing a profile dropdown or changing the fixed header takes about 50 ms instead of 1.1 s, with the same pixels), and a frame stays as it is when they were not rendered then and are not now (e.g. a dropdown shown, measured and hidden again)

# 0.9.1

Released on Friday, October 30 2026.
Expand Down
2 changes: 1 addition & 1 deletion build/Build.cs
Original file line number Diff line number Diff line change
Expand Up @@ -220,9 +220,9 @@ protected override void OnBuildInitialized()
.DependsOn(RunUnitTests)
.Executes(() =>
{
// In CI this is the short-lived key issued by NuGet trusted publishing (NuGet/login).
var apiKey = Environment.GetEnvironmentVariable("NUGET_API_KEY");


if (apiKey.IsNullOrEmpty())
{
throw new BuildAbortedException("Could not resolve the NuGet API key.");
Expand Down
2 changes: 1 addition & 1 deletion src/AngleSharp.Renderer.Docs/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@anglesharp/renderer",
"version": "0.9.0",
"version": "0.9.2",
"preview": true,
"description": "The doclet for the AngleSharp.Renderer documentation.",
"keywords": [
Expand Down
22 changes: 22 additions & 0 deletions src/AngleSharp.Renderer.Tests/ElementCssomViewExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,28 @@ public async Task GetBoundingClientRect_AndClientMetrics_AreDerivedFromBorderBox
Assert.Equal(30, element.GetScrollHeight());
}

[Fact]
public async Task AbsolutelyPositionedContent_DoesNotMoveTheBaselineOfItsInlineBlock()
{
var document = await ParseAsync("""
<html>
<head><style>html, body { margin: 0; padding: 0; } p { margin: 0; }</style></head>
<body>
<p><span style="display:inline-block; position:relative">Card
<span id="dropdown" style="position:absolute; top:100%; left:0; display:none; font-size:30px">One<br>Two</span></span></p>
<div id="after">After</div>
</body>
</html>
""");
var after = document.QuerySelector("#after")!;
var top = after.GetBoundingClientRect().Top;

document.QuerySelector("#dropdown")!.SetAttribute("style", "position:absolute; top:100%; left:0; font-size:30px");
document.Context.GetDomHarness().PaintToPng();

Assert.Equal(top, after.GetBoundingClientRect().Top, 3);
}

[Fact]
public async Task GetClientRects_ReturnsEmpty_WhenElementHasNoLayoutBox()
{
Expand Down
17 changes: 17 additions & 0 deletions src/AngleSharp.Renderer.Tests/FormControlTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,23 @@ public async Task CheckedControls_UseTheDefaultAccentColor(string type)
Assert.Contains(displayList.Commands.OfType<FillRectCommand>(), command => command.Color.Equals(DefaultAccent));
}

[Theory]
[InlineData("<div style=\"display:flex\">{0}<span>Label</span></div>")]
[InlineData("<label style=\"display:flex; align-items:center\">{0}<span>Label</span></label>")]
[InlineData("<div style=\"display:flex\">{0}Label</div>")]
[InlineData("<div style=\"display:inline-flex\">{0}</div>")]
[InlineData("<div style=\"display:flex; flex-direction:column\">{0}<span>Label</span></div>")]
public async Task CheckedControls_ArePaintedAsFlexItems(string container)
{
// found in the browser's settings page: checkboxes beside their label in a flex row vanished
foreach (var type in new[] { "checkbox", "radio" })
{
var displayList = await LayoutAsync(string.Format(container, $"<input type=\"{type}\" checked style=\"margin:0 12px 0 0\">"));

Assert.Contains(displayList.Commands.OfType<FillRectCommand>(), command => command.Color.Equals(DefaultAccent));
}
}

[Theory]
[InlineData("checkbox", "checked")]
[InlineData("radio", "checked")]
Expand Down
Loading
Loading