From dbf5e32016286d26ea4040faaa136f5e772cdcdf Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Thu, 10 Sep 2026 14:08:23 +0200 Subject: [PATCH 1/9] Updated version --- CHANGELOG.md | 6 ++++++ src/AngleSharp.Renderer.Docs/package.json | 2 +- src/Directory.Build.props | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 848084e..896367c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# 0.5.0 + +Released on ?. + +- Updated to use the AngleSharp.Css gradient model (#10) + # 0.4.0 Released on Thursday, September 10 2026. diff --git a/src/AngleSharp.Renderer.Docs/package.json b/src/AngleSharp.Renderer.Docs/package.json index fd7ef9b..feecb99 100644 --- a/src/AngleSharp.Renderer.Docs/package.json +++ b/src/AngleSharp.Renderer.Docs/package.json @@ -1,6 +1,6 @@ { "name": "@anglesharp/renderer", - "version": "0.4.0", + "version": "0.5.0", "preview": true, "description": "The doclet for the AngleSharp.Renderer documentation.", "keywords": [ diff --git a/src/Directory.Build.props b/src/Directory.Build.props index ce2f514..14fb6d4 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ Adds rendering functionality to the core AngleSharp library. AngleSharp.Renderer - 0.4.0 + 0.5.0 enable latest true From 0af62f16cd39989264cca074432f7648a806c651 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Thu, 10 Sep 2026 15:18:51 +0200 Subject: [PATCH 2/9] Added support for grid track sizing #7 --- AGENTS.md | 30 +- CHANGELOG.md | 1 + .../HtmlRendererTests.cs | 155 ++- .../VisualConformanceTests.cs | 32 + ...tracks-with-fr-repeat-and-minmax.macos.png | Bin 0 -> 423 bytes src/AngleSharp.Renderer/HtmlRenderer.cs | 1036 +++++++++-------- 6 files changed, 740 insertions(+), 514 deletions(-) create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/sizes-grid-tracks-with-fr-repeat-and-minmax.macos.png diff --git a/AGENTS.md b/AGENTS.md index eee098a..956917d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,7 +144,7 @@ CSS `white-space` (`normal`/`nowrap`/`pre`/`pre-wrap`/`pre-line`/`break-spaces`) Collapsing/preserving whitespace is only half the feature; the other half is what a preserved `\n` and a suppressed wrap actually *do* to layout, and the two text-layout functions handle it differently because of how differently they are already structured. `LayoutWrappedText` (a block element's own direct text content, or a lone all-inline leaf element with only text - the common case `
`/`

`/etc. actually appear in) gained `WrapTextRespectingWhiteSpace`: it splits the already-normalized text on `\n` into paragraphs first (a no-op split for `normal`/`nowrap`, which never have a literal `\n` surviving `NormalizeWhitespace` in the first place), then either treats each paragraph as one unwrapped line (`nowrap`/`pre`) or still word-wraps it against `maxWidth` via the existing `WrapText` (every other mode) - an empty paragraph (a blank line from consecutive forced breaks) still becomes its own empty line entry rather than being dropped, so blank lines in preserved text still occupy the vertical space a browser would give them, while `LayoutWrappedText`'s own per-line loop skips measuring/painting a `DrawText` command for a line with nothing in it (cursorY still advances by a full line-height first, so the blank row's height is still reserved). `LayoutInlineTextRun` (mixed inline content sharing a line with sibling text/elements - a `` interleaved with plain text and other inline elements) only gets the narrower half of this: `nowrap`/`pre` still suppress its own width-driven wrap-to-new-line check (`IsNoWrapWhiteSpace`, the same predicate `LayoutWrappedText` uses), but multi-space preservation and explicit forced breaks are a deliberate, documented scope cut here specifically - this function's word-by-word model (`Split(' ', RemoveEmptyEntries)`, one `DrawText` command per word, a fixed single-space-width gap between them) has no way to represent either even if the text handed to it preserved them, so `NormalizeWhitespaceForInlineRun` (used only at this function's own two call sites) still collapses runs of horizontal whitespace exactly like `normal` would and always neutralizes any literal `\n` down to a plain space before the text ever reaches this function, rather than handing it a character it cannot lay out. `

` gets `white-space: pre` from AngleSharp.Css's own UA stylesheet already (the same targeted-rule mechanism that gives `
    `/`
      ` their `list-style-type` default), not something this renderer has to inject itself. -Current behavior includes block layout, margins, padding, borders, floats, inline-block, relative/fixed/absolute positioning, z-index ordering, outlines, text styling, text alignment, line-height, letter-spacing, text-indent, vertical-align, `white-space` collapsing/preservation, border-radius, box-shadow, text-shadow, list-item markers, overflow clipping, page-level scroll offsets, image and gradient backgrounds, form controls (including a focused text input's animated caret), 2D `transform`/`transform-origin`, CSS `filter`, CSS `opacity`, real `:hover` matching and CSS `transition`/`animation`/`@keyframes` for interactive documents, and generic font-family handling. +Current behavior includes block layout, margins, padding, borders, floats, inline-block, relative/fixed/absolute positioning, z-index ordering, outlines, text styling, text alignment, line-height, letter-spacing, text-indent, vertical-align, `white-space` collapsing/preservation, border-radius, box-shadow, text-shadow, list-item markers, overflow clipping, page-level scroll offsets, image and gradient backgrounds, form controls (including a focused text input's animated caret), flexbox, CSS Grid (auto-placement plus `fr`/`repeat()`/`minmax()` track sizing), 2D `transform`/`transform-origin`, CSS `filter`, CSS `opacity`, real `:hover` matching and CSS `transition`/`animation`/`@keyframes` for interactive documents, and generic font-family handling. SVG support: `` (including `data:` URIs) and inline `` markup both render, through two loading paths that converge on the same DOM-walking rasterizer (`Skia/Svg/`) - no third-party SVG parser is involved anywhere. An `` SVG source is sniffed and rasterized inside `TryLoadImageResource`, exactly where a PNG/JPEG source is decoded: `SvgRasterizer.TryRasterizeMarkup` parses the bytes with AngleSharp's own HTML/foreign-content parser (wrapped in a throwaway `` shell) and is cached per-document by URL like any other image. Inline `` has no URL and, more importantly, is already sitting in the host document's DOM - `SvgRasterizer.TryRasterizeElement` walks that element directly and is cached per-element in `s_inlineSvgCacheByElement`; it is never serialized back to text and re-parsed. `LayoutElement` empties `orderedChildren` for an `` root so its foreign-namespaced children are never walked as HTML flow content; both `` and inline `` are treated as a single replaced element. Rasterization always happens at the SVG's own natural (`viewBox`/`width`/`height`) size oversampled by a fixed factor (`SvgRasterizer.OversampleFactor`), not at the resolved CSS box size - the loader runs before CSS sizing is known, so this is a deliberate blur-vs-memory tradeoff rather than a per-render-size cache. @@ -152,13 +152,23 @@ Supported elements: `rect`/`circle`/`ellipse`/`line`/`polyline`/`polygon`/`path` Not implemented, and silently skipped rather than approximated: `feFlood`/`feComposite`/`feTurbulence`/`feDisplacementMap`/`feTile`/`feImage`/`feComponentTransfer`/`feConvolveMatrix`/`feDiffuseLighting`/`feSpecularLighting`/`feMorphology` filter primitives (pass their input through unchanged), a `filter`'s own region clipping (`x`/`y`/`width`/`height`/`filterUnits` - the effect is not clipped to the nominal -10%/120% region), `SourceAlpha` as distinct from `SourceGraphic` in a filter chain, percentages in `points` (the SVG spec disallows them there too), text measurement contributing to `SvgGeometry.ComputeBounds` (a ``/gradient/pattern bounding box that depends on text extent falls back to "no bounds", which skips the mask-region clip rather than guessing), and CSS attribute selectors/pseudo-classes/child/sibling combinators in an SVG ` +
      +
      +
      +
      + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 200, + ViewPortHeight = 120, + FontSize = 16f, + }); + + var childBackgrounds = displayList.Commands + .OfType() + .Where(command => command.Rect.Height == 20f && command.Rect.Width < 150f) + .OrderBy(command => command.Rect.X) + .ToArray(); + + Assert.Equal(2, childBackgrounds.Length); + Assert.Equal(0f, childBackgrounds[0].Rect.X); + Assert.Equal(50f, childBackgrounds[0].Rect.Width); + Assert.Equal(50f, childBackgrounds[1].Rect.X); + Assert.Equal(100f, childBackgrounds[1].Rect.Width); + } + + [Fact] + public async Task BuildDisplayList_ExpandsRepeatFunctionIntoFixedTracks() + { + var document = await ParseAsync(""" + +
      +
      +
      +
      +
      + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 200, + ViewPortHeight = 120, + FontSize = 16f, + }); + + var childBackgrounds = displayList.Commands + .OfType() + .Where(command => command.Rect.Height == 20f && command.Rect.Width < 150f) + .OrderBy(command => command.Rect.X) + .ToArray(); + + Assert.Equal(3, childBackgrounds.Length); + Assert.Equal(0f, childBackgrounds[0].Rect.X); + Assert.Equal(40f, childBackgrounds[0].Rect.Width); + Assert.Equal(45f, childBackgrounds[1].Rect.X); + Assert.Equal(40f, childBackgrounds[1].Rect.Width); + Assert.Equal(90f, childBackgrounds[2].Rect.X); + Assert.Equal(40f, childBackgrounds[2].Rect.Width); + } + + [Fact] + public async Task BuildDisplayList_ClampsMinMaxTrackToItsFractionalMaximum() + { + var document = await ParseAsync(""" + +
      +
      +
      +
      + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 200, + ViewPortHeight = 120, + FontSize = 16f, + }); + + var childBackgrounds = displayList.Commands + .OfType() + .Where(command => command.Rect.Height == 20f && command.Rect.Width < 150f) + .OrderBy(command => command.Rect.X) + .ToArray(); + + Assert.Equal(2, childBackgrounds.Length); + Assert.Equal(0f, childBackgrounds[0].Rect.X); + Assert.Equal(90f, childBackgrounds[0].Rect.Width); + Assert.Equal(90f, childBackgrounds[1].Rect.X); + Assert.Equal(60f, childBackgrounds[1].Rect.Width); + } + + [Fact] + public async Task BuildDisplayList_ClampsAutoTrackToItsMinMaxLengthBounds() + { + var document = await ParseAsync(""" + +
      +
      +
      +
      +
      +
      +
      +
      + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + FontSize = 16f, + }); + + var secondColumnCells = displayList.Commands + .OfType() + .Where(command => command.Rect.Width == 30f) + .OrderBy(command => command.Rect.Y) + .ToArray(); + + Assert.Equal(2, secondColumnCells.Length); + // First grid: the item's estimated size (20px) is below the minmax() floor, so the + // Auto track clamps up to its 60px minimum. + Assert.Equal(60f, secondColumnCells[0].Rect.X); + // Second grid: the item's estimated size (150px) exceeds the minmax() ceiling, so the + // Auto track clamps down to its 100px maximum. + Assert.Equal(100f, secondColumnCells[1].Rect.X); + } + [Fact] public async Task BuildDisplayList_AppliesExplicitGridItemPlacement() { @@ -1049,13 +1194,17 @@ public async Task BuildDisplayList_AppliesAutoPlacementAcrossImplicitTracks() .ThenBy(command => command.Rect.X) .ToArray(); + // Implicit auto rows now size to their own content (10px, each item's actual height), + // the same way auto columns already did - this used to assert 20 (containerHeight/rowCount, + // 40/2), a coarse guess from before implicit rows grew to fit content like explicit Auto + // tracks do (see GrowGridTrackSize). Assert.Equal(3, childBackgrounds.Length); Assert.Equal(0f, childBackgrounds[0].Rect.X); Assert.Equal(0f, childBackgrounds[0].Rect.Y); Assert.Equal(50f, childBackgrounds[1].Rect.X); Assert.Equal(0f, childBackgrounds[1].Rect.Y); Assert.Equal(0f, childBackgrounds[2].Rect.X); - Assert.Equal(20f, childBackgrounds[2].Rect.Y); + Assert.Equal(10f, childBackgrounds[2].Rect.Y); } [Fact] diff --git a/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs b/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs index 0ce8013..839be06 100644 --- a/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs +++ b/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs @@ -2624,6 +2624,38 @@ public async Task RenderToPng_PreWhiteSpacePreservesIndentationAndLineBreaks() maxDifferentPixels: TextRenderingToleranceMaxPixels); } + [Fact] + public async Task RenderToPng_SizesGridTracksWithFrRepeatAndMinMax() + { + var document = await ParseAsync(""" + + + + + +
      +
      +
      +
      +
      + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 200, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "sizes-grid-tracks-with-fr-repeat-and-minmax.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + private static async Task ParseAsync(string html, IConfiguration? configuration = null) { var context = BrowsingContext.New(configuration ?? Configuration.Default.WithCss()); diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/sizes-grid-tracks-with-fr-repeat-and-minmax.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/sizes-grid-tracks-with-fr-repeat-and-minmax.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..8e7c4352ac69e53e8a2c45bb2e85d4ccb3930e8d GIT binary patch literal 423 zcmeAS@N?(olHy`uVBq!ia0vp^CxAGGgAGU?ZmZ`8QY^(zo*^7SP{WbZ!N9;6=jq}Y zQZeW4)s3A_jts7WqS_lon{yMo4Yo-pY8&2Wt5a>#&EZaFJoQxlw8k7^KIYGpPUjq# zNM_!%WwtHDy_pUQ94t+!#G$&kM}L3(G2hzmT4Ln9bNklU3r}0KeGZ?0`t#f8TU1~F zt&3f@=QVqyf&d58pdvQNCimP_eh~Z95z|v>ek6`-!|?l#;K37*o|^+hp25@A&t;uc GLK6UkMQzyt literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer/HtmlRenderer.cs b/src/AngleSharp.Renderer/HtmlRenderer.cs index 10d7e6a..97192c4 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.cs @@ -1154,6 +1154,49 @@ private readonly record struct FlexItemLayoutInfo( private readonly record struct GridPlacement(int LineIndex, int Span); + /// + /// A CSS Grid track's own sizing function, kept unresolved (unlike a plain pixel size) until + /// 's own two-pass sizing algorithm can run: + /// tracks are grown by the estimated size of the items placed in them (this renderer's existing + /// approximation for content-based sizing - see ), and + /// only once every / track is settled can the free space + /// left over be distributed across (`fr`) tracks. + /// + private sealed class GridTrackSize + { + public GridTrackSizeKind Kind; + + /// The resolved pixel size for , or the + /// working/final grown size for and (once resolved) + /// . + public float Pixels; + + /// The `fr` count, for only. + public float FractionValue; + + /// A `minmax()` floor, honored for both and + /// tracks. + public float? MinPixels; + + /// A `minmax()`/`fit-content()` ceiling, honored for + /// tracks only - a flexible track's own maximum is + /// always itself (its `fr` share), matching spec. + public float? MaxPixels; + + public static GridTrackSize Fixed(float pixels) => new() { Kind = GridTrackSizeKind.Fixed, Pixels = Math.Max(0f, pixels) }; + + public static GridTrackSize Auto(float? min = null, float? max = null) => new() { Kind = GridTrackSizeKind.Auto, MinPixels = min, MaxPixels = max }; + + public static GridTrackSize Fraction(float fr, float? min = null) => new() { Kind = GridTrackSizeKind.Fraction, FractionValue = Math.Max(0f, fr), MinPixels = min }; + } + + private enum GridTrackSizeKind + { + Fixed, + Auto, + Fraction, + } + private static void LayoutFlexContainer( ElementRenderNode node, float containingX, @@ -2054,7 +2097,14 @@ private static void LayoutGridContainer( // (and appended), so their paint commands are spliced in before this index instead. var boxPaintInsertIndex = displayList.Commands.Count; - var columns = ParseGridTrackList(styleMap, "grid-template-columns", containingWidth, 1); + var explicitGridTemplateColumns = ResolveExplicitPropertyValue(node.Ref, node.ComputedStyle, "grid-template-columns"); + var columns = ParseGridTrackListStructured(explicitGridTemplateColumns, containingWidth); + + if (columns.Count == 0) + { + columns.Add(GridTrackSize.Fixed(containingWidth)); + } + var columnGap = ParseGridGap(styleMap, "column-gap", containingWidth, 0) ?? ParseGridGap(styleMap, "gap", containingWidth, 0); var rowGap = ParseGridGap(styleMap, "row-gap", containingWidth, 0) @@ -2064,70 +2114,124 @@ private static void LayoutGridContainer( var gridItems = node.Children .Where(child => child is ElementRenderNode || (child is TextRenderNode textNode && NormalizeWhitespace(textNode.Ref.Data).Length > 0)) .ToList(); - var hasExplicitRowTracks = styleMap.TryGetValue("grid-template-rows", out var rowTemplateValue) && !string.IsNullOrWhiteSpace(rowTemplateValue); var containerHeight = ParseLength(styleMap, "height", containingWidth, containingWidth, allowAuto: true); - var rows = hasExplicitRowTracks - ? ParseGridTrackList(styleMap, "grid-template-rows", containerHeight, 1) - : CreateAutoRows(gridItems.Count, columns.Count, containerHeight); + var explicitGridTemplateRows = ResolveExplicitPropertyValue(node.Ref, node.ComputedStyle, "grid-template-rows"); + var rows = ParseGridTrackListStructured(explicitGridTemplateRows, containerHeight); + var hasExplicitRowTracks = rows.Count > 0; + + if (!hasExplicitRowTracks) + { + // Implicit rows default to content-sized (Auto), the same way a real browser sizes + // them - grown below from each item's own estimated height, exactly like an Auto + // column. The row count itself is still only a starting guess (one row per + // ceil(itemCount / columnCount)); ResolveGridItemPlacement grows this list further for + // any item that lands past it (an explicit grid-row/an oversized span). + var rowCount = Math.Max(1, (int)Math.Ceiling((double)gridItems.Count / Math.Max(1, columns.Count))); + + for (var i = 0; i < rowCount; i++) + { + rows.Add(GridTrackSize.Auto()); + } + } + // Pass 1: resolve every item's placement once (reused unchanged in pass 2 below, so a + // wrapping auto-placement cursor can never land an item in a different cell the second + // time around) and grow every Auto column/row to fit its own items' estimated size - + // mirroring this renderer's existing, pre-structured-parsing item-estimate approximation + // for content sizing (ResolveGridItemEstimatedSize), just now applied through the new + // GridTrackSize model instead of a flat pixel list. + var itemPlacements = new List<(ElementRenderNode Element, GridPlacement Column, GridPlacement Row)>(); var currentColumn = 0; var currentRow = 0; foreach (var child in gridItems) { - if (child is TextRenderNode textNode) - { - LayoutTextNode(textNode.Ref, containingX, containingWidth, ref cursorY, ref previousBlockMarginBottom, ref suppressNextBlockTopMargin, ref activeFloatLeftOffset, ref activeFloatBottom, ref textIndentConsumed, inheritedTextStyle, context, displayList, maxY); - continue; - } - if (child is not ElementRenderNode elementChild) { continue; } - var placementColumn = ResolveGridPlacement(styleMap, elementChild, "grid-column", currentColumn); - var placementRow = ResolveGridPlacement(styleMap, elementChild, "grid-row", currentRow); - var effectivePlacementColumn = placementColumn; - var effectivePlacementRow = placementRow; + var childStyleMap = CreateStyleMap(elementChild.ComputedStyle, elementChild.Ref); + var placementColumn = ResolveGridPlacementFromMap(childStyleMap, "grid-column", currentColumn); + var placementRow = ResolveGridPlacementFromMap(childStyleMap, "grid-row", currentRow); + var hasExplicitPlacement = childStyleMap.ContainsKey("grid-column") || childStyleMap.ContainsKey("grid-row"); - var hasExplicitColumnPlacement = elementChild.Ref.GetAttribute("data-render-grid-column") is not null; - var hasExplicitRowPlacement = elementChild.Ref.GetAttribute("data-render-grid-row") is not null; + var effectivePlacementColumn = hasExplicitPlacement + ? new GridPlacement(Math.Max(0, placementColumn.LineIndex), placementColumn.Span) + : new GridPlacement(Math.Max(0, currentColumn), placementColumn.Span); + var effectivePlacementRow = hasExplicitPlacement + ? new GridPlacement(Math.Max(0, placementRow.LineIndex), placementRow.Span) + : new GridPlacement(Math.Max(0, currentRow), placementRow.Span); - if (hasExplicitColumnPlacement || hasExplicitRowPlacement) + itemPlacements.Add((elementChild, effectivePlacementColumn, effectivePlacementRow)); + + // The item's *own* width/height (childStyleMap), not the container's - a real, + // confirmed bug in the pre-existing estimate (it read the container's styleMap here, + // so every item's estimate was really just re-reading the container's own width/height + // regardless of what any individual item was actually styled with). + var estimatedItemWidth = ResolveGridItemEstimatedSize(elementChild, childStyleMap, containingWidth, "width"); + var estimatedItemHeight = ResolveGridItemEstimatedSize(elementChild, childStyleMap, containingWidth, "height"); + var effectiveColumnCount = Math.Max(columns.Count, effectivePlacementColumn.LineIndex + effectivePlacementColumn.Span); + var effectiveRowCount = Math.Max(rows.Count, effectivePlacementRow.LineIndex + effectivePlacementRow.Span); + + while (columns.Count < effectiveColumnCount) { - effectivePlacementColumn = new GridPlacement(Math.Max(0, placementColumn.LineIndex), placementColumn.Span); - effectivePlacementRow = new GridPlacement(Math.Max(0, placementRow.LineIndex), placementRow.Span); + columns.Add(GridTrackSize.Auto()); } - else + + while (rows.Count < effectiveRowCount) { - effectivePlacementColumn = new GridPlacement(Math.Max(0, currentColumn), placementColumn.Span); - effectivePlacementRow = new GridPlacement(Math.Max(0, currentRow), placementRow.Span); + rows.Add(GridTrackSize.Auto()); } - var estimatedItemWidth = ResolveGridItemEstimatedSize(elementChild, styleMap, containingWidth, "width"); - var estimatedItemHeight = ResolveGridItemEstimatedSize(elementChild, styleMap, containingWidth, "height"); - var effectiveColumnCount = Math.Max(columns.Count, effectivePlacementColumn.LineIndex + effectivePlacementColumn.Span); - var effectiveRowCount = Math.Max(rows.Count, effectivePlacementRow.LineIndex + effectivePlacementRow.Span); - if (effectiveColumnCount > columns.Count) + GrowGridTrackSize(columns, effectivePlacementColumn.LineIndex, estimatedItemWidth); + GrowGridTrackSize(rows, effectivePlacementRow.LineIndex, estimatedItemHeight); + + currentColumn++; + if (currentColumn >= columns.Count) { - columns.AddRange(Enumerable.Repeat(containingWidth, effectiveColumnCount - columns.Count)); + currentColumn = 0; + currentRow++; } + } + + // Between passes: distribute each axis's remaining free space across its own `fr` tracks. + // Row `fr` tracks only grow when the container has a definite height to distribute - `fr` + // rows have no real meaning against an otherwise auto-sized container (there is no "free + // space" to speak of), a deliberate, documented scope cut rather than an attempt at the + // spec's own intrinsic-sizing fallback for that case. + ResolveGridFractionTracks(columns, resolvedColumnGap, containingWidth); + + if (hasExplicitRowTracks && !float.IsNaN(containerHeight)) + { + ResolveGridFractionTracks(rows, resolvedRowGap, containerHeight); + } - if (effectiveRowCount > rows.Count) + var columnSizes = columns.Select(t => t.Pixels).ToList(); + var rowSizes = rows.Select(t => t.Pixels).ToList(); + + // Pass 2: lay out every item for real, against the now-fully-resolved track sizes. + foreach (var child in gridItems) + { + if (child is TextRenderNode textNode) + { + LayoutTextNode(textNode.Ref, containingX, containingWidth, ref cursorY, ref previousBlockMarginBottom, ref suppressNextBlockTopMargin, ref activeFloatLeftOffset, ref activeFloatBottom, ref textIndentConsumed, inheritedTextStyle, context, displayList, maxY); + continue; + } + + if (child is not ElementRenderNode elementChild) { - rows.AddRange(Enumerable.Repeat(0f, effectiveRowCount - rows.Count)); + continue; } - EnsureGridTrackSize(columns, effectivePlacementColumn.LineIndex, estimatedItemWidth, containingWidth); - EnsureGridTrackSize(rows, effectivePlacementRow.LineIndex, estimatedItemHeight, 0f); + var (_, effectivePlacementColumn, effectivePlacementRow) = itemPlacements.First(p => ReferenceEquals(p.Element, elementChild)); var contentX = borderBoxX + borderLeft + paddingLeft; var contentY = borderBoxY + borderTop + paddingTop; - var cellX = contentX + GetGridTrackOffset(columns, effectivePlacementColumn.LineIndex, resolvedColumnGap); - var cellY = contentY + GetGridTrackOffset(rows, effectivePlacementRow.LineIndex, resolvedRowGap); - var cellWidth = GetGridTrackSpanSize(columns, effectivePlacementColumn.LineIndex, effectivePlacementColumn.Span, resolvedColumnGap, containingWidth); - var cellHeight = GetGridTrackSpanSize(rows, effectivePlacementRow.LineIndex, effectivePlacementRow.Span, resolvedRowGap, containingWidth); + var cellX = contentX + GetGridTrackOffset(columnSizes, effectivePlacementColumn.LineIndex, resolvedColumnGap); + var cellY = contentY + GetGridTrackOffset(rowSizes, effectivePlacementRow.LineIndex, resolvedRowGap); + var cellWidth = GetGridTrackSpanSize(columnSizes, effectivePlacementColumn.LineIndex, effectivePlacementColumn.Span, resolvedColumnGap, containingWidth); + var cellHeight = GetGridTrackSpanSize(rowSizes, effectivePlacementRow.LineIndex, effectivePlacementRow.Span, resolvedRowGap, containingWidth); var childCursor = cellY; var childPreviousBlockMarginBottom = 0f; @@ -2155,18 +2259,11 @@ private static void LayoutGridContainer( isRowDirection: true, flexMainSize: null, flexCrossSize: null); - - currentColumn++; - if (currentColumn >= columns.Count) - { - currentColumn = 0; - currentRow++; - } } - var gridContentWidth = GetGridContentSize(columns, resolvedColumnGap, containingWidth); + var gridContentWidth = GetGridContentSize(columnSizes, resolvedColumnGap, containingWidth); var specifiedHeight = ParseLength(styleMap, "height", containingWidth, containingWidth, allowAuto: true); - var gridContentHeight = GetGridContentSize(rows, resolvedRowGap, specifiedHeight); + var gridContentHeight = GetGridContentSize(rowSizes, resolvedRowGap, specifiedHeight); var borderBoxWidth = borderLeft + paddingLeft + Math.Max(containingWidth, gridContentWidth) + paddingRight + borderRight; var borderBoxHeight = borderTop + paddingTop + Math.Max(ParseLength(styleMap, "height", containingWidth, containingWidth, allowAuto: true), gridContentHeight) + paddingBottom + borderBottom; var canCollapseWithLastChild = borderBottom <= 0f && paddingBottom <= 0f; @@ -2268,41 +2365,160 @@ private static float GetGridTrackSpanSize(IReadOnlyList tracks, int index return totalSize; } - private static List ParseGridTrackList(Dictionary styleMap, string propertyName, float fallbackSize, int minimumCount) + /// + /// Parses `grid-template-columns`/`grid-template-rows` into a track-sizing-function list, + /// delegating the outer function/list grammar to AngleSharp.Css's own `GridParser.ParseTrackList` + /// (mirroring the `transform`/`filter`/gradient precedent: AngleSharp.Css computes the pure-CSS + /// structure - `repeat()`, `minmax()`, `fit-content()`, the `fr` unit - this renderer still owns + /// turning that into its own backend-agnostic list and, later, the + /// actual pixel sizes). Returns an empty list when unset or `none`, matching CSS's own initial + /// value - the caller falls back to a single implicit track, the same default a real browser + /// gives an unstyled grid container. + /// + private static List ParseGridTrackListStructured(string rawValue, float relativeTo) { - if (!styleMap.TryGetValue(propertyName, out var rawValue) || string.IsNullOrWhiteSpace(rawValue)) + var tracks = new List(); + + if (string.IsNullOrWhiteSpace(rawValue) || string.Equals(rawValue.Trim(), "none", StringComparison.OrdinalIgnoreCase)) { - var fallbackTracks = new List(Math.Max(1, minimumCount)); - var fallbackTrackSize = Math.Max(0f, fallbackSize / Math.Max(1, minimumCount)); - for (var index = 0; index < Math.Max(1, minimumCount); index++) - { - fallbackTracks.Add(fallbackTrackSize); - } + return tracks; + } + + var source = new StringSource(rawValue.Trim()); + var parsed = source.ParseTrackList(); - return fallbackTracks; + if (parsed is not null) + { + AppendGridTracks(parsed, relativeTo, tracks); } - var tracks = new List(); - foreach (var token in rawValue.Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + return tracks; + } + + private static void AppendGridTracks(ICssValue value, float relativeTo, List tracks) + { + switch (value) { - var normalized = token.Trim().ToLowerInvariant(); - var trackSize = normalized switch - { - "auto" => Math.Max(0f, fallbackSize), - _ => ParseLengthValue(normalized, fallbackSize, allowAuto: false) - }; + case CssLineNamesValue: + // Named grid lines (`[name]`) are not represented in this renderer's line-index + // placement model (see ResolveGridPlacement, which only understands numeric lines + // and spans) - a deliberate, documented scope cut, not a crash or a dropped track. + break; + + // CssRepeatValue/CssFitContentValue are both `internal` in AngleSharp.Css (unlike + // CssMinMaxValue, which is public) - matched via the public ICssFunctionValue interface + // (Name/Arguments) they both implement instead of the concrete type. + case ICssFunctionValue repeatFunc when string.Equals(repeatFunc.Name, "repeat", StringComparison.OrdinalIgnoreCase) && repeatFunc.Arguments.Length == 2: + var count = ResolveGridRepeatCount(repeatFunc.Arguments[0]); + + for (var i = 0; i < count; i++) + { + AppendGridTracks(repeatFunc.Arguments[1], relativeTo, tracks); + } + + break; + + case CssTupleValue tuple: + foreach (var item in tuple.Items) + { + if (item is not null) + { + AppendGridTracks(item, relativeTo, tracks); + } + } + + break; - tracks.Add(float.IsNaN(trackSize) ? Math.Max(0f, fallbackSize) : Math.Max(0f, trackSize)); + default: + tracks.Add(ConvertGridTrackSize(value, relativeTo)); + break; + } + } + + /// + /// `repeat(auto-fill, ...)`/`repeat(auto-fit, ...)` need the container's own available space to + /// compute how many repetitions fit - a genuinely different, container-size-dependent algorithm + /// this renderer does not implement. Falls back to a single repetition (the count `1` never + /// causes a dropped track or a NaN/absurd count), a deliberate, documented scope cut. + /// + private static int ResolveGridRepeatCount(ICssValue countValue) + { + var text = countValue.CssText; + + if (int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var count)) + { + return Math.Clamp(count, 1, 1000); + } + + return 1; + } + + private static GridTrackSize ConvertGridTrackSize(ICssValue value, float relativeTo) + { + switch (value) + { + case CssFractionValue fraction: + return GridTrackSize.Fraction((float)fraction.Value); + + case CssMinMaxValue minMax: + return ConvertGridMinMax(minMax, relativeTo); + + case ICssFunctionValue fitContentFunc when string.Equals(fitContentFunc.Name, "fit-content", StringComparison.OrdinalIgnoreCase) && fitContentFunc.Arguments.Length == 1: + return GridTrackSize.Auto(max: ResolveGridTrackLength(fitContentFunc.Arguments[0], relativeTo)); + + case CssLengthValue: + return GridTrackSize.Fixed(ResolveGridTrackLength(value, relativeTo)); + + default: + // "auto"/"min-content"/"max-content" and anything else unrecognized - all treated + // as content-sized, the same approximation this renderer already used for every + // plain "auto" track before structured parsing. + return GridTrackSize.Auto(); + } + } + + /// + /// `minmax(min, max)` where `max` is a `<flex>` (`fr`) is the common, important case + /// (`minmax(100px, 1fr)` - "at least 100px, then grow to fill") and gets real support: the + /// track participates in free-space distribution like any other `fr` track, but never shrinks + /// below `min`. Any other combination (`minmax(100px, 300px)`, `minmax(min-content, 1fr)`, ...) + /// is approximated as a content-sized track clamped to whichever bounds were themselves plain + /// lengths/percentages - not the spec's own iterative clamping algorithm, but consistent with + /// this renderer's existing item-estimate-based approximation for content sizing in general. + /// + private static GridTrackSize ConvertGridMinMax(CssMinMaxValue minMax, float relativeTo) + { + if (minMax.Maximum is CssFractionValue maxFraction) + { + var floorPixels = minMax.Minimum is CssLengthValue ? ResolveGridTrackLength(minMax.Minimum, relativeTo) : (float?)null; + return GridTrackSize.Fraction((float)maxFraction.Value, floorPixels); } - return tracks.Count > 0 ? tracks : new List { Math.Max(0f, fallbackSize) }; + var min = minMax.Minimum is CssLengthValue ? ResolveGridTrackLength(minMax.Minimum, relativeTo) : (float?)null; + var max = minMax.Maximum is CssLengthValue ? ResolveGridTrackLength(minMax.Maximum, relativeTo) : (float?)null; + return GridTrackSize.Auto(min, max); } - private static List CreateAutoRows(int itemCount, int columnCount, float containerHeight) + /// + /// Resolves one already-structured track-size sub-value's own length/percentage into pixels, + /// reading its `.CssText` (e.g. "50%", "20px") the same "re-parse the structured value's own + /// serialized text with this renderer's existing semantic parsers" pattern already established + /// for `filter`/gradients - itself has no percentage handling + /// (every other caller resolves percentages against a styleMap-driven containing dimension it + /// doesn't have here), so this adds that one case directly rather than reusing it verbatim. + /// + private static float ResolveGridTrackLength(ICssValue value, float relativeTo) { - var rowCount = Math.Max(1, (int)Math.Ceiling((double)itemCount / Math.Max(1, columnCount))); - var fallbackRowSize = containerHeight > 0f ? containerHeight / rowCount : 0f; - return Enumerable.Range(0, rowCount).Select(_ => fallbackRowSize).ToList(); + var text = value.CssText.Trim(); + + if (text.EndsWith("%", StringComparison.Ordinal) && + float.TryParse(text[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out var percent)) + { + return Math.Max(0f, relativeTo * percent / 100f); + } + + var pixels = ParseLengthValue(text, float.NaN, allowAuto: false); + return float.IsNaN(pixels) ? 0f : Math.Max(0f, pixels); } private static float? ParseGridGap(Dictionary styleMap, string propertyName, float relativeTo, int tokenIndex) @@ -2323,9 +2539,8 @@ private static List CreateAutoRows(int itemCount, int columnCount, float return float.IsNaN(parsed) ? null : parsed; } - private static GridPlacement ResolveGridPlacement(Dictionary styleMap, ElementRenderNode elementChild, string propertyName, int fallbackIndex) + private static GridPlacement ResolveGridPlacementFromMap(Dictionary childStyleMap, string propertyName, int fallbackIndex) { - var childStyleMap = CreateStyleMap(elementChild.ComputedStyle, elementChild.Ref); if (!childStyleMap.TryGetValue(propertyName, out var rawValue) || string.IsNullOrWhiteSpace(rawValue)) { return new GridPlacement(fallbackIndex, 1); @@ -2362,16 +2577,69 @@ private static float ResolveGridItemEstimatedSize(ElementRenderNode elementChild return float.IsNaN(parsed) ? fallbackSize : Math.Max(0f, parsed); } - private static void EnsureGridTrackSize(List tracks, int index, float size, float fallbackSize) + /// + /// Grows an track to fit an item's own estimated size, + /// the same "grow, never shrink" approximation for content-based sizing this renderer already + /// used before structured `grid-template-columns`/`rows` parsing - now scoped to `Auto` tracks + /// specifically (a `Fixed` track already has its final size; a `Fraction` track's only comes + /// from , once every `Auto`/`Fixed` track is settled). + /// + private static void GrowGridTrackSize(List tracks, int index, float itemEstimate) { while (tracks.Count <= index) { - tracks.Add(Math.Max(0f, fallbackSize)); + tracks.Add(GridTrackSize.Auto()); + } + + var track = tracks[index]; + + if (track.Kind != GridTrackSizeKind.Auto) + { + return; + } + + var candidate = Math.Max(track.Pixels, itemEstimate); + + if (track.MaxPixels is { } max) + { + candidate = Math.Min(candidate, max); + } + + if (track.MinPixels is { } min) + { + candidate = Math.Max(candidate, min); + } + + track.Pixels = candidate; + } + + /// + /// Distributes one axis's remaining free space across its own `fr` tracks, proportional to + /// each one's own `fr` count - the CSS Grid spec's own "distribute free space by flex factor" + /// step, simplified (not the full spec algorithm's iterative handling of a `minmax()` track + /// whose floor alone already exceeds its fair share - a rare, defensible approximation gap). + /// A `minmax(floor, 1fr)` track's floor is reserved as already-used space before the remaining + /// free space is computed, then added back on top of that track's own distributed share. + /// + private static void ResolveGridFractionTracks(List tracks, float gap, float availableSpace) + { + var totalFraction = tracks.Where(t => t.Kind == GridTrackSizeKind.Fraction).Sum(t => t.FractionValue); + + if (totalFraction <= 0f) + { + return; } - if (tracks[index] <= 0f) + var usedSpace = tracks.Sum(t => t.Kind == GridTrackSizeKind.Fraction ? (t.MinPixels ?? 0f) : t.Pixels); + var gapSpace = Math.Max(0, tracks.Count - 1) * gap; + var freeSpace = Math.Max(0f, availableSpace - usedSpace - gapSpace); + + foreach (var track in tracks) { - tracks[index] = Math.Max(0f, Math.Max(size, fallbackSize)); + if (track.Kind == GridTrackSizeKind.Fraction) + { + track.Pixels = (track.MinPixels ?? 0f) + (freeSpace * (track.FractionValue / totalFraction)); + } } } @@ -2981,31 +3249,13 @@ private static void PrepareDocumentForRendering(IDocument document) var currentStyle = styleAttribute; var changed = false; - if (TryExtractGradientBackground(currentStyle, out var gradientValue, out var updatedStyle)) - { - currentStyle = updatedStyle; - changed = true; - element.SetAttribute("data-render-gradient", gradientValue); - } - - if (TryExtractTransformDeclaration(currentStyle, out var transformValue, out updatedStyle)) + if (TryExtractTransformDeclaration(currentStyle, out var transformValue, out var updatedStyle)) { currentStyle = updatedStyle; changed = true; element.SetAttribute("data-render-transform", transformValue); } - if (TryExtractGridDeclarations(currentStyle, out var gridValues, out updatedStyle)) - { - currentStyle = updatedStyle; - changed = true; - - foreach (var entry in gridValues) - { - element.SetAttribute($"data-render-{entry.Key}", entry.Value); - } - } - if (changed) { element.SetAttribute("style", currentStyle); @@ -3013,60 +3263,11 @@ private static void PrepareDocumentForRendering(IDocument document) } } - private static bool TryExtractGradientBackground(string styleAttribute, out string gradientValue, out string updatedStyle) - { - gradientValue = string.Empty; - updatedStyle = styleAttribute; - - if (!styleAttribute.Contains("background-image", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - var declarations = styleAttribute.Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); - var remaining = new List(); - - foreach (var declaration in declarations) - { - var separator = declaration.IndexOf(':'); - if (separator <= 0) - { - continue; - } - - var property = declaration[..separator].Trim(); - var value = declaration[(separator + 1)..].Trim(); - - if (string.Equals(property, "background-image", StringComparison.OrdinalIgnoreCase) && - (value.StartsWith("linear-gradient", StringComparison.OrdinalIgnoreCase) || - value.StartsWith("radial-gradient", StringComparison.OrdinalIgnoreCase) || - value.StartsWith("conic-gradient", StringComparison.OrdinalIgnoreCase) || - value.StartsWith("repeating-linear-gradient", StringComparison.OrdinalIgnoreCase) || - value.StartsWith("repeating-radial-gradient", StringComparison.OrdinalIgnoreCase) || - value.StartsWith("repeating-conic-gradient", StringComparison.OrdinalIgnoreCase))) - { - gradientValue = value; - continue; - } - - remaining.Add(declaration); - } - - if (string.IsNullOrWhiteSpace(gradientValue)) - { - return false; - } - - updatedStyle = string.Join(";", remaining); - return true; - } - /// /// Extracts a `transform` declaration out of an inline `style` attribute before AngleSharp.Css - /// ever sees it, the same "extract to a data-render-* attribute" workaround - /// already established for gradient - /// `background-image` values - except here the workaround is for a genuine upstream crash, not - /// an unsupported-value gap: AngleSharp.Css's own `CssTranslateValue.Compute()` throws a + /// ever sees it (via a `data-render-transform` attribute this renderer reads back in + /// `CreateStyleMap` instead) - a workaround for a genuine upstream crash, not an unsupported- + /// value gap: AngleSharp.Css's own `CssTranslateValue.Compute()` throws a /// `NullReferenceException` - confirmed via a failing test with a minimal repro, not assumed - /// for *any* `translate`/`translateX`/`translateY` function, and that crash happens eagerly /// while building the render tree (`RenderTreeBuilder.RenderElement` computing the *entire* @@ -3075,7 +3276,11 @@ private static bool TryExtractGradientBackground(string styleAttribute, out stri /// `translate` - both to keep this single code path simple and because relying on exactly /// which other functions are crash-free would be fragile against a future AngleSharp.Css /// version. `rotate()`/`scale()` were separately confirmed *not* to crash, but are extracted - /// the same way regardless, for that same reason. + /// the same way regardless, for that same reason. `background-image` gradients used to need + /// this identical extraction pattern too, until AngleSharp.Css's own `GradientParser` shipped + /// and computed style started round-tripping the raw gradient text correctly - see the + /// `filter`/gradient paragraphs in AGENTS.md for the general "each upstream gap gets its own + /// fix, not a shared local workaround" policy this follows. /// private static bool TryExtractTransformDeclaration(string styleAttribute, out string transformValue, out string updatedStyle) { @@ -3121,103 +3326,72 @@ private static bool TryExtractTransformDeclaration(string styleAttribute, out st return true; } - private static bool TryExtractGridDeclarations(string styleAttribute, out Dictionary values, out string updatedStyle) - { - values = new Dictionary(StringComparer.OrdinalIgnoreCase); - updatedStyle = styleAttribute; - - if (string.IsNullOrWhiteSpace(styleAttribute)) - { - return false; - } + /// + /// Resolves `background-image` from the cascaded but *uncomputed* declaration rather than + /// ComputeCurrentStyle()'s normal computed value, a deliberate, narrow exception to how + /// every other property in this map is read. AngleSharp.Css's `.Compute()` step eagerly + /// resolves a `CssPoint2D`'s percentage/keyword components (a gradient's `at <position>`, + /// and equally a radial gradient's explicit percentage size) into absolute pixels using + /// whatever `IRenderDimensions` happens to be current at CSSOM-compute time - the *viewport*, + /// not the element's own box, since a gradient's box is not a concept the general CSSOM compute + /// pass has any way to know about. Confirmed empirically: a `radial-gradient(red, blue)` (no + /// `at` clause at all - the default, and by far the most common way one is written) on a + /// 200x100px box inside a 300x200px viewport computed to `at 150px 150px` - both axes resolved + /// against the *viewport's* 300px width, not each axis against the box's own matching + /// dimension, which would already be wrong even before 's own separate + /// `Compute()` bug (its `y` local is assigned from `_x.Compute(context)` instead of + /// `_y.Compute(context)` - reported upstream) compounds it further. There is no way to recover + /// the original percentage/keyword/position from that already-resolved-against-the-wrong-thing + /// pixel text, so this renderer cannot use the computed value for `background-image` at all - + /// `StyleCollectionExtensions.GetDeclarations` (`ComputeExplicitStyle` under the hood) gives the + /// same cascaded, colour-normalized value with none of this resolution applied, matching exactly + /// what a plain `style.GetPropertyValue("background-image")` gives for every other case + /// (`url(...)`, or a gradient with only non-percentage arguments) where the two do not diverge. + /// Falls back to the ordinary computed value if no window/device is available to build the + /// style collection from (mirrors the same fallback ComputeCurrentStyle() itself uses + /// internally when an element has no `Owner.DefaultView`). + /// + private static string ResolveExplicitBackgroundImage(IElement? element, ICssStyleDeclaration computedStyle) => + ResolveExplicitPropertyValue(element, computedStyle, "background-image"); - var declarations = styleAttribute.Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); - var remaining = new List(); - var strippedAny = false; + /// + /// Reads from the cascaded but *uncomputed* declaration rather + /// than ComputeCurrentStyle()'s normal computed value - the same technique + /// established for `background-image`'s gradient + /// percentages, reused here for the CSS Grid properties that hit the identical class of bug: + /// `grid-template-columns`/`grid-template-rows` can hold percentage tracks, which AngleSharp.Css's + /// `.Compute()` step eagerly resolves against whatever `IRenderDimensions` happens to be current + /// at CSSOM-compute time (the *viewport*, confirmed empirically - `grid-template-rows: 30%` on an + /// element with no defined height inside a 600x300 viewport computed to `180px`, i.e. 30% of the + /// 600px *width*, not the 300px height a row percentage should track) rather than the grid + /// container's own box, which is not a concept the general CSSOM compute pass has any way to + /// know about - the same root cause `ResolveExplicitBackgroundImage`'s own remarks document in + /// full. There is no way to recover the original percentage from that already-resolved-against- + /// the-wrong-thing pixel text, so the computed value cannot be used for these properties at all. + /// + private static string ResolveExplicitPropertyValue(IElement? element, ICssStyleDeclaration computedStyle, string propertyName) + { + var window = element?.Owner?.DefaultView; - foreach (var declaration in declarations) + if (window is not null) { - var separator = declaration.IndexOf(':'); - if (separator <= 0) - { - continue; - } - - var property = declaration[..separator].Trim(); - var value = declaration[(separator + 1)..].Trim(); - - if (string.Equals(property, "grid-column", StringComparison.OrdinalIgnoreCase)) - { - values["grid-column"] = value; - strippedAny = true; - continue; - } - - if (string.Equals(property, "grid-row", StringComparison.OrdinalIgnoreCase)) - { - values["grid-row"] = value; - strippedAny = true; - continue; - } + var device = window.Document.Context.GetService() ?? new DefaultRenderDevice(); + var styleCollection = window.GetStyleCollection(device); + var explicitValue = styleCollection.GetDeclarations(element!).GetPropertyValue(propertyName); - if (string.Equals(property, "grid-template-columns", StringComparison.OrdinalIgnoreCase)) + if (!string.IsNullOrWhiteSpace(explicitValue)) { - values["grid-template-columns"] = value; - strippedAny = true; - continue; - } - - if (string.Equals(property, "grid-template-rows", StringComparison.OrdinalIgnoreCase)) - { - values["grid-template-rows"] = value; - strippedAny = true; - continue; + return explicitValue; } - - if (string.Equals(property, "column-gap", StringComparison.OrdinalIgnoreCase)) - { - values["column-gap"] = value; - strippedAny = true; - continue; - } - - if (string.Equals(property, "row-gap", StringComparison.OrdinalIgnoreCase)) - { - values["row-gap"] = value; - strippedAny = true; - continue; - } - - if (string.Equals(property, "gap", StringComparison.OrdinalIgnoreCase)) - { - values["gap"] = value; - strippedAny = true; - continue; - } - - remaining.Add(declaration); - } - - if (!strippedAny) - { - return false; } - updatedStyle = string.Join(";", remaining); - return true; + return computedStyle.GetPropertyValue(propertyName); } private static Dictionary CreateStyleMap(ICssStyleDeclaration style, IElement? element = null) { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); var inlineStyle = element?.GetAttribute("style"); - var gridColumnValue = element?.GetAttribute("data-render-grid-column"); - var gridRowValue = element?.GetAttribute("data-render-grid-row"); - var gridTemplateColumnsValue = element?.GetAttribute("data-render-grid-template-columns"); - var gridTemplateRowsValue = element?.GetAttribute("data-render-grid-template-rows"); - var columnGapValue = element?.GetAttribute("data-render-column-gap"); - var rowGapValue = element?.GetAttribute("data-render-row-gap"); - var gapValue = element?.GetAttribute("data-render-gap"); var displayValue = style.GetDisplay(); if (string.IsNullOrWhiteSpace(displayValue)) @@ -3280,13 +3454,28 @@ private static Dictionary CreateStyleMap(ICssStyleDeclaration st AddIfPresent(map, "outline-color", style.GetPropertyValue("outline-color")); AddIfPresent(map, "background-color", style.GetBackgroundColor()); - AddIfPresent(map, "grid-template-columns", !string.IsNullOrWhiteSpace(gridTemplateColumnsValue) ? gridTemplateColumnsValue : (string.IsNullOrWhiteSpace(style.GetPropertyValue("grid-template-columns")) ? ParseStyleAttributeValue(inlineStyle, "grid-template-columns") : style.GetPropertyValue("grid-template-columns"))); - AddIfPresent(map, "grid-template-rows", !string.IsNullOrWhiteSpace(gridTemplateRowsValue) ? gridTemplateRowsValue : (string.IsNullOrWhiteSpace(style.GetPropertyValue("grid-template-rows")) ? ParseStyleAttributeValue(inlineStyle, "grid-template-rows") : style.GetPropertyValue("grid-template-rows"))); - AddIfPresent(map, "column-gap", !string.IsNullOrWhiteSpace(columnGapValue) ? columnGapValue : (string.IsNullOrWhiteSpace(style.GetPropertyValue("column-gap")) ? ParseStyleAttributeValue(inlineStyle, "column-gap") : style.GetPropertyValue("column-gap"))); - AddIfPresent(map, "row-gap", !string.IsNullOrWhiteSpace(rowGapValue) ? rowGapValue : (string.IsNullOrWhiteSpace(style.GetPropertyValue("row-gap")) ? ParseStyleAttributeValue(inlineStyle, "row-gap") : style.GetPropertyValue("row-gap"))); - AddIfPresent(map, "gap", !string.IsNullOrWhiteSpace(gapValue) ? gapValue : (string.IsNullOrWhiteSpace(style.GetPropertyValue("gap")) ? ParseStyleAttributeValue(inlineStyle, "gap") : style.GetPropertyValue("gap"))); - AddIfPresent(map, "grid-column", !string.IsNullOrWhiteSpace(gridColumnValue) ? gridColumnValue : ParseStyleAttributeValue(inlineStyle, "grid-column")); - AddIfPresent(map, "grid-row", !string.IsNullOrWhiteSpace(gridRowValue) ? gridRowValue : ParseStyleAttributeValue(inlineStyle, "grid-row")); + // grid-template-columns/rows, grid-column/row, and the gap properties are all read from + // the explicit/cascaded declaration rather than computed style, for the same reason + // `background-image` is (see ResolveExplicitPropertyValue's own remarks): a percentage + // track/gap gets eagerly resolved against the wrong reference dimension by AngleSharp.Css's + // `.Compute()` step. grid-column/grid-row additionally used to crash computed style + // entirely for the common ` / span ` form (CssTupleValue.Compute() calling + // .Compute() on the omitted end line's null entry) - fixed upstream, but reading the + // explicit declaration sidesteps that whole bug class regardless. + var explicitGridTemplateColumns = ResolveExplicitPropertyValue(element, style, "grid-template-columns"); + AddIfPresent(map, "grid-template-columns", string.IsNullOrWhiteSpace(explicitGridTemplateColumns) ? ParseStyleAttributeValue(inlineStyle, "grid-template-columns") : explicitGridTemplateColumns); + var explicitGridTemplateRows = ResolveExplicitPropertyValue(element, style, "grid-template-rows"); + AddIfPresent(map, "grid-template-rows", string.IsNullOrWhiteSpace(explicitGridTemplateRows) ? ParseStyleAttributeValue(inlineStyle, "grid-template-rows") : explicitGridTemplateRows); + var explicitColumnGap = ResolveExplicitPropertyValue(element, style, "column-gap"); + AddIfPresent(map, "column-gap", string.IsNullOrWhiteSpace(explicitColumnGap) ? ParseStyleAttributeValue(inlineStyle, "column-gap") : explicitColumnGap); + var explicitRowGap = ResolveExplicitPropertyValue(element, style, "row-gap"); + AddIfPresent(map, "row-gap", string.IsNullOrWhiteSpace(explicitRowGap) ? ParseStyleAttributeValue(inlineStyle, "row-gap") : explicitRowGap); + var explicitGap = ResolveExplicitPropertyValue(element, style, "gap"); + AddIfPresent(map, "gap", string.IsNullOrWhiteSpace(explicitGap) ? ParseStyleAttributeValue(inlineStyle, "gap") : explicitGap); + var explicitGridColumn = ResolveExplicitPropertyValue(element, style, "grid-column"); + AddIfPresent(map, "grid-column", string.IsNullOrWhiteSpace(explicitGridColumn) ? ParseStyleAttributeValue(inlineStyle, "grid-column") : explicitGridColumn); + var explicitGridRow = ResolveExplicitPropertyValue(element, style, "grid-row"); + AddIfPresent(map, "grid-row", string.IsNullOrWhiteSpace(explicitGridRow) ? ParseStyleAttributeValue(inlineStyle, "grid-row") : explicitGridRow); AddIfPresent(map, "flex-direction", string.IsNullOrWhiteSpace(style.GetPropertyValue("flex-direction")) ? ParseStyleAttributeValue(inlineStyle, "flex-direction") : style.GetPropertyValue("flex-direction")); AddIfPresent(map, "justify-content", string.IsNullOrWhiteSpace(style.GetPropertyValue("justify-content")) ? ParseStyleAttributeValue(inlineStyle, "justify-content") : style.GetPropertyValue("justify-content")); AddIfPresent(map, "align-items", string.IsNullOrWhiteSpace(style.GetPropertyValue("align-items")) ? ParseStyleAttributeValue(inlineStyle, "align-items") : style.GetPropertyValue("align-items")); @@ -3298,26 +3487,10 @@ private static Dictionary CreateStyleMap(ICssStyleDeclaration st AddIfPresent(map, "order", string.IsNullOrWhiteSpace(style.GetPropertyValue("order")) ? ParseStyleAttributeValue(inlineStyle, "order") : style.GetPropertyValue("order")); AddIfPresent(map, "align-content", string.IsNullOrWhiteSpace(style.GetPropertyValue("align-content")) ? ParseStyleAttributeValue(inlineStyle, "align-content") : style.GetPropertyValue("align-content")); - var backgroundImageValue = element is not null - ? element.GetAttribute("data-render-gradient") - : null; - - if (!string.IsNullOrWhiteSpace(backgroundImageValue)) - { - AddIfPresent(map, "background-image", backgroundImageValue); - } - else - { - // AngleSharp.Css does compute a `url(...)` background-image (unlike the `overflow` - // shorthand quirk documented elsewhere), but as a normalized, quoted `url("...")` - the - // raw inline `style=""` fallback below matches the pattern the grid/flex properties - // above already use for their own AngleSharp.Css computation gaps, kept here as the - // same defensive fallback for the rare case computation reports nothing at all. - var computedBackgroundImage = style.GetPropertyValue("background-image"); - AddIfPresent(map, "background-image", string.IsNullOrWhiteSpace(computedBackgroundImage) - ? ParseStyleAttributeValue(inlineStyle, "background-image") - : computedBackgroundImage); - } + var resolvedBackgroundImage = ResolveExplicitBackgroundImage(element, style); + AddIfPresent(map, "background-image", string.IsNullOrWhiteSpace(resolvedBackgroundImage) + ? ParseStyleAttributeValue(inlineStyle, "background-image") + : resolvedBackgroundImage); AddIfPresent(map, "background-repeat", string.IsNullOrWhiteSpace(style.GetPropertyValue("background-repeat")) ? ParseStyleAttributeValue(inlineStyle, "background-repeat") : style.GetPropertyValue("background-repeat")); AddIfPresent(map, "background-position", string.IsNullOrWhiteSpace(style.GetPropertyValue("background-position")) ? ParseStyleAttributeValue(inlineStyle, "background-position") : style.GetPropertyValue("background-position")); @@ -5911,226 +6084,111 @@ private static RenderBackgroundSizeAxis ParseSizeAxisToken(string token) return new RenderBackgroundSizeAxis(true, false, 0f); } + // AngleSharp.Css now parses gradients into structured values (`GradientParser.ParseGradient`, + // mirroring `TransformParser`/`FilterParser` exactly), so this no longer hand-parses the raw + // `background-image` text itself - it delegates the outer function/argument grammar and reads + // each already-typed piece (angle, stop color/position, center point) off the result, the same + // division of labor already established for `transform`/`filter`: AngleSharp.Css computes the + // pure-CSS structure, this renderer still owns turning it into its own backend-agnostic + // `RenderGradient`. `ParseGradient` returns null cleanly for anything it does not recognize + // (including a plain color), so no name-prefix pre-check is needed before calling it. private static RenderPaint ParseGradientPaint(string rawValue, RenderColor fallbackColor) { - var value = rawValue.Trim(); - - if (value.StartsWith("repeating-linear-gradient", StringComparison.OrdinalIgnoreCase)) - { - return new RenderGradientPaint(ParseLinearGradient(value, "repeating-linear-gradient", repeating: true, fallbackColor)); - } - - if (value.StartsWith("linear-gradient", StringComparison.OrdinalIgnoreCase)) - { - return new RenderGradientPaint(ParseLinearGradient(value, "linear-gradient", repeating: false, fallbackColor)); - } - - if (value.StartsWith("repeating-radial-gradient", StringComparison.OrdinalIgnoreCase)) - { - return new RenderGradientPaint(ParseRadialGradient(value, "repeating-radial-gradient", repeating: true, fallbackColor)); - } - - if (value.StartsWith("radial-gradient", StringComparison.OrdinalIgnoreCase)) - { - return new RenderGradientPaint(ParseRadialGradient(value, "radial-gradient", repeating: false, fallbackColor)); - } - - if (value.StartsWith("repeating-conic-gradient", StringComparison.OrdinalIgnoreCase)) - { - return new RenderGradientPaint(ParseConicGradient(value, "repeating-conic-gradient", repeating: true, fallbackColor)); - } + var source = new StringSource(rawValue.Trim()); - if (value.StartsWith("conic-gradient", StringComparison.OrdinalIgnoreCase)) + return source.ParseGradient() switch { - return new RenderGradientPaint(ParseConicGradient(value, "conic-gradient", repeating: false, fallbackColor)); - } - - return new RenderColorPaint(fallbackColor); + CssLinearGradientValue linear => new RenderGradientPaint(ConvertLinearGradient(linear, fallbackColor)), + CssRadialGradientValue radial => new RenderGradientPaint(ConvertRadialGradient(radial, fallbackColor)), + CssConicGradientValue conic => new RenderGradientPaint(ConvertConicGradient(conic, fallbackColor)), + _ => new RenderColorPaint(fallbackColor), + }; } - private static RenderGradient ParseLinearGradient(string rawValue, string functionName, bool repeating, RenderColor fallbackColor) + private static RenderGradient ConvertLinearGradient(CssLinearGradientValue gradient, RenderColor fallbackColor) { - var inner = ExtractGradientInnerExpression(rawValue, functionName); - var parts = SplitTopLevelCommaList(inner); - var startIndex = 0; - var angleDegrees = 90f; - - if (parts.Length > 0) - { - var first = parts[0].Trim(); - - if (TryParseDirection(first, out var parsedAngle)) - { - angleDegrees = parsedAngle; - startIndex = 1; - } - } - - var stops = ParseGradientStops(parts.Skip(startIndex).ToArray(), fallbackColor); - return new RenderGradient(RenderGradientKind.Linear, stops, AngleDegrees: angleDegrees, Repeating: repeating); + // `.Angle` already defaults correctly to 180deg ("to bottom") when no direction was + // authored - confirmed against AngleSharp.Css's own test suite, unlike the conic-gradient + // equivalent below. Re-parsing its own `.CssText` (rather than reading a numeric degree + // value directly) reuses the exact same deg/grad/turn/rad-unit handling `filter`'s + // `hue-rotate` already relies on (`TryParseAngle`), so a keyword direction like "to right" + // (which AngleSharp.Css resolves to a plain `CssAngleValue` internally, per `Map.GradientAngles`) + // and an explicit `135deg` both flow through the same one conversion path - both are CSS's + // own "0deg points up, clockwise" convention. `CreateLinearGradientShader` (unlike its conic + // counterpart, which applies this same correction itself via a rotation matrix) expects + // `AngleDegrees` pre-converted to its own "0 points right, clockwise" screen convention, so + // the -90 shift has to happen here - confirmed by a real, visible bug this surfaced: + // `to right` (CSS 90deg) rendered as horizontal stripes instead of vertical ones without it. + var angleDegrees = ParseAngle(gradient.Angle.CssText) - 90f; + var stops = ConvertGradientStops(gradient.Stops, fallbackColor, isConic: false); + return new RenderGradient(RenderGradientKind.Linear, stops, AngleDegrees: angleDegrees, Repeating: gradient.IsRepeating); } - private static RenderGradient ParseRadialGradient(string rawValue, string functionName, bool repeating, RenderColor fallbackColor) + private static RenderGradient ConvertRadialGradient(CssRadialGradientValue gradient, RenderColor fallbackColor) { - var inner = ExtractGradientInnerExpression(rawValue, functionName); - var parts = SplitTopLevelCommaList(inner); - var startIndex = 0; - - var isCircle = false; + var (centerX, centerY) = ParsePosition(gradient.Position.CssText); var sizeKind = RenderGradientSizeKind.FarthestCorner; float? explicitRadiusX = null; float? explicitRadiusY = null; - var centerX = 0.5f; - var centerY = 0.5f; - if (parts.Length > 0 && LooksLikeRadialConfiguration(parts[0])) + if (gradient.Mode != CssRadialGradientValue.SizeMode.None) { - var configText = parts[0].Trim(); - startIndex = 1; - - var atIndex = configText.IndexOf(" at ", StringComparison.OrdinalIgnoreCase); - var shapeSizeText = atIndex >= 0 ? configText[..atIndex].Trim() : configText; - var positionText = atIndex >= 0 ? configText[(atIndex + 4)..].Trim() : null; - - ParseRadialShapeAndSize(shapeSizeText, out isCircle, out sizeKind, out explicitRadiusX, out explicitRadiusY); + sizeKind = gradient.Mode switch + { + CssRadialGradientValue.SizeMode.ClosestCorner => RenderGradientSizeKind.ClosestCorner, + CssRadialGradientValue.SizeMode.ClosestSide => RenderGradientSizeKind.ClosestSide, + CssRadialGradientValue.SizeMode.FarthestSide => RenderGradientSizeKind.FarthestSide, + _ => RenderGradientSizeKind.FarthestCorner, + }; + } + else if (gradient.MajorRadius.CssText != CssLengthValue.Full.CssText || gradient.MinorRadius.CssText != CssLengthValue.Full.CssText) + { + // `Mode.None` alone does not distinguish "no size/radius was authored at all" from "an + // explicit radius was given" - `CssRadialGradientValue` has no public signal for that + // beyond this: an unset radius's own getter substitutes `CssLengthValue.Full` (100%) + // for the `null` it actually holds internally, with no way to tell the two apart from + // the outside. Comparing against that same sentinel is therefore the only available + // signal; the one case it cannot distinguish - an *explicit* ellipse radius that + // legitimately happens to be exactly 100% on both axes - is rare enough (and visually + // close to `farthest-corner` in most box aspect ratios anyway) to accept as a known, + // narrow approximation rather than threading extra state through GradientParser for it. + sizeKind = RenderGradientSizeKind.Explicit; - if (positionText is not null) + if (TryParsePixelValue(gradient.MajorRadius.CssText, out var radiusX)) { - (centerX, centerY) = ParsePosition(positionText); + explicitRadiusX = radiusX; } + + explicitRadiusY = TryParsePixelValue(gradient.MinorRadius.CssText, out var radiusY) ? radiusY : explicitRadiusX; } - var stops = ParseGradientStops(parts.Skip(startIndex).ToArray(), fallbackColor); + var stops = ConvertGradientStops(gradient.Stops, fallbackColor, isConic: false); return new RenderGradient( RenderGradientKind.Radial, stops, CenterX: centerX, CenterY: centerY, - IsCircle: isCircle, - Repeating: repeating, + IsCircle: gradient.IsCircle, + Repeating: gradient.IsRepeating, SizeKind: sizeKind, ExplicitRadiusX: explicitRadiusX, ExplicitRadiusY: explicitRadiusY); } - private static RenderGradient ParseConicGradient(string rawValue, string functionName, bool repeating, RenderColor fallbackColor) - { - var inner = ExtractGradientInnerExpression(rawValue, functionName); - var parts = SplitTopLevelCommaList(inner); - var startIndex = 0; - var angleDegrees = 0f; - var centerX = 0.5f; - var centerY = 0.5f; - - if (parts.Length > 0) - { - var first = parts[0].Trim(); - - if (first.StartsWith("from", StringComparison.OrdinalIgnoreCase) || first.StartsWith("at", StringComparison.OrdinalIgnoreCase)) - { - var atIndex = first.IndexOf(" at ", StringComparison.OrdinalIgnoreCase); - var fromText = atIndex >= 0 ? first[..atIndex].Trim() : first; - var positionText = atIndex >= 0 - ? first[(atIndex + 4)..].Trim() - : (first.StartsWith("at", StringComparison.OrdinalIgnoreCase) ? first[2..].Trim() : null); - - if (fromText.StartsWith("from", StringComparison.OrdinalIgnoreCase)) - { - angleDegrees = ParseAngle(fromText[4..].Trim()); - } - - if (positionText is not null) - { - (centerX, centerY) = ParsePosition(positionText); - } - - startIndex = 1; - } - } - - var stops = ParseGradientStops(parts.Skip(startIndex).ToArray(), fallbackColor, isConic: true); - return new RenderGradient(RenderGradientKind.Conic, stops, AngleDegrees: angleDegrees, CenterX: centerX, CenterY: centerY, Repeating: repeating); - } - - /// - /// Distinguishes a radial-gradient's leading `<ending-shape> || <size> [at - /// <position>]` configuration clause from what is actually just its first color stop - - /// a color stop never starts with a shape/size keyword, "at", or a bare length. - /// - private static bool LooksLikeRadialConfiguration(string part) + private static RenderGradient ConvertConicGradient(CssConicGradientValue gradient, RenderColor fallbackColor) { - var lower = part.Trim().ToLowerInvariant(); - - return lower.StartsWith("circle", StringComparison.Ordinal) || - lower.StartsWith("ellipse", StringComparison.Ordinal) || - lower.StartsWith("closest-", StringComparison.Ordinal) || - lower.StartsWith("farthest-", StringComparison.Ordinal) || - lower.StartsWith("at ", StringComparison.Ordinal) || - lower.Contains(" at ", StringComparison.Ordinal) || - (TryParsePixelValue(lower.Split(' ')[0], out _) && !lower.Contains(',')); + // Unlike linear's `.Angle`, conic's own default-angle fallback was a confirmed AngleSharp.Css + // bug (reported and fixed upstream, AngleSharp.Css.Tests/Values/Gradient.cs + // ConicGradientDefaultAngleIsZeroNotHalfCircle) - it used to report 180deg for an omitted + // `from` clause instead of the CSS spec's own 0deg default, so this renderer must build + // against a version with that fix rather than working around it locally. + var angleDegrees = ParseAngle(gradient.Angle.CssText); + var (centerX, centerY) = ParsePosition(gradient.Center.CssText); + var stops = ConvertGradientStops(gradient.Stops, fallbackColor, isConic: true); + return new RenderGradient(RenderGradientKind.Conic, stops, AngleDegrees: angleDegrees, CenterX: centerX, CenterY: centerY, Repeating: gradient.IsRepeating); } - private static void ParseRadialShapeAndSize(string text, out bool isCircle, out RenderGradientSizeKind sizeKind, out float? explicitRadiusX, out float? explicitRadiusY) - { - isCircle = false; - sizeKind = RenderGradientSizeKind.FarthestCorner; - explicitRadiusX = null; - explicitRadiusY = null; - - if (string.IsNullOrWhiteSpace(text)) - { - return; - } - - var tokens = text.Split(' ', StringSplitOptions.RemoveEmptyEntries); - var lengths = new List(); - - foreach (var token in tokens) - { - switch (token.ToLowerInvariant()) - { - case "circle": - isCircle = true; - break; - case "ellipse": - isCircle = false; - break; - case "closest-side": - sizeKind = RenderGradientSizeKind.ClosestSide; - break; - case "farthest-side": - sizeKind = RenderGradientSizeKind.FarthestSide; - break; - case "closest-corner": - sizeKind = RenderGradientSizeKind.ClosestCorner; - break; - case "farthest-corner": - sizeKind = RenderGradientSizeKind.FarthestCorner; - break; - default: - if (TryParsePixelValue(token, out var pixels)) - { - lengths.Add(pixels); - } - - break; - } - } - - if (lengths.Count > 0) - { - sizeKind = RenderGradientSizeKind.Explicit; - explicitRadiusX = lengths[0]; - explicitRadiusY = lengths.Count > 1 ? lengths[1] : lengths[0]; - - if (lengths.Count == 1) - { - // A single explicit length implies a circle - CSS grammar doesn't allow one - // length with an explicit "ellipse" keyword (that needs two lengths). - isCircle = true; - } - } - } + private static RenderColor ToRenderColor(CssColorValue color) => new(color.R, color.G, color.B, color.A); private static readonly string[] PositionKeywords = ["left", "right", "top", "bottom", "center"]; @@ -6191,24 +6249,6 @@ private static (float X, float Y) ParsePosition(string text) return (x ?? 0.5f, y ?? 0.5f); } - private static string ExtractGradientInnerExpression(string rawValue, string functionName) - { - if (!rawValue.StartsWith(functionName, StringComparison.OrdinalIgnoreCase)) - { - return string.Empty; - } - - var opening = rawValue.IndexOf('('); - var closing = rawValue.LastIndexOf(')'); - - if (opening < 0 || closing <= opening) - { - return string.Empty; - } - - return rawValue[(opening + 1)..closing].Trim(); - } - internal static string[] SplitTopLevelCommaList(string value) { if (string.IsNullOrWhiteSpace(value)) @@ -6254,48 +6294,54 @@ internal static string[] SplitTopLevelCommaList(string value) return parts.ToArray(); } - private static IReadOnlyList ParseGradientStops(string[] parts, RenderColor fallbackColor, bool isConic = false) + /// + /// Converts AngleSharp.Css's own list (already split and + /// individually parsed by ) into this renderer's backend-agnostic + /// stops - the counterpart to the old text-splitting version this replaced, kept as narrow a + /// change as possible: each stop's own color comes directly off CssGradientStopValue.Color + /// (a real, already-resolved - no re-parsing needed, unlike before), + /// while its position still goes through exactly as it always + /// did, just fed that stop's own Location.CssText instead of a hand-split token. + /// + private static IReadOnlyList ConvertGradientStops(ICssValue[] rawStops, RenderColor fallbackColor, bool isConic) { - if (parts.Length == 0) + var stops = rawStops.OfType().ToArray(); + + if (stops.Length == 0) { return [new RenderGradientStop(0f, fallbackColor)]; } - var stops = new List(parts.Length); + var result = new List(stops.Length); - for (var index = 0; index < parts.Length; index++) + for (var index = 0; index < stops.Length; index++) { - var part = parts[index].Trim(); - if (part.Length == 0) + var stop = stops[index]; + var color = ToRenderColor(stop.Color); + var autoPosition = stops.Length == 1 ? 0f : (index / (float)Math.Max(1, stops.Length - 1)); + + if (stop.IsUndetermined) { + result.Add(new RenderGradientStop(autoPosition, color)); continue; } - var separatorIndex = part.IndexOfAny([ ' ', '\t', '\n', '\r' ]); - var colorToken = separatorIndex >= 0 ? part[..separatorIndex].Trim() : part; - var positionToken = separatorIndex >= 0 ? part[(separatorIndex + 1)..].Trim() : string.Empty; + var positionText = stop.Location.CssText; - var color = ParseColor(colorToken, fallbackColor); - var autoPosition = parts.Length == 1 ? 0f : (index / (float)Math.Max(1, parts.Length - 1)); - - if (string.IsNullOrWhiteSpace(positionToken)) - { - stops.Add(new RenderGradientStop(autoPosition, color)); - } - else if (!isConic && TryParsePixelValue(positionToken, out var pixels)) + if (!isConic && TryParsePixelValue(positionText, out var pixels)) { // An absolute-length stop position ("red 10px") cannot become a fraction until // the gradient's own rendered geometry (line length/radius) is known, so the raw // pixel value is carried through and resolved by the backend at paint time. - stops.Add(new RenderGradientStop(autoPosition, color, pixels)); + result.Add(new RenderGradientStop(autoPosition, color, pixels)); } else { - stops.Add(new RenderGradientStop(ParseStopPosition(positionToken, isConic), color)); + result.Add(new RenderGradientStop(ParseStopPosition(positionText, isConic), color)); } } - return stops; + return result; } private static float ParseStopPosition(string rawPosition, bool isConic = false) @@ -6328,32 +6374,6 @@ private static float ParseStopPosition(string rawPosition, bool isConic = false) return 0f; } - private static bool TryParseDirection(string value, out float angleDegrees) - { - angleDegrees = 90f; - var normalized = value.Trim().ToLowerInvariant(); - - if (normalized.StartsWith("to ", StringComparison.Ordinal)) - { - var direction = normalized[3..].Trim(); - angleDegrees = direction switch - { - "top" => 270f, - "right" => 0f, - "bottom" => 90f, - "left" => 180f, - "top right" or "right top" => 315f, - "top left" or "left top" => 225f, - "bottom right" or "right bottom" => 45f, - "bottom left" or "left bottom" => 135f, - _ => 90f, - }; - return true; - } - - return TryParseAngle(normalized, out angleDegrees); - } - private static bool TryParseAngle(string value, out float angleDegrees) { angleDegrees = 90f; From a7da32fcc842fa7359019af9c22d72b5d3a40320 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Thu, 10 Sep 2026 15:34:29 +0200 Subject: [PATCH 3/9] Improved overflow with text ellipsis --- AGENTS.md | 10 +- CHANGELOG.md | 1 + .../HtmlRendererTests.cs | 110 ++++++ .../VisualConformanceTests.cs | 30 ++ ...ates-and-breaks-overflowing-text.macos.png | Bin 0 -> 5949 bytes src/AngleSharp.Renderer/HtmlRenderer.cs | 315 +++++++++++++++++- 6 files changed, 454 insertions(+), 12 deletions(-) create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/truncates-and-breaks-overflowing-text.macos.png diff --git a/AGENTS.md b/AGENTS.md index 956917d..738e86a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,7 +144,15 @@ CSS `white-space` (`normal`/`nowrap`/`pre`/`pre-wrap`/`pre-line`/`break-spaces`) Collapsing/preserving whitespace is only half the feature; the other half is what a preserved `\n` and a suppressed wrap actually *do* to layout, and the two text-layout functions handle it differently because of how differently they are already structured. `LayoutWrappedText` (a block element's own direct text content, or a lone all-inline leaf element with only text - the common case `
      `/`

      `/etc. actually appear in) gained `WrapTextRespectingWhiteSpace`: it splits the already-normalized text on `\n` into paragraphs first (a no-op split for `normal`/`nowrap`, which never have a literal `\n` surviving `NormalizeWhitespace` in the first place), then either treats each paragraph as one unwrapped line (`nowrap`/`pre`) or still word-wraps it against `maxWidth` via the existing `WrapText` (every other mode) - an empty paragraph (a blank line from consecutive forced breaks) still becomes its own empty line entry rather than being dropped, so blank lines in preserved text still occupy the vertical space a browser would give them, while `LayoutWrappedText`'s own per-line loop skips measuring/painting a `DrawText` command for a line with nothing in it (cursorY still advances by a full line-height first, so the blank row's height is still reserved). `LayoutInlineTextRun` (mixed inline content sharing a line with sibling text/elements - a `` interleaved with plain text and other inline elements) only gets the narrower half of this: `nowrap`/`pre` still suppress its own width-driven wrap-to-new-line check (`IsNoWrapWhiteSpace`, the same predicate `LayoutWrappedText` uses), but multi-space preservation and explicit forced breaks are a deliberate, documented scope cut here specifically - this function's word-by-word model (`Split(' ', RemoveEmptyEntries)`, one `DrawText` command per word, a fixed single-space-width gap between them) has no way to represent either even if the text handed to it preserved them, so `NormalizeWhitespaceForInlineRun` (used only at this function's own two call sites) still collapses runs of horizontal whitespace exactly like `normal` would and always neutralizes any literal `\n` down to a plain space before the text ever reaches this function, rather than handing it a character it cannot lay out. `

      ` gets `white-space: pre` from AngleSharp.Css's own UA stylesheet already (the same targeted-rule mechanism that gives `
        `/`
          ` their `list-style-type` default), not something this renderer has to inject itself. -Current behavior includes block layout, margins, padding, borders, floats, inline-block, relative/fixed/absolute positioning, z-index ordering, outlines, text styling, text alignment, line-height, letter-spacing, text-indent, vertical-align, `white-space` collapsing/preservation, border-radius, box-shadow, text-shadow, list-item markers, overflow clipping, page-level scroll offsets, image and gradient backgrounds, form controls (including a focused text input's animated caret), flexbox, CSS Grid (auto-placement plus `fr`/`repeat()`/`minmax()` track sizing), 2D `transform`/`transform-origin`, CSS `filter`, CSS `opacity`, real `:hover` matching and CSS `transition`/`animation`/`@keyframes` for interactive documents, and generic font-family handling. +`text-overflow: ellipsis`, `word-break: break-all`, and `overflow-wrap`/`word-wrap: break-word`/`anywhere` are read into the style map the same simple `AddIfPresent` way `white-space` is, and resolved onto three new `RenderTextStyle` fields (`TextOverflowMode`/`WordBreakMode`/`OverflowWrapMode`, `HtmlRenderer.cs`) alongside `WhiteSpaceMode`. `word-break`/`overflow-wrap` are inherited exactly like `white-space` (`ParseWordBreak`/`ParseOverflowWrap` fall back to `inherited` when unset, confirmed empirically the same way `white-space`'s own inheritance was); `text-overflow` is the deliberate exception - `ParseTextOverflow` never consults `inherited` at all, always re-deriving fresh from the current element's own style map, mirroring `RenderTextStyle.TextIndent`'s existing non-inheritance for the identical reason (it targets this element's own line box, not a descendant's). `ParseTextOverflow` also gates on `ShouldClipOverflow` (the same function `overflow` clipping already uses) - `ellipsis` on a box that does not clip is silently ignored, matching a real browser's own "text-overflow has no effect unless overflow is not visible" rule, and reusing `ShouldClipOverflow`'s existing "either axis, hidden/scroll/auto all count" simplification rather than a separate, narrower rule of its own. + +Discovered while wiring this up: AngleSharp.Css had **no registered declaration for `text-overflow` at all** - no `TextOverflowDeclaration.cs`, never wired into `DefaultDeclarationFactory` - so `ComputeCurrentStyle().GetPropertyValue("text-overflow")` always reported an empty string, even for an explicitly authored `text-overflow: ellipsis` (confirmed with a throwaway probe test before assuming anything); its own placeholder initial-value constant (`InitialValues.TextOverflowDecl`) also reused the wrong enum entirely (`OverflowMode`, whose keywords are `visible`/`hidden`/`scroll`/`auto`/`clip`) paired with the wrong keyword (`auto`) for a property whose real CSS initial value is the keyword `clip`. `word-break`/`overflow-wrap`/`word-wrap`, by contrast, were already fully implemented and correctly computed - confirmed empirically before writing any renderer code against them, so no upstream work was needed for those two. Fixed upstream: a new public `AngleSharp.Css.Dom.TextOverflow` enum (`Clip`/`Ellipsis`), a `Map.TextOverflows` string mapping and `ValueConverters.TextOverflowConverter` (mirroring `OverflowWrap`'s own pattern exactly), a real `TextOverflowDeclaration.cs`, and its registration in `DefaultDeclarationFactory` - locked in by `AngleSharp.Css.Tests/Styling/TextOverflowComputedStyleTests.cs`. + +`text-overflow: ellipsis` truncation itself (`TruncateWithEllipsis`, `HtmlRenderer.cs`) is scoped to the single-line case only - by far the dominant real-world usage (`overflow: hidden; white-space: nowrap; text-overflow: ellipsis`) - rather than truncating the last of several wrapped lines, which the CSS spec itself does not define without a non-standard extension (`-webkit-line-clamp`); `LayoutWrappedText` only invokes it when `WrapTextRespectingWhiteSpace` produced exactly one line and that line still overflows `lineMaxWidth`, leaving a genuinely multi-line wrapped result untouched. It finds the longest prefix (by *rune*, not raw UTF-16 `char`, so a truncation point never lands inside a surrogate pair) whose width plus the ellipsis character's own width still fits, via binary search over rune count rather than a linear character-by-character scan, since `MeasureTextWidth` goes through the backend's own font shaping and this runs on the hot layout path (on already-overflowing text specifically, so still rare in practice, but no reason to make it worse than necessary). + +`word-break: break-all` (`WrapTextCharacterWise`) and `overflow-wrap`/`word-wrap: break-word`/`anywhere` (`SplitOverlongWord`, called from inside `WrapText`) both extend the same greedy word-wrapping `WrapText` already implements, but at different granularities: `break-all` treats *every* character as a potential break point and abandons the word-based model entirely - each character becomes its own token with no separator width added around it, which reproduces ordinary space-based wrapping for free wherever a line happens to break at one, while still allowing a break mid-word wherever it does not (a deliberate, simpler approximation of the spec's own preference for word boundaries over arbitrary ones, defensible since a greedy per-character fill already tends to break near a word boundary when one is nearby). `overflow-wrap`/`word-wrap: break-word`/`anywhere` (folded into one `OverflowWrapMode.BreakWord` - the two keywords only differ in how they affect *intrinsic* (min-content) sizing, a concept this renderer's already-approximate, non-intrinsic text layout does not model) is narrower, matching the spec's own "last resort" framing precisely: `WrapText` only reaches for `SplitOverlongWord` when a *whole* word is wider than the entire line (not merely wider than what's left of the current line) - a word that simply doesn't fit the remainder of a line still wraps to a new line whole first, exactly like `overflow-wrap: normal`, and only a word that would overflow even a fresh, empty line gets broken mid-word. `word-break: break-all` takes precedence over `overflow-wrap` when both are set (matching spec) - `WrapText` checks `WordBreakMode.BreakAll` first and returns early via `WrapTextCharacterWise`, never consulting `OverflowWrapMode` at all in that case, since character-level breaking is already unrestricted. `LayoutInlineTextRun` (mixed inline content sharing a line with sibling text/elements) does not support either - the same documented scope cut already in place for `white-space`'s multi-space/forced-break handling there: that function's word-by-word model paints one whole word per `DrawText` command with no sub-word split point, unlike `WrapText`'s line-based model where a broken chunk can simply become its own line, so an overlong word in mixed inline content still overflows its line whole regardless of `word-break`/`overflow-wrap`. + +Current behavior includes block layout, margins, padding, borders, floats, inline-block, relative/fixed/absolute positioning, z-index ordering, outlines, text styling, text alignment, line-height, letter-spacing, text-indent, vertical-align, `white-space` collapsing/preservation, `text-overflow: ellipsis`, `word-break`/`overflow-wrap` line breaking, border-radius, box-shadow, text-shadow, list-item markers, overflow clipping, page-level scroll offsets, image and gradient backgrounds, form controls (including a focused text input's animated caret), flexbox, CSS Grid (auto-placement plus `fr`/`repeat()`/`minmax()` track sizing), 2D `transform`/`transform-origin`, CSS `filter`, CSS `opacity`, real `:hover` matching and CSS `transition`/`animation`/`@keyframes` for interactive documents, and generic font-family handling. SVG support: `` (including `data:` URIs) and inline `` markup both render, through two loading paths that converge on the same DOM-walking rasterizer (`Skia/Svg/`) - no third-party SVG parser is involved anywhere. An `` SVG source is sniffed and rasterized inside `TryLoadImageResource`, exactly where a PNG/JPEG source is decoded: `SvgRasterizer.TryRasterizeMarkup` parses the bytes with AngleSharp's own HTML/foreign-content parser (wrapped in a throwaway `` shell) and is cached per-document by URL like any other image. Inline `` has no URL and, more importantly, is already sitting in the host document's DOM - `SvgRasterizer.TryRasterizeElement` walks that element directly and is cached per-element in `s_inlineSvgCacheByElement`; it is never serialized back to text and re-parsed. `LayoutElement` empties `orderedChildren` for an `` root so its foreign-namespaced children are never walked as HTML flow content; both `` and inline `` are treated as a single replaced element. Rasterization always happens at the SVG's own natural (`viewBox`/`width`/`height`) size oversampled by a fixed factor (`SvgRasterizer.OversampleFactor`), not at the resolved CSS box size - the loader runs before CSS sizing is known, so this is a deliberate blur-vs-memory tradeoff rather than a per-render-size cache. diff --git a/CHANGELOG.md b/CHANGELOG.md index b54e04c..dbb29df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ Released on ?. - Updated to use the AngleSharp.Css gradient model (#10) +- Improved rendering of overflow with ellipsis (#11) - Added support for CSS grid track sizing (#7) # 0.4.0 diff --git a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs index 9d84398..80fdd0c 100644 --- a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs +++ b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs @@ -4854,6 +4854,116 @@ public async Task BuildDisplayList_TransformOpacityAndFilterNestInOpacityBetween // Mirrors the private HtmlRenderer.FormControlAccentColor constant (26, 115, 232) - kept as an // independent literal here rather than reflecting into the private field, so a test failure // reads as "the painted color changed" rather than needing reflection to even compile. + [Fact] + public async Task BuildDisplayList_TextOverflowEllipsisTruncatesOverflowingSingleLine() + { + var document = await ParseAsync(""" + +
          This is a long line of text
          + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 200, + ViewPortHeight = 120, + FontSize = 16f, + }); + + var text = Assert.Single(displayList.Commands.OfType()); + Assert.EndsWith("…", text.Text); + Assert.True(text.Text.Length < "This is a long line of text".Length); + } + + [Fact] + public async Task BuildDisplayList_TextOverflowEllipsisHasNoEffectWithoutClipping() + { + var document = await ParseAsync(""" + +
          This is a long line of text
          + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 200, + ViewPortHeight = 120, + FontSize = 16f, + }); + + var text = Assert.Single(displayList.Commands.OfType()); + Assert.Equal("This is a long line of text", text.Text); + } + + [Fact] + public async Task BuildDisplayList_WordBreakAllWrapsAnOverlongWordAcrossMultipleLines() + { + var document = await ParseAsync(""" + +
          aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
          + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 200, + ViewPortHeight = 200, + FontSize = 16f, + }); + + var lines = displayList.Commands.OfType().ToArray(); + + Assert.True(lines.Length > 1); + Assert.Equal("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", string.Concat(lines.Select(line => line.Text))); + } + + [Fact] + public async Task BuildDisplayList_OverflowWrapBreakWordBreaksAnOverlongWordAsLastResort() + { + var document = await ParseAsync(""" + +
          aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
          + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 200, + ViewPortHeight = 200, + FontSize = 16f, + }); + + var lines = displayList.Commands.OfType().ToArray(); + + Assert.True(lines.Length > 1); + Assert.Equal("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", string.Concat(lines.Select(line => line.Text))); + } + + [Fact] + public async Task BuildDisplayList_OverflowWrapNormalLeavesAnOverlongWordOnOneOverflowingLine() + { + var document = await ParseAsync(""" + +
          aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
          + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 200, + ViewPortHeight = 200, + FontSize = 16f, + }); + + var text = Assert.Single(displayList.Commands.OfType()); + Assert.Equal("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", text.Text); + } + private static readonly RenderColor FormControlAccentColorForTests = new(26, 115, 232); private static async Task ParseAsync(string html, IConfiguration? configuration = null, string? address = null) diff --git a/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs b/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs index 839be06..8ed77d2 100644 --- a/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs +++ b/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs @@ -2656,6 +2656,36 @@ public async Task RenderToPng_SizesGridTracksWithFrRepeatAndMinMax() maxDifferentPixels: 0); } + [Fact] + public async Task RenderToPng_TruncatesAndBreaksOverflowingText() + { + var document = await ParseAsync(""" + + + + + +
          This text is much too long to fit
          +
          Supercalifragilisticexpialidocious
          + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 160, + ViewPortHeight = 160, + FontSize = 16f, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "truncates-and-breaks-overflowing-text.png", + actualPng: image.Data, + perChannelTolerance: TextRenderingToleranceChannel, + maxDifferentPixels: TextRenderingToleranceMaxPixels); + } + private static async Task ParseAsync(string html, IConfiguration? configuration = null) { var context = BrowsingContext.New(configuration ?? Configuration.Default.WithCss()); diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/truncates-and-breaks-overflowing-text.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/truncates-and-breaks-overflowing-text.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..a04d1c2bca736f554bf0982915bd8745fccb9cd6 GIT binary patch literal 5949 zcmZvg1yodRx5p0%h;%6M(nu;Tsid?@OG$S(NFyVSw17jWz>9P<3?PVfOLvFT-Ebe) z{qDW*y5BczYE3-noM-R-`~UY2Q&yD4#UjUoAPD!BoRlhf&AfYHVu0uH0Sk8Ug61Oe zN(~d-d@xNzA&BJYmDEc$kJRlnZ)LS{s*dk4E1scZLf7#?#=M6yMg710nWRY3S=GbU z#2NG6>X6G=$oXT56415doqbEPUMkE%9W(D$90`rKM&~OJcB0}+;XcZbf+SAl9AEy-riT2X=i#$V_n1=m^tQ1z;ZabSOlhB~Td{ zg3#pTupY$3JS3u}d%_?tp&E>7mdIr!;a3$vwh*6?fXLN-iBezbywug4CO~)bfa%}= zAqNZm8UsCe^&gkCCpA=2A*`{RW=h-*=WJ>oE7mdMBAc20`oV(hw767|ty#!@r@6IN zaxX`j1%g8GX=JpulMNBfO6gKwUN7qF>nV4oIiskAf=3HATP=A{3Me(l7QZ#&%1E(y z{OQ|UY%TwA?WM1+p+OS=no(6%wT?GQeoYy{W6>X2TPqNZ<5c%bxIaxOWP7@nUO<2} zJ$(n!BBUKsc)?HR^crR}RDp8(KKop6=}KmXMRf zY-ni6RAwO{B@M~TV@i3yvbJV?dvguJLPIevdC8@urJXuT4ZcSO2BNV%#Paa+lE@ia z06Vl0H9j(8*d6~wPEm2KS|NY1YSCwD_- zB_$mCUQ$wM_nNGrGqd9PxPLeKf@tE;`eMae=>5n*9vWo1}%v$&~+1w8`;+JM-taE<-!pONJR zkm3+zKU0SiDH8y~zw=gG8w;xQyA_4cPft_qHXP!?(kHJzJz}LS873*&nU3p+T{{R^ z{bJBXIHRkptFy4Mh&wuR{Td#2TKs_?((^2`Fgqt_Xmr%!epbVj|(Dq%*C;kOULP-KE=8`-R)TIfvc#g1*WD_LNJ+}a_^GY zxu_7EUj4)K^YcOLY6wOpbnvZ=;;4)o^LtHlJ*^8+&sn6#7>=9m$# z=JEEg+&AExH*c16dsdy9;60vJ;D!SsU4MTzL`c-RkZiRiUbA~K%O}!K_G5yIv9z>= zMMvYG+D|^>6%bek6#)aJl8L5fKcgI=sw;HZX-*?28{}m6_3>F)US2+MmW`qei=~&X zZ_t%>)SOS}#aJPW@$e#efB$N$a$sf_Z|RA&GR&ks;w2!NUsVuUX2f4S?RI9^iV~PV z(gw-?LHB>M`tcd%UgPOptHX!GlpD*QOv%!57gjyY%~0(KK&^3u?Z4CjFg~{hLD~9k zW)>DmBvk-W7J439B+7%Z{|Ng#{rKk*?gS=x0Cj6;r#(%`-SPO(Xuf^;y9$F1Aw}#m zB$C2ryrhk1KsbQgq+L8$^(6;fGE3PI(6q#M85XofdJ#3O<6=8ehVuORb4*-ZX=CHZ zBi4M@*4B;-%_!RbQlafuC_A%AQpjup86iH^(8(t!|Lwt-b2n` zS=8SEdkuiI3mow^S<}-2%B`;=Vh#x*8r9X7oDu z*OE`d>o&Wy&Iu(qyfop+%6_HELT<;o}(HFZB! z#;RE|u~o~59jsocvCr?jW%u0>*WG?gP|MK^M-TvR1ZdrSlP?PgM|%>Fg)-P;zzd(f zmit;wK8Uxg{mFdcm#23rGBKgGHP=X?S*qW5w!bVr&7PPj<|pc~H9j8w%fEY*U&+V9u60`@cF zvaza?^28Ubj~XDBHXr)`S&ZEb%Ry{p^%Y#2337G)LSOEbu6yPRs3!3oQXGoSYrV-6m{=E?#Db z{o?8OTai1Si`||NBm&q@O;6wH)l8!|+@+$bL+oZB|7Tm9QG3K+on2iYOG`QJ=bIwI z^%#Q;AmcVhGd3~VFZH{|210*!ThlacfO&)?_xMZ3&g6_9&As$?X+Z~ zpwC&E^rd$S^OuB#a;xDyrw)+7LLPgx_nL3c2mGG3pPgVw=?X0|U#t)t^OC=ll)QI& zGVSnAT|FU4+3RpQTCAeG`}N7$zDZ$+0;*}zI$>wE&G?0vujBHKjc^ zJIg-usBmO#tis{@^%p(?0UZ&21A_q|ys1x9&{*U%Y{`X%g=yhEhzwcp2r}*;#{P!k zbdte_pep^(b&YBlHaD?B&{H}LJQjmQ6O<-G^c{&lKQlx-+d=Lwt#rky3IuFeU_`wA ztX*8Q_mBxg?A{uloN(Gdjd+_C%G4)Jq}TCNx7|7RS5=9gDMi&o39+^hAFZPXrmIbR zB|9{cRTdV8>ufxU>q=BDs5&2O)l01Hn(|l4lK#2|@LvHiY0gE4w#gGR7l^rhWReK# z^vCpciq6OE@lD{AVq#-I;!^O-5&bloW3o6rTAM+*Of>llIlH*9U#wWZ3uII@K>Qxg zH@P_aJ#z}=8xl7){i?UmO)+F;{CL1N&8AQxYR!rFLP6d)&^NV7VS5Ha40skzBi8~U9gW3GSHF=_wr&uFACWe3mbqBxz&{*d6>rZ|BVO9czl?|f6 zmjc7KcW~e@ktGf?JUKZzCMBhZefLvnC~zV#)zso8j|oqlF7P}qPwe93>jGj&eT*^m`y!yf9hXV2>3w~h1z!MGH`OH0OEKy(cJuK8SFP=Q9k zX=9MC;e0Jcxo~u%+~htu9G68uCDaUpWLNV_9vLY^Pr{)a3VeByb~RSd7j{V#6M8Ic z?4hZtPi19X5flP=FVgV#_xHgmFSVtF7DN+>TgI@s5gklgWN<@UhXZ2ktCgf_OiTy8 zRXe5C)d@Xsm6e;JvaUGhX}h>VhOhPY$tZ?k;mXO|kr6eFW@{ZQqF?S8c1O0F5|3OF*9zxlJ&F@oDFMc7_S~5P6d(78D~~DkESlf7taj`X zDl7*@Sy?C$0SQUF(7^aO>9eGw0lR~}J@J<>QBd#Qi%m<51Thf4y*iwzf6sbdbV7X1 zB!0NT%N=WxBO8qYajg83va>7h%Vd&G;bC>qj?FX2c;;O1d2Zx6!S1X77t{cjbh*MYKX6)93 zZxfxB>^e1fvWAI|-;pgHJ~%TI(U&5C{E7>75-fh!V{qfIsqcvnVBFc$a}NSN7X$+q zbnwvQH@@H5y*p~NX_YlI9uxZ#WS)!9^0shgSWb95Ufo>o=H%oAc&F#9J$2e!czLxS z?YGkZMozAIu{EUJs1>6~7ePcwNGRyKL9jNE;XsIQJjY}c7dB`0ED7iM`1owo;I=!r zVAR@nrjGD%ec(RSu-{HGxOqHL>p%lKSqvx@vZ`1@4b1Dh-e+kU>Z}CKDi_4Y&fdp( zEFd6I37kQAWWoiONHC$fc*D;sQqQr$K^Ty6T2WEz)?nOLP)_A`)BKQ#H%>h&@bUJ{ z_p|-}B0#O`Xbr(HciYx3(nEfMr)Oj+aWe$KP&k``!E*HOnL)ba14jYaPsVSXIHEwW z+YBS6g>WeNaY`Fcl1KVzXCQ@T%>QlV%?2NoaekCA_D{wff3|@(L3+t{=+gD6=USMs z(oojXERz783PA(cd#8!^=(&G!MIdSrf&PAT?~^SfU>r=dva)p3 zOPek}O`Dt-*UlLyB_+)?^){Nw@;l7+LS-{Ey6sv6OAru*73b%LrKJ`T*AI0b$8~AF zevLITnkB})x z%&J>hlgYHbwF|0taZkRnlu$UF39o<7>a{?BL+g zaM&-@+1nc^&(sPUzoK-LzHXb-omn?{uT8b~9Zq7pOz-xsp|KHRVv1yBS5%PrIidI5 zy72w=#d}k8b5scE>)r69aCNHkpaNsk9@jRN=)?jc8#7u@HdxEH=70S4UJX;B@(3w zsH@w?@!)3>&tulomM&tK`wSS&|7>pFAOBdB!1b}iT(JuXd^CF5y%${%l z_U2R!jAUAdhl#(RwQepGI`2qYV`d7$Y;YiF5I9~%n>@eT|QUdn~*Fa1)8bpR6(G+K`Ce#&yQ@E}{{*Or1pn*$7f z_rj}HPQR|o!DrIn6w+QKz#TRQv+igcNg)7*hF9>&+D;nNO>-~8@ITAn6T#M+o|p=XZrvK|)v^i;#{T!BdKjcjb0d8#6yaA7*#aH(9a$ zcm_5|J2WQl1>7(W6doOIVesLcQOJ1(lQeV0o2bHJUKI4|va*#xwcWNRxH;qsP6Knw z%3@N~X`VX={&szF(#4#y$3(mqM^J8d_t7-oN}Q6Y5g4k;GsW%ur#D^vVI}$NuP!Vs ztVG3p6N~;-qp+|r9O&|BSc!__9rm4BOeHaNcNggC?_ZPtyYcqsl8u))o>?5kRPMgxGH`GJ|I@s&@eyru&`kMR#>>WyBnQxd$rDg_}<-p`EvJr$H0dZp0o4w zNnaZkP0f(8F^vdn5$*VZ*olhWS!4*(efKe#Z~hP+lL7-9FzEtQh1N)gH0g~gLh%fA zxryMCG_IqV@c$kHXU_%7CvvsSZquf#qIJN~78N&`6aQ~M{Qvv=e;XkG{~xU0Cb#Bq V8l=An0P}F@m9(N%iG-p5e*wR0WJ~}6 literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer/HtmlRenderer.cs b/src/AngleSharp.Renderer/HtmlRenderer.cs index 97192c4..ace879c 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.cs @@ -330,7 +330,7 @@ private static DisplayList BuildDisplayList(IDocument document, RenderViewport v return displayList; } - var textStyle = new RenderTextStyle(context.FontSize, context.TextColor, context.FontFamily, context.LineHeightMultiplier, 400f, false, false, false, context.TextColor, global::AngleSharp.Renderer.Rendering.RenderTextDecorationStyle.Solid, TextAlign.Left, 0f, 0f, 0f, [], WhiteSpaceMode.Normal); + var textStyle = new RenderTextStyle(context.FontSize, context.TextColor, context.FontFamily, context.LineHeightMultiplier, 400f, false, false, false, context.TextColor, global::AngleSharp.Renderer.Rendering.RenderTextDecorationStyle.Solid, TextAlign.Left, 0f, 0f, 0f, [], WhiteSpaceMode.Normal, WordBreakMode.Normal, OverflowWrapMode.Normal, TextOverflowMode.Clip); var cursorY = contentY; var previousBlockMarginBottom = 0f; var suppressNextBlockTopMargin = false; @@ -2734,6 +2734,18 @@ private static void LayoutWrappedText( var lineWidth = MeasureTextWidth(context, line, textStyle); var lineMaxWidth = index == 0 ? Math.Max(0f, maxWidth - firstLineIndent) : maxWidth; + + // `text-overflow: ellipsis` is scoped to the single-line case - by far the dominant + // real-world usage (`overflow: hidden; white-space: nowrap; text-overflow: ellipsis`) - + // rather than truncating the last of several wrapped lines, which the CSS spec itself + // does not define without a non-standard extension (`-webkit-line-clamp`); a genuinely + // multi-line result here (`lines.Count > 1`) is left as-is, matching that scope cut. + if (textStyle.TextOverflow == TextOverflowMode.Ellipsis && lines.Count == 1 && lineWidth > lineMaxWidth) + { + line = TruncateWithEllipsis(context, line, lineMaxWidth, textStyle); + lineWidth = MeasureTextWidth(context, line, textStyle); + } + var lineX = x + (index == 0 ? firstLineIndent : 0f) + ResolveTextAlignmentOffset(textStyle.TextAlign, lineMaxWidth, lineWidth); var baselineY = cursorY + textStyle.VerticalAlignOffset; @@ -2816,7 +2828,11 @@ private static void LayoutInlineTextRun( // multi-space preservation and explicit forced breaks are not supported at this level (a // deliberate scope cut: the caller already collapsed any literal '\n' in `text` to a plain // space before it ever reaches here, since this word-by-word model has no way to represent - // one - see the two LayoutNode call sites that build `inlineText`). + // one - see the two LayoutNode call sites that build `inlineText`). `word-break: break-all`/ + // `overflow-wrap: break-word` are the same kind of scope cut, for the same reason: this + // model paints one whole word per DrawText call with no sub-word split point, unlike + // WrapText's line-based model where a broken chunk can simply become its own line - an + // overlong word here still overflows its line whole, exactly like `overflow-wrap: normal`. var noWrap = IsNoWrapWhiteSpace(textStyle.WhiteSpace); foreach (var word in words) @@ -3042,8 +3058,70 @@ private static RenderTextStyle ResolveTextStyle(Dictionary style var verticalAlignOffset = ParseVerticalAlign(styleMap, fontSize); var textShadows = ParseTextShadows(styleMap.TryGetValue("text-shadow", out var textShadowValue) ? textShadowValue : null, inherited.TextShadows); var whiteSpace = ParseWhiteSpace(styleMap, inherited.WhiteSpace); + var wordBreak = ParseWordBreak(styleMap, inherited.WordBreak); + var overflowWrap = ParseOverflowWrap(styleMap, inherited.OverflowWrap); + var textOverflow = ParseTextOverflow(styleMap); + + return new RenderTextStyle(fontSize, color, fontFamily, lineHeight, fontWeight, isItalic, underline, strikeThrough, decorationColor, decorationStyle, textAlign, letterSpacing, textIndent, verticalAlignOffset, textShadows, whiteSpace, wordBreak, overflowWrap, textOverflow); + } + + /// + /// word-break is inherited, the same as white-space above. + /// + private static WordBreakMode ParseWordBreak(Dictionary styleMap, WordBreakMode inherited) + { + if (!styleMap.TryGetValue("word-break", out var value) || string.IsNullOrWhiteSpace(value)) + { + return inherited; + } + + return string.Equals(value.Trim(), "break-all", StringComparison.OrdinalIgnoreCase) + ? WordBreakMode.BreakAll + : WordBreakMode.Normal; + } + + /// + /// overflow-wrap, falling back to its legacy word-wrap alias when the modern + /// property was not itself authored - both are inherited, the same as white-space above. + /// + private static OverflowWrapMode ParseOverflowWrap(Dictionary styleMap, OverflowWrapMode inherited) + { + var raw = styleMap.TryGetValue("overflow-wrap", out var value) && !string.IsNullOrWhiteSpace(value) + ? value + : (styleMap.TryGetValue("word-wrap", out var legacyValue) ? legacyValue : null); + + if (string.IsNullOrWhiteSpace(raw)) + { + return inherited; + } - return new RenderTextStyle(fontSize, color, fontFamily, lineHeight, fontWeight, isItalic, underline, strikeThrough, decorationColor, decorationStyle, textAlign, letterSpacing, textIndent, verticalAlignOffset, textShadows, whiteSpace); + return raw.Trim().ToLowerInvariant() switch + { + "break-word" or "anywhere" => OverflowWrapMode.BreakWord, + _ => OverflowWrapMode.Normal, + }; + } + + /// + /// text-overflow, unlike every other property resolved in , + /// is deliberately never inherited - every element re-derives it fresh from its own style map, + /// defaulting to even when an ancestor set + /// `text-overflow: ellipsis`, mirroring the same non-inheritance + /// already establishes for itself. It also has no effect unless this element's own `overflow` + /// clips (per spec, and matching 's existing "either axis" + /// simplification) - `ellipsis` on a box that does not clip is simply ignored, same as a real + /// browser. + /// + private static TextOverflowMode ParseTextOverflow(Dictionary styleMap) + { + if (!ShouldClipOverflow(styleMap)) + { + return TextOverflowMode.Clip; + } + + return styleMap.TryGetValue("text-overflow", out var value) && string.Equals(value.Trim(), "ellipsis", StringComparison.OrdinalIgnoreCase) + ? TextOverflowMode.Ellipsis + : TextOverflowMode.Clip; } /// @@ -3524,6 +3602,10 @@ private static Dictionary CreateStyleMap(ICssStyleDeclaration st AddIfPresent(map, "line-height", style.GetLineHeight()); AddIfPresent(map, "color", style.GetColor()); AddIfPresent(map, "white-space", style.GetPropertyValue("white-space")); + AddIfPresent(map, "text-overflow", style.GetPropertyValue("text-overflow")); + AddIfPresent(map, "word-break", style.GetPropertyValue("word-break")); + AddIfPresent(map, "overflow-wrap", style.GetPropertyValue("overflow-wrap")); + AddIfPresent(map, "word-wrap", style.GetPropertyValue("word-wrap")); ApplyActiveTransitionAndAnimationOverrides(map, element); @@ -6747,8 +6829,26 @@ private static float MeasureCellHeight( return Math.Max(20f, contentHeight + placement.PaddingTop + placement.PaddingBottom + placement.BorderTopWidth + placement.BorderBottomWidth); } + /// + /// Greedy word-wrapping, extended with `word-break: break-all`/`overflow-wrap: break-word` + /// support - both read straight off rather than as separate + /// parameters, since every caller already carries a fully-resolved . + /// `break-all` () breaks at any character boundary + /// everywhere, matching spec precedence over `overflow-wrap` (a word-break-all element ignores + /// `overflow-wrap` entirely, since breaking is already unrestricted). Otherwise, the ordinary + /// word-based algorithm below only reaches for character-level breaking () + /// as the spec's own "last resort": a single word wider than the *entire* line (not just what is + /// left of the current line) that `overflow-wrap: break-word`/`anywhere` explicitly permits + /// breaking - a word that merely doesn't fit what's left of the current line still simply wraps + /// to a new line whole, exactly like `overflow-wrap: normal`. + /// private static IReadOnlyList WrapText(LayoutContext context, string text, float maxWidth, RenderTextStyle textStyle) { + if (textStyle.WordBreak == WordBreakMode.BreakAll) + { + return WrapTextCharacterWise(context, text, maxWidth, textStyle); + } + var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries); if (words.Length == 0) @@ -6760,17 +6860,25 @@ private static IReadOnlyList WrapText(LayoutContext context, string text var current = new StringBuilder(); var currentWidth = 0f; - foreach (var word in words) + void FlushCurrentLine() { - var wordWidth = MeasureTextWidth(context, word, textStyle); - var separatorWidth = current.Length == 0 ? 0f : MeasureTextWidth(context, " ", textStyle); - - if (current.Length > 0 && currentWidth + separatorWidth + wordWidth > maxWidth) + if (current.Length > 0) { lines.Add(current.ToString()); current.Clear(); currentWidth = 0f; } + } + + void AppendToken(string token, float tokenWidth) + { + var separatorWidth = current.Length == 0 ? 0f : MeasureTextWidth(context, " ", textStyle); + + if (current.Length > 0 && currentWidth + separatorWidth + tokenWidth > maxWidth) + { + FlushCurrentLine(); + separatorWidth = 0f; + } if (current.Length > 0) { @@ -6778,8 +6886,111 @@ private static IReadOnlyList WrapText(LayoutContext context, string text currentWidth += separatorWidth; } - current.Append(word); - currentWidth += wordWidth; + current.Append(token); + currentWidth += tokenWidth; + } + + foreach (var word in words) + { + var wordWidth = MeasureTextWidth(context, word, textStyle); + + if (wordWidth > maxWidth && textStyle.OverflowWrap == OverflowWrapMode.BreakWord) + { + var chunks = SplitOverlongWord(context, word, maxWidth, textStyle); + + for (var i = 0; i < chunks.Count; i++) + { + if (i == 0) + { + // The first chunk of a broken word is still an ordinary word boundary - + // it gets ordinary inter-word wrapping/spacing against the current line. + AppendToken(chunks[i], MeasureTextWidth(context, chunks[i], textStyle)); + } + else + { + // A mid-word break always continues on a fresh line - there is no space to + // share the previous chunk's remaining room with. + FlushCurrentLine(); + current.Append(chunks[i]); + currentWidth = MeasureTextWidth(context, chunks[i], textStyle); + } + } + + continue; + } + + AppendToken(word, wordWidth); + } + + FlushCurrentLine(); + + return lines; + } + + /// + /// Splits one overlong word into the largest chunks that each fit within , + /// for overflow-wrap: break-word/anywhere's "last resort" mid-word break. Always + /// makes progress (appends at least one character per chunk) even if a single character alone + /// exceeds , so an extreme case (a huge font size in a tiny box) still + /// terminates rather than looping. + /// + private static List SplitOverlongWord(LayoutContext context, string word, float maxWidth, RenderTextStyle textStyle) + { + var chunks = new List(); + var current = new StringBuilder(); + var currentWidth = 0f; + + foreach (var rune in word.EnumerateRunes()) + { + var chStr = rune.ToString(); + var chWidth = MeasureTextWidth(context, chStr, textStyle); + + if (current.Length > 0 && currentWidth + chWidth > maxWidth) + { + chunks.Add(current.ToString()); + current.Clear(); + currentWidth = 0f; + } + + current.Append(chStr); + currentWidth += chWidth; + } + + if (current.Length > 0) + { + chunks.Add(current.ToString()); + } + + return chunks; + } + + /// + /// `word-break: break-all` wrapping: every character (not just every word) is a potential break + /// point, so this greedily fills each line character-by-character instead of word-by-word - a + /// space is simply a character like any other here (no separator width added around it), which + /// reproduces ordinary space-based wrapping for free wherever a line happens to break at one, + /// while still allowing a break mid-word wherever it does not. + /// + private static IReadOnlyList WrapTextCharacterWise(LayoutContext context, string text, float maxWidth, RenderTextStyle textStyle) + { + var lines = new List(); + var current = new StringBuilder(); + var currentWidth = 0f; + + foreach (var rune in text.EnumerateRunes()) + { + var chStr = rune.ToString(); + var chWidth = MeasureTextWidth(context, chStr, textStyle); + + if (current.Length > 0 && currentWidth + chWidth > maxWidth) + { + lines.Add(current.ToString()); + current.Clear(); + currentWidth = 0f; + } + + current.Append(chStr); + currentWidth += chWidth; } if (current.Length > 0) @@ -6790,6 +7001,48 @@ private static IReadOnlyList WrapText(LayoutContext context, string text return lines; } + private const string EllipsisCharacter = "…"; + + /// + /// Truncates to the longest prefix (by rune, not raw UTF-16 char, so a + /// truncation point never lands inside a surrogate pair) whose width plus the ellipsis + /// character's own width still fits within , then appends it - the + /// approach every real browser's own `text-overflow: ellipsis` uses (truncate, do not scale or + /// reflow). Binary search over rune count, not a linear scan, since + /// goes through the backend's own font shaping and this runs on already-overflowing text on the + /// hot layout path. + /// + private static string TruncateWithEllipsis(LayoutContext context, string text, float maxWidth, RenderTextStyle textStyle) + { + var ellipsisWidth = MeasureTextWidth(context, EllipsisCharacter, textStyle); + + if (ellipsisWidth > maxWidth || text.Length == 0) + { + return EllipsisCharacter; + } + + var runes = text.EnumerateRunes().ToArray(); + var low = 0; + var high = runes.Length; + + while (low < high) + { + var mid = (low + high + 1) / 2; + var prefixWidth = MeasureTextWidth(context, string.Concat(runes.Take(mid).Select(r => r.ToString())), textStyle); + + if (prefixWidth + ellipsisWidth <= maxWidth) + { + low = mid; + } + else + { + high = mid - 1; + } + } + + return string.Concat(runes.Take(low).Select(r => r.ToString())) + EllipsisCharacter; + } + private static RenderFont ToRenderFont(RenderTextStyle textStyle, FontFaceSet fonts) => new( textStyle.FontFamily, textStyle.FontSize, @@ -6953,7 +7206,10 @@ private readonly record struct RenderTextStyle( float TextIndent, float VerticalAlignOffset, IReadOnlyList TextShadows, - WhiteSpaceMode WhiteSpace); + WhiteSpaceMode WhiteSpace, + WordBreakMode WordBreak, + OverflowWrapMode OverflowWrap, + TextOverflowMode TextOverflow); private enum TextAlign { @@ -6977,6 +7233,43 @@ private enum WhiteSpaceMode BreakSpaces, } + /// + /// word-break. KeepAll (meant for CJK text, suppressing breaks between ideographic + /// characters that Normal would otherwise allow) is folded into Normal - this + /// renderer has no CJK-aware line-breaking of any kind to differentiate the two, a deliberate, + /// documented scope cut rather than an oversight. + /// + private enum WordBreakMode + { + Normal, + BreakAll, + } + + /// + /// overflow-wrap (and its legacy word-wrap alias). Anywhere is folded into + /// BreakWord - the two keywords only differ in how they affect *intrinsic* (min-content) + /// sizing, a concept this renderer's already-approximate, non-intrinsic text layout does not + /// model, so both simply mean "break an otherwise-unbreakable word as a last resort" here. + /// + private enum OverflowWrapMode + { + Normal, + BreakWord, + } + + /// + /// text-overflow. Unlike // + /// , this is deliberately never inherited - see + /// , which mirrors 's own + /// existing non-inheritance for the same reason (it targets this element's own line box, not a + /// descendant's). + /// + private enum TextOverflowMode + { + Clip, + Ellipsis, + } + private readonly record struct EdgeSizes(float Top, float Right, float Bottom, float Left); private enum BorderStyleKind From 0349d00b0d92fb7ba6388049a3187dfe579df4bd Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Fri, 11 Sep 2026 09:53:54 +0200 Subject: [PATCH 4/9] Implemented #9 --- AGENTS.md | 27 +++- CHANGELOG.md | 1 + .../VisualConformanceTests.cs | 72 +++++++++ ...ed-to-top-while-scrolled-past-it.macos.png | Bin 0 -> 321 bytes src/AngleSharp.Renderer/HtmlRenderer.cs | 137 +++++++++++++++++- .../Rendering/DisplayList.cs | 24 +++ 6 files changed, 253 insertions(+), 8 deletions(-) create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/sticky-header-stays-pinned-to-top-while-scrolled-past-it.macos.png diff --git a/AGENTS.md b/AGENTS.md index 738e86a..b2c75bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -152,7 +152,7 @@ Discovered while wiring this up: AngleSharp.Css had **no registered declaration `word-break: break-all` (`WrapTextCharacterWise`) and `overflow-wrap`/`word-wrap: break-word`/`anywhere` (`SplitOverlongWord`, called from inside `WrapText`) both extend the same greedy word-wrapping `WrapText` already implements, but at different granularities: `break-all` treats *every* character as a potential break point and abandons the word-based model entirely - each character becomes its own token with no separator width added around it, which reproduces ordinary space-based wrapping for free wherever a line happens to break at one, while still allowing a break mid-word wherever it does not (a deliberate, simpler approximation of the spec's own preference for word boundaries over arbitrary ones, defensible since a greedy per-character fill already tends to break near a word boundary when one is nearby). `overflow-wrap`/`word-wrap: break-word`/`anywhere` (folded into one `OverflowWrapMode.BreakWord` - the two keywords only differ in how they affect *intrinsic* (min-content) sizing, a concept this renderer's already-approximate, non-intrinsic text layout does not model) is narrower, matching the spec's own "last resort" framing precisely: `WrapText` only reaches for `SplitOverlongWord` when a *whole* word is wider than the entire line (not merely wider than what's left of the current line) - a word that simply doesn't fit the remainder of a line still wraps to a new line whole first, exactly like `overflow-wrap: normal`, and only a word that would overflow even a fresh, empty line gets broken mid-word. `word-break: break-all` takes precedence over `overflow-wrap` when both are set (matching spec) - `WrapText` checks `WordBreakMode.BreakAll` first and returns early via `WrapTextCharacterWise`, never consulting `OverflowWrapMode` at all in that case, since character-level breaking is already unrestricted. `LayoutInlineTextRun` (mixed inline content sharing a line with sibling text/elements) does not support either - the same documented scope cut already in place for `white-space`'s multi-space/forced-break handling there: that function's word-by-word model paints one whole word per `DrawText` command with no sub-word split point, unlike `WrapText`'s line-based model where a broken chunk can simply become its own line, so an overlong word in mixed inline content still overflows its line whole regardless of `word-break`/`overflow-wrap`. -Current behavior includes block layout, margins, padding, borders, floats, inline-block, relative/fixed/absolute positioning, z-index ordering, outlines, text styling, text alignment, line-height, letter-spacing, text-indent, vertical-align, `white-space` collapsing/preservation, `text-overflow: ellipsis`, `word-break`/`overflow-wrap` line breaking, border-radius, box-shadow, text-shadow, list-item markers, overflow clipping, page-level scroll offsets, image and gradient backgrounds, form controls (including a focused text input's animated caret), flexbox, CSS Grid (auto-placement plus `fr`/`repeat()`/`minmax()` track sizing), 2D `transform`/`transform-origin`, CSS `filter`, CSS `opacity`, real `:hover` matching and CSS `transition`/`animation`/`@keyframes` for interactive documents, and generic font-family handling. +Current behavior includes block layout, margins, padding, borders, floats, inline-block, relative/fixed/absolute/sticky positioning, z-index ordering, outlines, text styling, text alignment, line-height, letter-spacing, text-indent, vertical-align, `white-space` collapsing/preservation, `text-overflow: ellipsis`, `word-break`/`overflow-wrap` line breaking, `::before`/`::after` generated content, border-radius, box-shadow, text-shadow, list-item markers, overflow clipping, page-level scroll offsets, image and gradient backgrounds, form controls (including a focused text input's animated caret), flexbox, CSS Grid (auto-placement plus `fr`/`repeat()`/`minmax()` track sizing), 2D `transform`/`transform-origin`, CSS `filter`, CSS `opacity`, real `:hover` matching and CSS `transition`/`animation`/`@keyframes` for interactive documents, and generic font-family handling. SVG support: `` (including `data:` URIs) and inline `` markup both render, through two loading paths that converge on the same DOM-walking rasterizer (`Skia/Svg/`) - no third-party SVG parser is involved anywhere. An `` SVG source is sniffed and rasterized inside `TryLoadImageResource`, exactly where a PNG/JPEG source is decoded: `SvgRasterizer.TryRasterizeMarkup` parses the bytes with AngleSharp's own HTML/foreign-content parser (wrapped in a throwaway `` shell) and is cached per-document by URL like any other image. Inline `` has no URL and, more importantly, is already sitting in the host document's DOM - `SvgRasterizer.TryRasterizeElement` walks that element directly and is cached per-element in `s_inlineSvgCacheByElement`; it is never serialized back to text and re-parsed. `LayoutElement` empties `orderedChildren` for an `` root so its foreign-namespaced children are never walked as HTML flow content; both `` and inline `` are treated as a single replaced element. Rasterization always happens at the SVG's own natural (`viewBox`/`width`/`height`) size oversampled by a fixed factor (`SvgRasterizer.OversampleFactor`), not at the resolved CSS box size - the loader runs before CSS sizing is known, so this is a deliberate blur-vs-memory tradeoff rather than a per-render-size cache. @@ -200,6 +200,31 @@ Two real, confirmed AngleSharp.Css bugs were found and fixed upstream while buil - `CssTupleValue.Compute(ICssComputeContext)` unconditionally called `.Compute(context)` on every item in the tuple, crashing with a `NullReferenceException` on the extremely common `grid-column: 2 / span 2` (an omitted end-line is a `null` tuple item, by design). Fixed (a `null` item stays `null` rather than being computed), locked in by `AngleSharp.Css.Tests/Values/GridComputation.cs`. - `GapDeclaration`/`GridGapDeclaration`'s own `Longhands` arrays listed `column-gap`/`grid-column-gap` before `row-gap`/`grid-row-gap` - the reverse of what their own `Merge()`/`Split()` methods already assumed (`values[0]`/`Items[0]` is always treated as the row value by both) - so `gap: 10px 20px` (row-gap 10px, column-gap 20px per spec's ` ` order) computed as `row-gap: 20px; column-gap: 10px`, swapped. Fixed (both `Longhands` arrays reordered to `[row, column]`), locked in by `AngleSharp.Css.Tests/Styling/GapShorthandComputedStyleTests.cs`; a pre-existing test (`CssVariablesTests.GridGapShorthandWithVariableKeepsLonghands`) had encoded the old, swapped behavior as "expected" and was corrected to match. Two pre-existing tests in *this* repo had the same problem for the same reason - `BuildDisplayList_AppliesGridGapsToTrackPlacement` asserted a column offset consistent with the old swapped gap order, and `BuildDisplayList_AppliesAutoPlacementAcrossImplicitTracks` separately asserted an implicit auto-row height of `containerHeight / rowCount` (a coarse guess from before implicit rows grew to fit content the way explicit `Auto` tracks now do via `GrowGridTrackSize`) - both were corrected to their now-actually-correct values once these fixes landed. +`::before`/`::after` generated content (`content`): this was previously a complete gap, not a partial one - `RenderTreeBuilder.RenderElement` (AngleSharp.Css, backing `window.Render(device)`, which `HtmlRenderer.BuildDisplayList` calls) only ever walked `element.ChildNodes` for real DOM elements/text; it had no awareness of `IElement.Pseudo(...)` at all, so a pseudo-element never appeared in the render tree regardless of how it was styled - confirmed with a probe test before assuming anything, since a *separate*, independent API (`window.GetPseudoElements`) already resolved `::before`/`::after` selector matching and `content` correctly, which made it easy to wrongly assume the render tree already had this too. Fixed upstream in `RenderTreeBuilder.RenderElement` itself: for a real (non-pseudo) element, `element.Pseudo("before")`/`.Pseudo("after")` are resolved and, if `ContentDeclaration.HasContent` on the *computed* result is true (i.e. `content` is not `none` nor the initial/unset `normal` - the only values that parse to zero content modes), recursively rendered through the exact same `RenderElement` call every other child goes through, spliced as the first/last entry of `children` respectively. Recursing into a *pseudo* element specifically takes one different turn: `PseudoElement.ChildNodes` (`PseudoElement.cs`) aliases its *host's* real children (not its own generated content), so `RenderElement` special-cases `element is IPseudoElement` to skip the generic `ChildNodes` walk entirely and instead append exactly one synthetic `TextRenderNode` - built via `_window.Document.CreateTextNode(...)`, a real but DOM-detached `IText` - holding `ContentDeclaration.Stringify(computedStyle, element)`'s resolved text (reusing the exact per-mode `Stringify(IElement)` logic `ContentValueConverter`'s own mode types already implemented internally, just not previously reachable from outside that type - `ContentValue`/`HasContent`/`Stringify` were bumped from `private` to `internal` for this, no public API surface change at all). + +`ContentDeclaration.Stringify`/`HasContent` are the only two members of `content`'s own text-generation machinery this needed; the property's structured parsing (strings, `attr()`, `counter()`, `url()`, `open-quote`/`close-quote`/`no-open-quote`/`no-close-quote`) was already fully implemented internally, just never exposed. `attr()` (`AttributeContentMode.Stringify`) reads the *host's* own attribute directly (`element.GetAttribute(...)`, which `PseudoElement` already proxies through to its host), so `::before { content: attr(data-label); }` works with no renderer-side attribute-resolution code needed at all. `counter()`/`url()`/`open-quote`/`close-quote` all `Stringify` to an empty string (`ContentDeclaration.cs`'s own existing behavior, unchanged) - a full CSS counter implementation and image-valued generated content are both out of scope here, the same "implement what's visually representable through existing primitives, document the rest as a scope cut" precedent this renderer already applies elsewhere (e.g. `range`/`file` form inputs, unsupported SVG filter primitives). + +Discovered along the way, and fixed upstream in the same pass: an unstyled `::before`/`::after` computed `display: block` (this renderer's own general "unset display defaults to block" fallback, correct for most real elements but wrong for generated content specifically), because AngleSharp.Css's UA stylesheet had no rule for pseudo-elements at all - confirmed with a probe test before the fix (`display='block'`) and after (`display='inline'`). A real browser's own UA stylesheet gives generated content `display: inline` by default, and without it every `::before`/`::after` would start its own new block line instead of flowing inline with the rest of its host's content - the overwhelmingly common real-world case (an icon, a required-field marker, a breadcrumb separator, `attr()`-based labels, ...). Fixed with a targeted `*:before, *:after { display: inline }` UA-stylesheet addition (`CssDefaultStyleSheetProvider.cs`), the same "give the right initial value via a targeted UA rule" precedent already used for `list-style-type`'s own default. Four pre-existing tests in AngleSharp.Css's own suite had hardcoded counts that shifted by exactly one property/rule once this landed (`ConfigurationTests.ObtainDefaultSheet`'s UA-rule count, and three `AnalysisWindowTests` computed-style-property counts for a `::before`/`::after`) - all updated with a comment explaining the new `display` entry, not silently bumped. + +Two renderer-side call sites needed guarding once pseudo-elements could actually reach them, since `PseudoElement`'s `LocalName`/`TagName`/`GetAttribute(...)` all proxy straight through to its host (`PseudoElement.cs`) - without these, a pseudo-element attached to a tag this renderer treats specially would be misidentified as that same special case and have its handling duplicated: +- `ResolveFormControlKind` now returns `FormControlKind.None` unconditionally for `element is IPseudoElement` - otherwise an ``/``'s own generated-content pseudo would be mistaken for the replaced element itself and repaint the same image a second time. The `renderAsBlock` computation in `LayoutElement` (`IsReplacedElementTag(tagName)`) and the ``-root-empties-its-children check both got the same `element is not IPseudoElement`/`element is IPseudoElement` guard, for the same underlying reason - a generated-content pseudo should get its own independent `display` computation (inline, by default, as fixed above) and should still get to paint its own synthetic text child, entirely unrelated to whatever the host's own tag would otherwise force. +- Locked in by `BuildDisplayList_PseudoElementOnFormControlDoesNotDuplicateControlChrome`/`BuildDisplayList_PseudoElementOnImageDoesNotRepaintTheImage`. + +One more real, confirmed bug surfaced while verifying a pseudo-element could actually *paint*, not just exist in the tree: `LayoutElement`'s "generic plain inline element" fallback (mixed inline content sharing a line with siblings - not a `
          `, not inline-block, not plain text - the path a nested ``/``/etc. goes through) read `element.TextContent` directly to get that element's own contribution to the shared line. `PseudoElement.TextContent` is hardcoded to always be an empty string (`PseudoElement.cs` - it has no real DOM text content of its own to report), so this path silently produced no text at all for a pseudo-element - and, more subtly, a *host* element's own `.TextContent` also cannot see a pseudo *child's* generated text either way (pseudo-elements exist only in the render tree, never in a DOM `.TextContent` walk), so `` - empty DOM text, all its visible content coming from its own `::after` - vanished completely even though the render tree correctly carried it. Fixed by `ResolvePlainInlineElementText`, which walks the element's own render-tree children in order instead of reading `.TextContent` once - a `TextRenderNode`'s data, a pseudo child's own single synthetic text child, or (preserving the exact previous behavior for anything else, i.e. a real nested element) that child's own flat `.TextContent` - so the fallback keeps working exactly as before for ordinary nested elements while also correctly picking up pseudo-generated text. + +Two more inline-layout issues were found while building visual tests for this feature, both confirmed - via a git-stash sanity check against the unmodified, already-committed codebase, and reproduced with plain HTML carrying no pseudo-elements or custom CSS at all - to be pre-existing and unrelated to pseudo-elements specifically, not something this pass introduced or is in scope to fix (matching the standing "distinct, pre-existing bug, confirmed but deliberately left unfixed here to keep this change's blast radius contained" precedent already set for a similar inline-layout gap during the `inline-block` flow work): +- Multiple plain inline siblings sharing one line do not continue a shared `inlineCursorX` across separate parent-loop iterations in every circumstance - confirmed with three plain `` elements as *direct children of ``* each independently starting at `X=0` instead of continuing after the previous one, with no pseudo-elements involved at all. +- A block element whose entire own content triggers the "hasInlineRun" merge path (a nested inline element or a generated-content pseudo, with no direct text of its own) paints one line-height too high when it is not the very first element in the whole layout - confirmed with plain `

          Hello World

          ` (zero custom CSS, zero pseudo-elements) as the second element on a page rendering `World` on a separate, mispositioned line instead of beside "Hello" on the same one. This is why the generated-content visual test (`RenderToPng_RendersBeforeAndAfterGeneratedContent`) deliberately stays to a single first-and-only element on the page rather than demonstrating several stacked pseudo-bearing elements at once - not a limitation of pseudo-element support itself, but of this pre-existing surrounding machinery. + +`position: sticky` builds directly on the page-level scroll-offset machinery `position: fixed`/CSSOM-view scrolling already established (see the "Page-level scrolling" section above), rather than introducing a second scroll model: `BuildDisplayList` already lays out an entire scrolled page in one coordinate space where `Y = context.Padding` is the viewport's own top edge (the whole page's starting Y is shifted by `-scrollOffsetY` up front), so once inside `LayoutElement`, a sticky element's own natural (`flowBorderBoxY`) and the viewport's own top edge are already expressed in the *same* coordinates - "stick to `top: ` once scrolled past it" is therefore just `Math.Max(flowBorderBoxY, context.Padding + topOffset)` for the box's final paint Y, with no separate scroll lookup needed. Only `top` is supported (not `bottom`, which would need this element's own final height known *before* its Y can be decided - in conflict with the general auto-height-after-children flow every other box in this renderer follows - and not `left`/`right`, which this renderer's scroll model has no horizontal axis for at all, so a horizontally-scrolled position could never exist to stick against); both are a deliberate, documented scope cut. A sticky element with no `top` authored at all (`styleMap.ContainsKey("top")`) has nothing to stick to and behaves exactly like `static`, per spec - `ParseLength`'s own `allowAuto` fallback to `0` for an *unset* property is deliberately not treated as "top: 0px was authored" here, the same distinction grid track placement already had to make for its own optional properties. + +For every other purpose - margins, margin collapsing, cursor advancement (`cursorY = flowBorderBoxY + borderBoxHeight`, always the *natural*, not the possibly-stuck, position), painting order relative to `z-index`-bucketed `absolute`/`fixed` descendants - `position: sticky` is simply never matched by any of the existing `isAbsolute`/`isFixed` checks throughout `LayoutElement`, so it falls through to the same code every `static`/`relative` element already uses, with no changes needed there at all: it reserves its natural in-flow space regardless of whether it currently renders stuck, exactly matching spec. + +A real, confirmed bug surfaced immediately once a stuck sticky element could actually be seen next to real content: `DisplayList` is a flat, backend-agnostic command sequence emitted in the exact order elements are laid out, and every other positioning mode's final screen position stays close enough to its natural document-order slot that plain document-order painting already produces the right stacking - but a *stuck* sticky element's whole point is to end up at a screen position far from its natural one, routinely overlapping *later* siblings that would otherwise paint on top of it in plain document order. Caught by rendering the sticky-header visual test and seeing the header completely painted over by the content scrolling underneath it. The natural-seeming fix - folding `sticky` into `OrderChildrenForPainting`'s existing z-index bucketing, the same mechanism `absolute`/`fixed` already use to paint after `flow` content - turned out to be wrong: that function's output order is not only a *painting* order, it is also the *layout* order (`LayoutElement`'s own `orderedChildren`/the top-level page loop process children by iterating that exact sequence, threading `cursorY` through each call in turn), so reordering it to paint sticky last also *laid out* sticky last, corrupting `cursorY` for every sibling in between - confirmed by three previously-passing structural tests failing immediately. + +Fixed instead with `DisplayList.MoveRangeToEnd(startIndex, count)` (`Rendering/DisplayList.cs`), a new primitive alongside the existing `InsertRange`-based "splice a box's own background before its children" mechanism (see the box-painting-order section above) - but physically relocating an already-emitted range rather than inserting a new one. The top-level page loop (`BuildDisplayList`) records the command-index range each sticky child's own `LayoutNode` call adds (laid out in plain document order, exactly like every other page child, so its own layout stays correct) and moves each recorded range to the end, in original relative order, only *after* every page child has been laid out - each earlier move shifts every later range's true position left by however many commands it removed, so the loop subtracts that same cumulative shift from each subsequent range's originally-recorded index before moving it. This is deliberately scoped to sticky children of the *page* (direct children of ``/``, the loop `BuildDisplayList` itself owns) rather than plumbed into `LayoutElement`'s/`LayoutFlexContainer`'s/`LayoutGridContainer`'s own separate child-processing loops - covering the overwhelmingly common real-world case (a page-level sticky header/nav bar) without the largest, riskiest part of this change (the index-bookkeeping loop) needing to be duplicated into every container type that lays out children sequentially. A sticky element nested inside some other container (not a direct child of the page) still positions itself correctly per the `Math.Max` clamp above, but may still be visually painted-over by later siblings *within its own immediate parent* - a deliberate, documented scope cut, not a partial fix. + ## Code Conventions Follow the repository's existing C# style, which is defined by `.editorconfig` and the existing source files: diff --git a/CHANGELOG.md b/CHANGELOG.md index dbb29df..e0c59db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Released on ?. - Updated to use the AngleSharp.Css gradient model (#10) - Improved rendering of overflow with ellipsis (#11) - Added support for CSS grid track sizing (#7) +- Added support for `position: sticky` (#9) # 0.4.0 diff --git a/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs b/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs index 8ed77d2..5a39527 100644 --- a/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs +++ b/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs @@ -2686,6 +2686,78 @@ public async Task RenderToPng_TruncatesAndBreaksOverflowingText() maxDifferentPixels: TextRenderingToleranceMaxPixels); } + [Fact] + public async Task RenderToPng_RendersBeforeAndAfterGeneratedContent() + { + var document = await ParseAsync(""" + + + + + +
          Widget
          + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 180, + ViewPortHeight = 50, + FontSize = 16f, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-before-and-after-generated-content.png", + actualPng: image.Data, + perChannelTolerance: TextRenderingToleranceChannel, + maxDifferentPixels: TextRenderingToleranceMaxPixels); + } + + [Fact] + public async Task RenderToPng_StickyHeaderStaysPinnedToTopWhileScrolledPastIt() + { + var renderDevice = new DefaultRenderDevice + { + ViewPortWidth = 80, + ViewPortHeight = 100, + DeviceWidth = 80, + DeviceHeight = 100, + FontSize = 16, + }; + var configuration = Configuration.Default.WithCss().WithRenderDevice(renderDevice); + var document = await ParseAsync(""" + + + +
          +
          +
          +
          +
          +
          + + + """, configuration); + + document.Context.GetDomHarness(); + document.DocumentElement.SetScrollTop(80); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, renderDevice); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "sticky-header-stays-pinned-to-top-while-scrolled-past-it.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + private static async Task ParseAsync(string html, IConfiguration? configuration = null) { var context = BrowsingContext.New(configuration ?? Configuration.Default.WithCss()); diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/sticky-header-stays-pinned-to-top-while-scrolled-past-it.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/sticky-header-stays-pinned-to-top-while-scrolled-past-it.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..35c38b7b7ae04b41f4d2b51a90140b0a69a3d3f0 GIT binary patch literal 321 zcmeAS@N?(olHy`uVBq!ia0vp^0YIF>!3HD+e{B~7QY^(zo*^7SP{WbZ0p$Piba4!+ znDh3QBX5I&fa}3&3pn0a90>h*z(); + foreach (var child in OrderChildrenForPainting(root.Children)) { + var isStickyChild = child is ElementRenderNode stickyCandidate && + IsStickyPositioned(CreateStyleMap(stickyCandidate.ComputedStyle, stickyCandidate.Ref)); + var stickyStartIndex = displayList.Commands.Count; + LayoutNode( node: child, containingX: contentX, @@ -362,12 +377,28 @@ private static DisplayList BuildDisplayList(IDocument document, RenderViewport v displayList: displayList, maxY: maxY); + if (isStickyChild) + { + stickyRanges.Add((stickyStartIndex, displayList.Commands.Count - stickyStartIndex)); + } + if (cursorY > maxY) { break; } } + // Each earlier move shifts every later index left by however many commands it removed, so + // later ranges (still expressed in their original, pre-move indices) need that same + // cumulative shift subtracted before they are moved themselves. + var cumulativeShift = 0; + + foreach (var (startIndex, count) in stickyRanges) + { + displayList.MoveRangeToEnd(startIndex - cumulativeShift, count); + cumulativeShift += count; + } + // The page's own scrolling elements (/) are never laid out as boxes of their // own - the loop above only ever lays out *their children* directly onto the page canvas - // so neither ever gets an ordinary RecordLayoutMetrics call the way a normal descendant @@ -491,7 +522,12 @@ private static void LayoutElement( return; } - var renderAsBlock = ShouldRenderAsBlock(computedStyle) || IsReplacedElementTag(tagName); + // IsReplacedElementTag reads the host's own tagName, which a ::before/::after pseudo-element + // shares (PseudoElement.LocalName/TagName proxy through) - a real browser gives a generated- + // content pseudo its own independent display computation (defaulting to inline, per spec), + // entirely unrelated to whatever the host's tag would otherwise force, so this renderer must + // not either. + var renderAsBlock = ShouldRenderAsBlock(computedStyle) || (element is not IPseudoElement && IsReplacedElementTag(tagName)); var isInlineBlock = IsInlineBlock(computedStyle); var currentTextStyle = ResolveTextStyle(styleMap, inheritedTextStyle); @@ -563,6 +599,7 @@ private static void LayoutElement( var isAbsolute = string.Equals(position, "absolute", StringComparison.OrdinalIgnoreCase); var isFixed = string.Equals(position, "fixed", StringComparison.OrdinalIgnoreCase); var isRelative = string.Equals(position, "relative", StringComparison.OrdinalIgnoreCase); + var isSticky = string.Equals(position, "sticky", StringComparison.OrdinalIgnoreCase); var isFloatLeft = string.Equals(GetFloat(styleMap), "left", StringComparison.OrdinalIgnoreCase); if (isAbsolute || isFixed) @@ -625,11 +662,25 @@ private static void LayoutElement( : isAbsolute ? containingX + leftOffset : flowBorderBoxX + (isRelative ? leftOffset : 0f); + // `position: sticky` stays in normal flow for every other purpose (margins, cursor + // advancement, painting order - see the isAbsolute/isFixed checks elsewhere in this + // method, none of which match "sticky") - only its own final paint Y is adjusted here. + // This renderer already lays out an entire scrolled page in one coordinate space where + // Y=context.Padding is the viewport's own top edge (BuildDisplayList shifts the whole + // page's starting Y by -scrollOffsetY up front, so flowBorderBoxY arrives already + // viewport-relative) - "stick to `top` once scrolled past it" is therefore just clamping + // the element's own natural Y to never go above that threshold, with no separate scroll + // lookup needed. Only triggers when `top` is actually authored (`styleMap.ContainsKey`, + // not just defaulted to 0 via ParseLength's own allowAuto fallback below) - an + // unconstrained sticky element (no offset property at all) has nothing to stick to and + // behaves exactly like `static`, per spec. var borderBoxY = isFixed ? context.Padding + topOffset : isAbsolute ? containingY + topOffset - : flowBorderBoxY + (isRelative ? topOffset : 0f); + : isSticky && styleMap.ContainsKey("top") + ? Math.Max(flowBorderBoxY, context.Padding + topOffset) + : flowBorderBoxY + (isRelative ? topOffset : 0f); var contentX = borderBoxX + borderLeft + paddingLeft; var contentY = borderBoxY + borderTop + paddingTop; @@ -690,8 +741,13 @@ private static void LayoutElement( // the ordinary block child-layout path below is exactly what a browser's own