From 5a52786e2bce6961695369abcc8c0fc054541763 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Tue, 10 Nov 2026 12:20:22 +0100 Subject: [PATCH 01/13] Fixed rendering issues --- CHANGELOG.md | 7 ++ src/AngleSharp.Renderer.Docs/package.json | 2 +- .../TextRunLayoutTests.cs | 93 +++++++++++++++++++ .../HtmlRenderer.Inline.cs | 23 ++++- src/AngleSharp.Renderer/HtmlRenderer.cs | 27 +++++- src/Directory.Build.props | 2 +- 6 files changed, 146 insertions(+), 8 deletions(-) create mode 100644 src/AngleSharp.Renderer.Tests/TextRunLayoutTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index ff0ec63..eb7fc8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# 0.11.2 + +Released on Wednesday, November 11 2026. + +- Fixed adjacent text nodes (e.g. inserted by a script or a framework like Blazor) each starting a line of their own in a block, table cell or flex item: they are laid out as one text, like the single node the parser makes of the same text +- Fixed the intrinsic width of a row flex container counting each of adjacent text nodes as an item of its own (with a gap between them) + # 0.11.1 Released on Sunday, November 8 2026. diff --git a/src/AngleSharp.Renderer.Docs/package.json b/src/AngleSharp.Renderer.Docs/package.json index a69d227..72bef05 100644 --- a/src/AngleSharp.Renderer.Docs/package.json +++ b/src/AngleSharp.Renderer.Docs/package.json @@ -1,6 +1,6 @@ { "name": "@anglesharp/renderer", - "version": "0.11.1", + "version": "0.11.2", "preview": true, "description": "The doclet for the AngleSharp.Renderer documentation.", "keywords": [ diff --git a/src/AngleSharp.Renderer.Tests/TextRunLayoutTests.cs b/src/AngleSharp.Renderer.Tests/TextRunLayoutTests.cs new file mode 100644 index 0000000..0672d9b --- /dev/null +++ b/src/AngleSharp.Renderer.Tests/TextRunLayoutTests.cs @@ -0,0 +1,93 @@ +using AngleSharp; +using AngleSharp.Css; +using AngleSharp.Dom; + +namespace AngleSharp.Renderer.Tests; + +/// +/// Adjacent text nodes, as scripts and frameworks (e.g. Blazor) insert them, are laid out like +/// the single text node the parser would have made of the same text. +/// +public sealed class TextRunLayoutTests +{ + private const string Page = """ + + + +

+
+
+

+
+ + + """; + + [Theory] + [InlineData("p")] + [InlineData("td")] + [InlineData("flex")] + [InlineData("column")] + [InlineData("inline-flex")] + public async Task AdjacentTextNodes_AreLaidOutAsOneText(string id) + { + var single = await LayoutAsync(id, "Picture 4 of 4"); + var split = await LayoutAsync(id, "Picture ", "4", " of 4"); + + Assert.Equal(single.Width, split.Width, 3); + Assert.Equal(single.Height, split.Height, 3); + } + + [Fact] + public async Task AdjacentTextNodes_AroundACommentAreOneText() + { + var single = await LayoutAsync("p", "Loaded at 10:42"); + var document = await ParseAsync(); + var p = document.GetElementById("p")!; + p.AppendChild(document.CreateTextNode("Loaded at ")); + p.AppendChild(document.CreateComment("marker")); + p.AppendChild(document.CreateTextNode("10:42")); + document.Context.GetDomHarness().PaintToPng(); + + Assert.Equal(single.Height, p.GetBoundingClientRect().Height, 3); + } + + [Fact] + public async Task AdjacentTextNodes_AfterWhiteSpaceOnlyNodeKeepTheirText() + { + var single = await LayoutAsync("p", "Picture 4 of 4"); + var split = await LayoutAsync("p", " ", "Picture ", "4", " of 4"); + + Assert.Equal(single.Height, split.Height, 3); + } + + private static async Task<(double Width, double Height)> LayoutAsync(string id, params string[] texts) + { + var document = await ParseAsync(); + var element = document.GetElementById(id)!; + + foreach (var text in texts) + { + element.AppendChild(document.CreateTextNode(text)); + } + + document.Context.GetDomHarness().PaintToPng(); + var rect = element.GetBoundingClientRect(); + return (rect.Width, rect.Height); + } + + private static async Task ParseAsync() + { + var context = BrowsingContext.New(Configuration.Default + .WithCss() + .WithRenderDevice(new DefaultRenderDevice + { + ViewPortWidth = 400, + ViewPortHeight = 300, + DeviceWidth = 400, + DeviceHeight = 300, + FontSize = 16, + })); + return await context.OpenAsync(request => request.Content(Page)); + } +} diff --git a/src/AngleSharp.Renderer/HtmlRenderer.Inline.cs b/src/AngleSharp.Renderer/HtmlRenderer.Inline.cs index bef5199..269e447 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.Inline.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.Inline.cs @@ -64,10 +64,25 @@ void FlushRun(ref float cursorY, ref float previousBlockMarginBottom, ref bool s if (run.All(static node => node is TextRenderNode) && !floats.HasFloatsBelow(cursorY)) { // A run of bare text keeps the dedicated paragraph path, which also implements - // `text-overflow: ellipsis` and `-webkit-line-clamp`. + // `text-overflow: ellipsis` and `-webkit-line-clamp`. Adjacent text nodes (e.g. + // "Picture ", "4", " of 4" inserted by a script) are one paragraph, laid out + // from the first of them (see GetTextRunData). + HashSet? covered = null; + foreach (var textNode in run.Cast()) { - LayoutTextNode(textNode.Ref, contentX, contentWidth, ref cursorY, ref previousBlockMarginBottom, ref suppressNextBlockTopMargin, floats, ref textIndentConsumed, textStyle, context, displayList, maxY); + if (covered?.Contains(textNode.Ref) == true) + { + continue; + } + + if (textNode.Ref.NextSibling is IText or IComment) + { + covered ??= []; + AddFollowingTextRun(textNode.Ref, covered); + } + + LayoutTextNode(textNode.Ref, contentX, contentWidth, ref cursorY, ref previousBlockMarginBottom, ref suppressNextBlockTopMargin, floats, ref textIndentConsumed, textStyle, context, displayList, maxY, asTextRun: true); } } else @@ -3047,7 +3062,9 @@ private static IntrinsicWidths ComputeElementContentWidths(ElementRenderNode ele var item = child switch { ElementRenderNode childElement => ComputeOuterIntrinsicWidths(childElement, textStyle, percentageBase, context), - TextRenderNode textNode when !IsCollapsibleWhiteSpaceOnly(textNode.Ref.Data) => ComputeTextIntrinsicWidths(textNode.Ref.Data, textStyle, context, out _), + // a run of text nodes is one anonymous item, as when laid out + TextRenderNode textNode when !ContinuesTextRun(textNode.Ref) && GetTextRunData(textNode.Ref) is var runData && + !IsCollapsibleWhiteSpaceOnly(runData) => ComputeTextIntrinsicWidths(runData, textStyle, context, out _), _ => IntrinsicWidths.Zero, }; diff --git a/src/AngleSharp.Renderer/HtmlRenderer.cs b/src/AngleSharp.Renderer/HtmlRenderer.cs index eebc839..eae739f 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.cs @@ -5658,9 +5658,12 @@ private static void LayoutTextNode( RenderTextStyle textStyle, LayoutContext context, DisplayList displayList, - float maxY) + float maxY, + bool asTextRun = false) { - var text = ExpandTabs(context, ApplyTextTransform(NormalizeWhitespace(textNode.Data, textStyle.WhiteSpace), textStyle.TextTransform, previousEndsWord: false), textStyle); + // as a run, the node stands for the text nodes following it, too (see GetTextRunData) + var data = asTextRun ? GetTextRunData(textNode) : textNode.Data; + var text = ExpandTabs(context, ApplyTextTransform(NormalizeWhitespace(data, textStyle.WhiteSpace), textStyle.TextTransform, previousEndsWord: false), textStyle); if (text.Length == 0) { @@ -5671,7 +5674,7 @@ private static void LayoutTextNode( suppressNextBlockTopMargin = false; var (bandLeft, bandRight) = floats.GetBand(cursorY, textStyle.FontSize * textStyle.LineHeightMultiplier, containingX, containingX + containingWidth); - LayoutWrappedText(text, bandLeft, bandRight - bandLeft, ref cursorY, textStyle, context, displayList, maxY, textIndentConsumed ? 0f : textStyle.TextIndent + textStyle.MarkerIndent, textNode); + LayoutWrappedText(text, bandLeft, bandRight - bandLeft, ref cursorY, textStyle, context, displayList, maxY, textIndentConsumed ? 0f : textStyle.TextIndent + textStyle.MarkerIndent, textNode, isTextRun: asTextRun); textIndentConsumed = true; } @@ -6490,6 +6493,24 @@ private static bool ContinuesTextRun(IText text) return false; } + /// + /// Adds the text nodes following the given one in its run (see ). + /// + private static void AddFollowingTextRun(IText text, HashSet nodes) + { + for (var sibling = text.NextSibling; sibling is not null; sibling = sibling.NextSibling) + { + if (sibling is IText next) + { + nodes.Add(next); + } + else if (sibling is not IComment) + { + break; + } + } + } + /// /// The text of the run of text nodes starting with the given one (see ). /// diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 3af1fba..8edc507 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.11.1 + 0.11.2 enable latest true From 1b0e8519ad5863c5a0f85b8288643da770b17fe9 Mon Sep 17 00:00:00 2001 From: Michael Thyregod <2386496+michaelthyregod@users.noreply.github.com> Date: Tue, 10 Nov 2026 04:30:43 +0100 Subject: [PATCH 02/13] Leave out deep subtrees when the stack runs low Layout, intrinsic sizing, inline collection and margin collapsing recurse per nesting level with large frames, so 45 nested divs (18 tables, 28 flex containers) overflowed a 1.5 MB stack and ended the process. Each of these walks now checks RuntimeHelpers.TryEnsureSufficientExecutionStack and leaves the rest of the subtree out instead. --- .../HtmlRenderer.Inline.cs | 10 +++++++++- src/AngleSharp.Renderer/HtmlRenderer.cs | 20 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/AngleSharp.Renderer/HtmlRenderer.Inline.cs b/src/AngleSharp.Renderer/HtmlRenderer.Inline.cs index 269e447..4f57e1c 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.Inline.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.Inline.cs @@ -2,6 +2,7 @@ namespace AngleSharp.Renderer; using System.Globalization; using System.Linq; +using System.Runtime.CompilerServices; using System.Text; using AngleSharp.Css.Dom; @@ -544,7 +545,8 @@ public void Collect(IRenderNode node, RenderTextStyle style, int styleId, Inline private void CollectElement(ElementRenderNode element, RenderTextStyle parentStyle, InlineBox? parentBox) { - if (!GeneratesBoxes(element)) + // a deep subtree is left out once the stack runs low (see LayoutNode) + if (!GeneratesBoxes(element) || !RuntimeHelpers.TryEnsureSufficientExecutionStack()) { return; } @@ -2998,6 +3000,12 @@ private static IntrinsicWidths ComputeElementContribution(ElementRenderNode elem /// private static IntrinsicWidths ComputeContentIntrinsicWidths(ElementRenderNode element, RenderTextStyle textStyle, float percentageBase, LayoutContext context) { + // a deep subtree is left out once the stack runs low (see LayoutNode) + if (!RuntimeHelpers.TryEnsureSufficientExecutionStack()) + { + return new IntrinsicWidths(0f, 0f); + } + var widths = ComputeElementContentWidths(element, textStyle, percentageBase, context); if (s_layoutPass.Value is { } pass) diff --git a/src/AngleSharp.Renderer/HtmlRenderer.cs b/src/AngleSharp.Renderer/HtmlRenderer.cs index eae739f..45b4302 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.cs @@ -1237,6 +1237,13 @@ private static void LayoutNode( LayoutTextNode(textNode.Ref, containingX, containingWidth, ref cursorY, ref previousBlockMarginBottom, ref suppressNextBlockTopMargin, floats, ref textIndentConsumed, textStyle, context, displayList, maxY); return; case ElementRenderNode element: + // Every nesting level takes a large stack frame; when the stack runs low the rest of + // the subtree is left out instead of ending the process with a stack overflow. + if (!RuntimeHelpers.TryEnsureSufficientExecutionStack()) + { + return; + } + LayoutStickyAwareElement(element, containingX, containingY, containingWidth, ref cursorY, ref previousBlockMarginBottom, ref suppressNextBlockTopMargin, floats, ref textIndentConsumed, textStyle, context, displayList, maxY, isFlexItem, isRowDirection, flexMainSize, flexCrossSize, containingHeight, positionedContainingX, positionedContainingY, positionedContainingWidth, positionedContainingHeight, forcedBorderBoxWidth, isAtomicInline, forcedBorderBoxHeight, contentOffsetY, mainAxisMarginsPlaced, crossAxisMarginsPlaced); return; default: @@ -8304,6 +8311,13 @@ private static List GetAuthoredRules(IDocument document, string pr private static bool TryGetFirstCollapsibleChildTopMargin(ElementRenderNode node, float containingWidth, LayoutContext context, out float marginTop) { + // a deep subtree is left out once the stack runs low (see LayoutNode) + if (!RuntimeHelpers.TryEnsureSufficientExecutionStack()) + { + marginTop = 0f; + return false; + } + var throughPositive = 0f; var throughNegative = 0f; var hasThrough = false; @@ -8386,6 +8400,12 @@ private static bool TryGetFirstCollapsibleChildTopMargin(ElementRenderNode node, /// private static bool IsSelfCollapsing(ElementRenderNode element, LayoutContext context) { + // a deep subtree is left out once the stack runs low (see LayoutNode) + if (!RuntimeHelpers.TryEnsureSufficientExecutionStack()) + { + return false; + } + var styleMap = GetOrCreateStyleMap(context, element.Ref, element.ComputedStyle); if (!IsFlowBlockDisplay(styleMap) || IsReplacedElement(element.Ref) || ResolveFormControlKind(element) != FormControlKind.None) From 75afd46eb47da9f248c2bb8b8db675896855e995 Mon Sep 17 00:00:00 2001 From: Michael Thyregod <2386496+michaelthyregod@users.noreply.github.com> Date: Tue, 10 Nov 2026 04:37:15 +0100 Subject: [PATCH 03/13] Test rendering the deepest parsable nesting on a 1 MB stack Without the stack guard the test host crashes with a stack overflow. --- .../DeepNestingTests.cs | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/AngleSharp.Renderer.Tests/DeepNestingTests.cs diff --git a/src/AngleSharp.Renderer.Tests/DeepNestingTests.cs b/src/AngleSharp.Renderer.Tests/DeepNestingTests.cs new file mode 100644 index 0000000..93cc321 --- /dev/null +++ b/src/AngleSharp.Renderer.Tests/DeepNestingTests.cs @@ -0,0 +1,70 @@ +namespace AngleSharp.Renderer.Tests; + +using System.Text; +using System.Threading; +using AngleSharp; +using AngleSharp.Css; + +/// +/// Deeply nested documents: layout recurses per nesting level, so a deep enough tree must leave +/// its innermost content out rather than overflow the stack (which ends the whole process). +/// +public sealed class DeepNestingTests +{ + private static readonly DefaultRenderDevice Device = new() { ViewPortWidth = 300, ViewPortHeight = 200, FontSize = 16 }; + + // The HTML parser keeps at most 511 levels below the body, so this is the deepest markup can get. + private const int ParserDepthLimit = 511; + + // A thread-pool thread or a desktop app's thread commonly has 1 MB of stack. + private const int StackSize = 1024 * 1024; + + [Theory] + [InlineData("
", "
")] + [InlineData("x", "")] + [InlineData("
", "
")] + [InlineData("
", "
")] + [InlineData("
", "
")] + [InlineData("
", "
")] + [InlineData("
", "
")] + public async Task DeepestParsableNesting_RendersWithoutOverflowingTheStack(string open, string close) + { + var markup = new StringBuilder(""); + + for (var i = 0; i < ParserDepthLimit; i++) + { + markup.Append(open); + } + + markup.Append("leaf"); + + for (var i = 0; i < ParserDepthLimit; i++) + { + markup.Append(close); + } + + markup.Append(""); + var context = BrowsingContext.New(Configuration.Default.WithCss().WithRenderDevice(Device)); + var document = await context.OpenAsync(request => request.Content(markup.ToString())); + byte[]? png = null; + Exception? error = null; + var thread = new Thread(() => + { + try + { + png = new HtmlRenderer().RenderToPng(document, Device).Data; + } + catch (Exception ex) + { + error = ex; + } + }, StackSize); + + thread.Start(); + thread.Join(); + + Assert.Null(error); + Assert.NotNull(png); + Assert.NotEmpty(png); + } +} From 3464d33c8c06f8feb32f75c8d1b841b4db75c3b7 Mon Sep 17 00:00:00 2001 From: Michael Thyregod <2386496+michaelthyregod@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:12:30 +0100 Subject: [PATCH 04/13] Reuse nested trial layouts within a render A trial layout is laid out on its own, so the same inputs give the same result within one render. Nested tables and inline-blocks trial-laid out every level again for each level above it, doubling the work per level (20 nested tables took 16 s, 22 over a minute). Trials inside a trial, the ones the layout around them repeats, are now kept per element with their inputs (style map, width, inherited style, measuring mode) for the render, not inside a line-clamped box; incremental rendering drops them on every change. --- .../HtmlRenderer.Incremental.cs | 2 + .../HtmlRenderer.Inline.cs | 100 +++++++++++++++++- src/AngleSharp.Renderer/HtmlRenderer.cs | 4 + 3 files changed, 105 insertions(+), 1 deletion(-) diff --git a/src/AngleSharp.Renderer/HtmlRenderer.Incremental.cs b/src/AngleSharp.Renderer/HtmlRenderer.Incremental.cs index 68c39aa..938067d 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.Incremental.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.Incremental.cs @@ -1805,6 +1805,8 @@ private void Forget(ElementRenderNode root) } RemoveKeys(_context.MeasuredHeights, key => elements.Contains(key.Element)); + // a trial holds the layout of its whole subtree, so any change can make one stale + _context.TrialLayouts.Clear(); RemoveKeys(_context.GridItemHeights, key => elements.Contains(key.Element)); static void Collect(IRenderNode node, HashSet elements) diff --git a/src/AngleSharp.Renderer/HtmlRenderer.Inline.cs b/src/AngleSharp.Renderer/HtmlRenderer.Inline.cs index 269e447..99afdf3 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.Inline.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.Inline.cs @@ -2,6 +2,7 @@ namespace AngleSharp.Renderer; using System.Globalization; using System.Linq; +using System.Runtime.CompilerServices; using System.Text; using AngleSharp.Css.Dom; @@ -2650,6 +2651,82 @@ _ when value.EndsWith('%') => Parse(value[..^1]) / 100f * lineHeight, }; } + /// + /// What a trial layout of an element depends on: the style map it is laid out with (a + /// container in a writing-mode system of its own swaps it), the space and style it gets, + /// and how it is measured. + /// + private readonly record struct TrialLayoutKey( + Dictionary StyleMap, + float ContainingWidth, + RenderTextStyle InheritedStyle, + float ForcedBorderBoxWidth, + bool IsAtomicInline, + bool IsFlexOrGridItem, + float MaxY, + bool IsHeightOnly); + + private readonly record struct TrialLayoutResult( + ElementLayoutMetrics? Metrics, + float? LastBaselineY, + float? FirstBaselineY, + IReadOnlyDictionary Laidout, + float? AutoContentHeight); + + /// + /// A kept trial layout of an element, and the one kept before it for other inputs. + /// + private sealed class TrialLayoutEntry(TrialLayoutKey key) + { + public TrialLayoutKey Key { get; } = key; + + public TrialLayoutResult Result { get; set; } + + public TrialLayoutEntry? Next { get; set; } + } + + /// + /// Finds the kept trial layout of an element for these inputs (), or + /// gives a new entry to keep the trial in when it runs inside another trial: only those are + /// repeated by the layout around them. Nothing is kept inside a line-clamped box, whose + /// lines are counted. Not inlined, so that the large inputs stay out of the frame of + /// , which is on the stack once per nesting level. + /// + [MethodImpl(MethodImplOptions.NoInlining)] + private static TrialLayoutEntry? FindTrialLayout( + ElementRenderNode element, + float containingWidth, + in RenderTextStyle inheritedStyle, + in LayoutContext context, + float forcedBorderBoxWidth, + bool isAtomicInline, + bool isFlexOrGridItem, + float maxY, + bool isHeightOnly, + out bool found) + { + found = false; + + if (context.LineClampScopes.TryPeek(out _)) + { + return null; + } + + var key = new TrialLayoutKey(GetOrCreateStyleMap(context, element.Ref, element.ComputedStyle), containingWidth, inheritedStyle, + forcedBorderBoxWidth, isAtomicInline, isFlexOrGridItem, maxY, isHeightOnly); + + for (var entry = context.TrialLayouts.GetValueOrDefault(element.Ref); entry is not null; entry = entry.Next) + { + if (entry.Key == key) + { + found = true; + return entry; + } + } + + return s_trialDepth > 0 ? new TrialLayoutEntry(key) : null; + } + /// /// Lays out at the origin into a throwaway display list, capturing /// its own border box and the last text baseline painted inside it. Layout metrics recorded @@ -2668,6 +2745,18 @@ private static (ElementLayoutMetrics? Metrics, float? LastBaselineY, float? Firs float maxY = float.MaxValue, bool isHeightOnly = false) { + // A trial is laid out on its own (at the origin, with floats, positioned and sticky boxes + // of its own), so the same inputs give the same result within a render. Without reusing + // it, nested trials (cells of nested tables, nested inline-blocks) lay out every level + // again for each level above it (see FindTrialLayout). + var entry = FindTrialLayout(element, containingWidth, inheritedStyle, context, forcedBorderBoxWidth, isAtomicInline, isFlexOrGridItem, maxY, isHeightOnly, out var found); + + if (found) + { + var cached = entry!.Result; + return (cached.Metrics, cached.LastBaselineY, cached.FirstBaselineY, cached.Laidout, cached.AutoContentHeight); + } + var pass = s_layoutPass.Value; // a trial asking for more (e.g., the baseline of an inline-block) needs all of its boxes var wasHeightOnly = s_measuringHeightOnly; @@ -2727,7 +2816,16 @@ private static (ElementLayoutMetrics? Metrics, float? LastBaselineY, float? Firs isFlexItem: isFlexOrGridItem); var snapshot = capture.Snapshot(); - return (snapshot.TryGetValue(element.Ref, out var metrics) ? metrics : null, capture.LastBaselineY, capture.FirstBaselineY, snapshot, capture.AutoContentHeights.TryGetValue(element.Ref, out var autoHeight) ? autoHeight : null); + var result = new TrialLayoutResult(snapshot.TryGetValue(element.Ref, out var metrics) ? metrics : null, capture.LastBaselineY, capture.FirstBaselineY, snapshot, capture.AutoContentHeights.TryGetValue(element.Ref, out var autoHeight) ? autoHeight : null); + + if (entry is not null) + { + entry.Result = result; + entry.Next = context.TrialLayouts.GetValueOrDefault(element.Ref); + context.TrialLayouts[element.Ref] = entry; + } + + return (result.Metrics, result.LastBaselineY, result.FirstBaselineY, result.Laidout, result.AutoContentHeight); } finally { diff --git a/src/AngleSharp.Renderer/HtmlRenderer.cs b/src/AngleSharp.Renderer/HtmlRenderer.cs index eae739f..6820567 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.cs @@ -232,6 +232,10 @@ private readonly record struct LayoutContext( // only needed for what the render tree does not hold (e.g. ::placeholder), so it is built on demand public IStyleCollection? StyleCollection => LazyStyleCollection.Value; + // Trial layouts per element, for this render only (see TrialLayout). The inputs are + // large, so they are kept with each result rather than as keys of one big table. + public Dictionary TrialLayouts { get; } = new(); + // the device the styles are computed for, and the collections of the shadow trees (see GetStyleCollection) public IRenderDevice? Device { get; init; } From f10f93af37d341125145d7015e0fddd42be91638 Mon Sep 17 00:00:00 2001 From: Michael Thyregod <2386496+michaelthyregod@users.noreply.github.com> Date: Tue, 10 Nov 2026 04:37:56 +0100 Subject: [PATCH 05/13] Test that nested trial layouts grow linearly with the depth Without the trial cache, 6 nested tables take 63 trial layouts and 12 take 4095 (2^depth - 1). --- .../InteractiveFrameTests.cs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/AngleSharp.Renderer.Tests/InteractiveFrameTests.cs b/src/AngleSharp.Renderer.Tests/InteractiveFrameTests.cs index 61c0c22..7de4ca5 100644 --- a/src/AngleSharp.Renderer.Tests/InteractiveFrameTests.cs +++ b/src/AngleSharp.Renderer.Tests/InteractiveFrameTests.cs @@ -290,6 +290,26 @@ public async Task Frame_HitsAnInlineBoxOnSeveralLinesOnlyOnItsLines() Assert.Equal("box", document.ElementFromPoint(first.Left / 2, (first.Top + first.Bottom) / 2)?.Id); } + [Theory] + [InlineData("
", "
")] + [InlineData("
", "
")] + public async Task NestedTrialLayouts_GrowLinearlyWithTheDepth(string open, string close) + { + // Every level measures its content with a trial layout; were each level to lay out the + // levels below it again, doubling the depth would multiply the trials by about 2^6. + var shallow = await CountTrialLayoutsAsync(open, close, depth: 6); + var deep = await CountTrialLayoutsAsync(open, close, depth: 12); + + Assert.True(deep <= 3 * shallow, $"{shallow} trial layouts at depth 6, {deep} at depth 12"); + } + + private static async Task CountTrialLayoutsAsync(string open, string close, int depth) + { + var body = string.Concat(Enumerable.Repeat(open, depth)) + "leaf" + string.Concat(Enumerable.Repeat(close, depth)); + var (_, harness) = await OpenAsync(body); + return harness.PrepareFrame().Metrics.TrialLayouts; + } + private static async Task<(IDocument Document, IDomHarness Harness)> OpenAsync(string body) { var configuration = Configuration.Default.WithCss().WithRenderDevice(Device); From fa9df0ed6b92a9fa068ca63c51a321bf9d29b916 Mon Sep 17 00:00:00 2001 From: Michael Thyregod <2386496+michaelthyregod@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:59:36 +0100 Subject: [PATCH 06/13] Add the CHANGELOG entry for #54 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb7fc8f..3d607a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ Released on Wednesday, November 11 2026. +- Improved the performance of nested tables and inline-blocks, which were laid out twice as often with every level (#54) @michaelthyregod - Fixed adjacent text nodes (e.g. inserted by a script or a framework like Blazor) each starting a line of their own in a block, table cell or flex item: they are laid out as one text, like the single node the parser makes of the same text - Fixed the intrinsic width of a row flex container counting each of adjacent text nodes as an item of its own (with a gap between them) From 31e9c81b615e4d7239cc32384641f0f69ee46431 Mon Sep 17 00:00:00 2001 From: Michael Thyregod <2386496+michaelthyregod@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:00:30 +0100 Subject: [PATCH 07/13] Add the CHANGELOG entry for #55 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb7fc8f..20fd319 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Released on Wednesday, November 11 2026. - Fixed adjacent text nodes (e.g. inserted by a script or a framework like Blazor) each starting a line of their own in a block, table cell or flex item: they are laid out as one text, like the single node the parser makes of the same text - Fixed the intrinsic width of a row flex container counting each of adjacent text nodes as an item of its own (with a gap between them) +- Improved deeply nested pages: what is nested too deep for the stack is left out instead of ending the process with a stack overflow (#55) @michaelthyregod # 0.11.1 From ac0a1ebd7cc97bdbe056ff16f72be5fa6b9f9ed9 Mon Sep 17 00:00:00 2001 From: Michael Thyregod <2386496+michaelthyregod@users.noreply.github.com> Date: Thu, 12 Nov 2026 04:40:04 +0100 Subject: [PATCH 08/13] Count the nested trial layouts on a thread with a large stack Laid out only a few times, the layout stays in the first JIT tier, whose frames are large: 12 nested inline-blocks overflowed the test thread's stack on macOS. With tiered compilation off, devel and the trial cache need the same stack. --- .../InteractiveFrameTests.cs | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/AngleSharp.Renderer.Tests/InteractiveFrameTests.cs b/src/AngleSharp.Renderer.Tests/InteractiveFrameTests.cs index 7de4ca5..70fdf60 100644 --- a/src/AngleSharp.Renderer.Tests/InteractiveFrameTests.cs +++ b/src/AngleSharp.Renderer.Tests/InteractiveFrameTests.cs @@ -1,5 +1,7 @@ namespace AngleSharp.Renderer.Tests; +using System.Runtime.ExceptionServices; + using AngleSharp; using AngleSharp.Css; using AngleSharp.Dom; @@ -303,11 +305,37 @@ public async Task NestedTrialLayouts_GrowLinearlyWithTheDepth(string open, strin Assert.True(deep <= 3 * shallow, $"{shallow} trial layouts at depth 6, {deep} at depth 12"); } + private const int NestedLayoutStackSize = 16 * 1024 * 1024; + private static async Task CountTrialLayoutsAsync(string open, string close, int depth) { var body = string.Concat(Enumerable.Repeat(open, depth)) + "leaf" + string.Concat(Enumerable.Repeat(close, depth)); var (_, harness) = await OpenAsync(body); - return harness.PrepareFrame().Metrics.TrialLayouts; + var trials = 0; + Exception? error = null; + // Laid out only a few times, the layout stays in the first JIT tier, whose frames are + // large: 12 levels need more stack than test threads have on some platforms (macOS). + var thread = new Thread(() => + { + try + { + trials = harness.PrepareFrame().Metrics.TrialLayouts; + } + catch (Exception ex) + { + error = ex; + } + }, NestedLayoutStackSize); + + thread.Start(); + thread.Join(); + + if (error is not null) + { + ExceptionDispatchInfo.Capture(error).Throw(); + } + + return trials; } private static async Task<(IDocument Document, IDomHarness Harness)> OpenAsync(string body) From 0d0d459ce3fe0e80175017af1f91d68d3b1ef2c7 Mon Sep 17 00:00:00 2001 From: Michael Thyregod <2386496+michaelthyregod@users.noreply.github.com> Date: Thu, 12 Nov 2026 04:40:47 +0100 Subject: [PATCH 09/13] Leave nested tables and inline-blocks out of the deep nesting test Each of their levels lays out the levels below it twice, so up to the depth the stack allows the test never completed in CI. They come back once the nested trial layouts are reused (#54). --- src/AngleSharp.Renderer.Tests/DeepNestingTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/AngleSharp.Renderer.Tests/DeepNestingTests.cs b/src/AngleSharp.Renderer.Tests/DeepNestingTests.cs index 93cc321..5843949 100644 --- a/src/AngleSharp.Renderer.Tests/DeepNestingTests.cs +++ b/src/AngleSharp.Renderer.Tests/DeepNestingTests.cs @@ -19,14 +19,14 @@ public sealed class DeepNestingTests // A thread-pool thread or a desktop app's thread commonly has 1 MB of stack. private const int StackSize = 1024 * 1024; + // Nested tables and inline-blocks are not here yet: each level lays out the levels below it + // twice (a trial and the real layout), so up to the depth the stack allows that takes hours. [Theory] [InlineData("
", "
")] [InlineData("x", "")] [InlineData("
", "
")] [InlineData("
", "
")] - [InlineData("
", "
")] [InlineData("
", "
")] - [InlineData("
", "
")] public async Task DeepestParsableNesting_RendersWithoutOverflowingTheStack(string open, string close) { var markup = new StringBuilder(""); From bc0a1021c42fa3310112320dad10f7010d37a73c Mon Sep 17 00:00:00 2001 From: Michael Thyregod <2386496+michaelthyregod@users.noreply.github.com> Date: Thu, 12 Nov 2026 09:33:17 +0100 Subject: [PATCH 10/13] Test nested tables and inline-blocks at the deepest parsable nesting again With the nested trial layouts reused (#54), they render in well under a second. --- src/AngleSharp.Renderer.Tests/DeepNestingTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/AngleSharp.Renderer.Tests/DeepNestingTests.cs b/src/AngleSharp.Renderer.Tests/DeepNestingTests.cs index 5843949..93cc321 100644 --- a/src/AngleSharp.Renderer.Tests/DeepNestingTests.cs +++ b/src/AngleSharp.Renderer.Tests/DeepNestingTests.cs @@ -19,14 +19,14 @@ public sealed class DeepNestingTests // A thread-pool thread or a desktop app's thread commonly has 1 MB of stack. private const int StackSize = 1024 * 1024; - // Nested tables and inline-blocks are not here yet: each level lays out the levels below it - // twice (a trial and the real layout), so up to the depth the stack allows that takes hours. [Theory] [InlineData("
", "
")] [InlineData("x", "")] [InlineData("
", "
")] [InlineData("
", "
")] + [InlineData("
", "
")] [InlineData("
", "
")] + [InlineData("
", "
")] public async Task DeepestParsableNesting_RendersWithoutOverflowingTheStack(string open, string close) { var markup = new StringBuilder(""); From c3b12ff9d2e76decc02bc2846ffa3d2bb720a5ea Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Thu, 12 Nov 2026 09:45:02 +0100 Subject: [PATCH 11/13] Updated contributors --- CONTRIBUTORS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index ac38197..92c0ba6 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -5,6 +5,7 @@ Thanks :heart: to all who contributed to AngleSharp.Renderer via issues, pull re AngleSharp.Renderer contains code written by (in order of first pull request / commit): * [Florian Rappl](https://github.com/FlorianRappl) +* [Michael Thyregod](https://github.com/michaelthyregod) Without these awesome people AngleSharp.Renderer could not exist. Thanks to everyone for your contributions! :beers: From 2af6e08346e7da7cb9be616cd22420b2bf4cf7aa Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Thu, 12 Nov 2026 10:36:35 +0100 Subject: [PATCH 12/13] Performance improvement --- CHANGELOG.md | 5 +- .../BASELINE.md | 17 +++ .../ParallelLayoutTests.cs | 32 ++++++ .../ViewportFrameTests.cs | 25 +++++ .../HtmlRenderer.Sticky.cs | 18 ++- src/AngleSharp.Renderer/HtmlRenderer.cs | 104 ++++++++++++++---- 6 files changed, 176 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bc276e..cf0ec0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,12 @@ # 0.11.2 -Released on Wednesday, November 11 2026. +Released on Thursday, November 12 2026. +- Improved deeply nested pages: what is nested too deep for the stack is left out instead of ending the process with a stack overflow (#55) @michaelthyregod - Improved the performance of nested tables and inline-blocks, which were laid out twice as often with every level (#54) @michaelthyregod +- Improved performance with partial layouting due to an off-screen lookahead which avoids full style-map creation - Fixed adjacent text nodes (e.g. inserted by a script or a framework like Blazor) each starting a line of their own in a block, table cell or flex item: they are laid out as one text, like the single node the parser makes of the same text - Fixed the intrinsic width of a row flex container counting each of adjacent text nodes as an item of its own (with a gap between them) -- Improved deeply nested pages: what is nested too deep for the stack is left out instead of ending the process with a stack overflow (#55) @michaelthyregod # 0.11.1 diff --git a/src/AngleSharp.Renderer.Benchmarks/BASELINE.md b/src/AngleSharp.Renderer.Benchmarks/BASELINE.md index 73b8517..36e176d 100644 --- a/src/AngleSharp.Renderer.Benchmarks/BASELINE.md +++ b/src/AngleSharp.Renderer.Benchmarks/BASELINE.md @@ -47,6 +47,23 @@ a per-run-deterministic number and does not have this problem. ## History +### 2026-11-12, viewport-only first-frame rendering + +Measured on Linux with .NET SDK 10.0.100, Release, viewport 1280x800. A fresh document was +parsed before each timer; each result is the median of the final six iterations in a 12-iteration +process. These exploratory probe results are not BenchmarkDotNet confidence intervals; absolute +times vary on this shared machine. + +| Fixture | Before: time / allocated | After: time / allocated | Work avoided | +|---|---:|---:|---| +| 1,500 block rows | 186 ms / 31.1 MiB | 107 ms / 19.9 MiB | Style maps: 1,521 โ†’ 37 | +| 1,500 fixed-height flex-column rows | 495 ms / 111.9 MiB | 397 ms / 83.7 MiB | Trial layouts: 1,500 โ†’ 0; text measurements: 144,000 โ†’ 72,000 | + +Both fixtures still laid out 37 and 3,003 boxes, respectively. The block result comes from avoiding +full style maps for off-screen siblings during lookahead; the flex result avoids trial-measuring +definite item heights before their actual layout. The viewport test suite separately checks pixel +equivalence and parallel-vs-sequential style preparation. + ### 2026-09-11, initial baseline (before any optimization) | Method (net10.0) | Mean | Allocated | diff --git a/src/AngleSharp.Renderer.Tests/ParallelLayoutTests.cs b/src/AngleSharp.Renderer.Tests/ParallelLayoutTests.cs index dedbdd0..62fd481 100644 --- a/src/AngleSharp.Renderer.Tests/ParallelLayoutTests.cs +++ b/src/AngleSharp.Renderer.Tests/ParallelLayoutTests.cs @@ -55,6 +55,29 @@ public async Task CreatingStyleMapsInParallelGivesTheSameFrame() } } + [Fact] + public async Task OnDemandFlexItemStylesCanBeCreatedInParallel() + { + var items = string.Concat(Enumerable.Repeat( + """
A fixed-height flex item
""", 240)); + + var parallel = await LayOutViewportAsync(items); + AppContext.SetSwitch(Switch, true); + + try + { + var sequential = await LayOutViewportAsync(items); + + Assert.True(parallel.IsPartial); + Assert.Equal(sequential.Boxes, parallel.Boxes); + Assert.Equal(sequential.Pixels, parallel.Pixels); + } + finally + { + AppContext.SetSwitch(Switch, false); + } + } + private static async Task<(int Elements, string Boxes, byte[] Pixels)> LayOutAsync(string body) { var context = BrowsingContext.New(Configuration.Default.WithCss().WithRenderDevice(Device)); @@ -64,4 +87,13 @@ public async Task CreatingStyleMapsInParallelGivesTheSameFrame() var boxes = string.Join('\n', document.All.Select(element => element.GetBoundingClientRect()).Select(rect => $"{rect.Left} {rect.Top} {rect.Width} {rect.Height}")); return (document.All.Length, boxes, pixels); } + + private static async Task<(bool IsPartial, int Boxes, byte[] Pixels)> LayOutViewportAsync(string items) + { + var context = BrowsingContext.New(Configuration.Default.WithCss().WithRenderDevice(Device)); + var document = await context.OpenAsync(request => request.Content( + $"""
{items}
""")); + var frame = document.Context.GetDomHarness().PrepareViewportFrame(); + return (frame.IsPartial, frame.Metrics.Boxes, frame.Rasterize().Data); + } } diff --git a/src/AngleSharp.Renderer.Tests/ViewportFrameTests.cs b/src/AngleSharp.Renderer.Tests/ViewportFrameTests.cs index c12ffb6..376f348 100644 --- a/src/AngleSharp.Renderer.Tests/ViewportFrameTests.cs +++ b/src/AngleSharp.Renderer.Tests/ViewportFrameTests.cs @@ -33,6 +33,31 @@ public async Task ViewportFrame_OfATallPageStylesAndLaysOutWhatIsInView() Assert.Null(frame.HitTestMap.HitTest(10, 190 + 2000)); } + [Fact] + public async Task ViewportFrame_DoesNotBuildStyleMapsForOffscreenSiblings() + { + var offscreen = string.Concat(Enumerable.Repeat("""
Offscreen
""", 300)); + var (_, harness) = await OpenAsync("""
Above the fold
""" + offscreen); + + var frame = harness.PrepareViewportFrame(); + + Assert.True(frame.IsPartial); + Assert.True(frame.Metrics.StyleMaps < 20, $"Created {frame.Metrics.StyleMaps} style maps for content outside the viewport."); + } + + [Fact] + public async Task ViewportFrame_AutoHeightColumnWithDefiniteItemHeightsDoesNotTrialLayoutItems() + { + var items = string.Concat(Enumerable.Repeat("""
A fixed-height flex item
""", 30)); + var (_, harness) = await OpenAsync("""
""" + items + "
"); + + var partial = harness.PrepareViewportFrame(); + var whole = harness.PrepareFrame(); + + Assert.Equal(0, partial.Metrics.TrialLayouts); + AssertSamePixels(whole.Rasterize(), partial.Rasterize()); + } + [Fact] public async Task ViewportFrame_LooksLikeAFrameOfTheWholePage() { diff --git a/src/AngleSharp.Renderer/HtmlRenderer.Sticky.cs b/src/AngleSharp.Renderer/HtmlRenderer.Sticky.cs index bbf5bcb..fe0b600 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.Sticky.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.Sticky.cs @@ -84,11 +84,23 @@ private static bool ContainsBottomStickyBox(IEnumerable nodes, Layo continue; } - var styleMap = GetOrCreateStyleMap(context, element.Ref, element.ComputedStyle); + var isSticky = context.StyleMapCache.TryGetValue(element.Ref, out var cachedStyle) + ? IsStickyPositioned(cachedStyle) + : string.Equals(element.ComputedStyle.GetPropertyValue("position"), "sticky", StringComparison.OrdinalIgnoreCase); // a tree built on demand is not built for this (what is below a viewport is left out) - if ((IsStickyPositioned(styleMap) && !float.IsNaN(ParseLength(styleMap, "bottom", 0f, float.NaN, allowAuto: true))) || - (s_layoutPass.Value?.BuiltNodes is not { } built || built.Contains(element)) && ContainsBottomStickyBox(element.Children, context)) + if (isSticky) + { + var styleMap = cachedStyle ?? GetOrCreateStyleMap(context, element.Ref, element.ComputedStyle); + + if (!float.IsNaN(ParseLength(styleMap, "bottom", 0f, float.NaN, allowAuto: true))) + { + return true; + } + } + + if ((s_layoutPass.Value?.BuiltNodes is not { } built || built.Contains(element)) && + ContainsBottomStickyBox(element.Children, context)) { return true; } diff --git a/src/AngleSharp.Renderer/HtmlRenderer.cs b/src/AngleSharp.Renderer/HtmlRenderer.cs index b68aa35..0c6996d 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.cs @@ -2790,6 +2790,7 @@ private static void LayoutFlexContainer( // percentage heights of the items refer to the container's height, if it is definite (else they are auto) var definiteContentHeight = ResolveAuthoredDimension(styleMap, "height", containingHeight, float.NaN, borderTop + borderBottom + paddingTop + paddingBottom); var children = OrderChildrenForLayout(FlattenDisplayContents(node.Children, context), context).ToList(); + CreateStyleMapsInParallel(children.OfType().ToList(), context); // absolutely positioned children do not take part in the flex layout var outOfFlowChildren = children .OfType() @@ -3594,6 +3595,7 @@ private static void LayoutGridContainer( var gridItems = FlattenDisplayContents(node.Children, context) .Where(child => child is ElementRenderNode || (child is TextRenderNode textNode && NormalizeWhitespace(textNode.Ref.Data).Length > 0)) .ToList(); + CreateStyleMapsInParallel(gridItems.OfType().ToList(), context); // absolutely positioned children take no cell; they are laid out once the grid's size is known var outOfFlowChildren = gridItems .OfType() @@ -5953,13 +5955,24 @@ private static IEnumerable FlattenDisplayContents(IEnumerable FlattenDisplayContents(IEnumerable styleMap) { return styleMap.TryGetValue("display", out var display) ? display : null; @@ -6179,6 +6205,19 @@ private static FlexItemLayoutInfo CreateFlexItemLayoutInfo(IRenderNode child, bo hasDeferredMainSize = true; deferredMeasureWidth = crossSize > 0f ? crossSize + crossBorderAndPadding : relativeTo; } + else if (!isRowDirection && deferringAlignItems is not null && + !float.IsNaN(specifiedMainSize) && + float.IsNaN(ResolveFlexBaseSize(childStyle, mainRelativeTo, mainBorderAndPadding)) && + GetContentSizeKeyword(childStyle, mainProperty) is null) + { + // In an auto-height, forward, non-wrapping column, a definite item height with an + // automatic flex basis already supplies its base size. Its automatic minimum cannot + // grow past that specified size, so measuring the subtree to find the content-based + // minimum cannot change the hypothetical size. The item is still laid out once at + // its final flexed size below. + contentMainSize = specifiedMainSize; + contentMinimum = specifiedMainSize; + } else { var measureWidth = crossSize > 0f ? crossSize + crossBorderAndPadding : relativeTo; @@ -7972,14 +8011,42 @@ private static void CreateStyleMapsInParallel(IRenderNode renderTree, LayoutCont var nodes = new List(); CollectElementNodes(renderTree, nodes, context); - if (nodes.Count < MinStyleMapsCreatedInParallel || Environment.ProcessorCount < 2 || + CreateStyleMapsInParallel(nodes, context); + + static void CollectElementNodes(IRenderNode node, List nodes, LayoutContext context) + { + if (node is ElementRenderNode element && !context.StyleMapCache.ContainsKey(element.Ref)) + { + nodes.Add(element); + } + + foreach (var child in node.Children) + { + CollectElementNodes(child, nodes, context); + } + } + } + + private static void CreateStyleMapsInParallel(List candidates, LayoutContext context) + { + List? nodes = null; + + foreach (var node in candidates) + { + if (!context.StyleMapCache.ContainsKey(node.Ref)) + { + (nodes ??= []).Add(node); + } + } + + if (nodes is null || nodes.Count < MinStyleMapsCreatedInParallel || Environment.ProcessorCount < 2 || (AppContext.TryGetSwitch(DisableParallelismSwitch, out var isDisabled) && isDisabled)) { return; } // what is gathered on first use, before the threads read it - if (renderTree is ElementRenderNode { Ref.Owner: { } document }) + if (nodes[0].Ref.Owner is { } document) { GetAuthoredRules(document, "-webkit-line-clamp"); GetAuthoredRules(document, "overflow-wrap"); @@ -8007,18 +8074,6 @@ private static void CreateStyleMapsInParallel(IRenderNode renderTree, LayoutCont } } - static void CollectElementNodes(IRenderNode node, List nodes, LayoutContext context) - { - if (node is ElementRenderNode element && !context.StyleMapCache.ContainsKey(element.Ref)) - { - nodes.Add(element); - } - - foreach (var child in node.Children) - { - CollectElementNodes(child, nodes, context); - } - } } /// @@ -8676,8 +8731,12 @@ private static bool IsNegativeZIndexChild(IRenderNode child, LayoutContext conte return false; } - var styleMap = GetOrCreateStyleMap(context, elementChild.Ref, elementChild.ComputedStyle); - return HasExplicitZIndex(styleMap) && ParseZIndex(styleMap) < 0; + if (context.StyleMapCache.TryGetValue(elementChild.Ref, out var styleMap)) + { + return HasExplicitZIndex(styleMap) && ParseZIndex(styleMap) < 0; + } + + return ParseZIndex(elementChild.ComputedStyle.GetPropertyValue("z-index")) < 0; } private static bool IsStickyPositioned(Dictionary styleMap) => @@ -8685,7 +8744,12 @@ private static bool IsStickyPositioned(Dictionary styleMap) => private static int ParseZIndex(Dictionary styleMap) { - if (!styleMap.TryGetValue("z-index", out var value) || string.IsNullOrWhiteSpace(value)) + return ParseZIndex(styleMap.GetValueOrDefault("z-index")); + } + + private static int ParseZIndex(string? value) + { + if (string.IsNullOrWhiteSpace(value)) { return 0; } From 27ff8fd3947416f0459fa2fbac67068d16af3519 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Thu, 12 Nov 2026 11:21:16 +0100 Subject: [PATCH 13/13] Improved first-time rendering performance --- CHANGELOG.md | 3 + .../BASELINE.md | 29 +++ .../HtmlRendererTests.cs | 21 ++ .../ViewportFrameTests.cs | 30 +++ .../HtmlRenderer.Incremental.cs | 3 +- src/AngleSharp.Renderer/HtmlRenderer.cs | 200 ++++++++++++++---- 6 files changed, 243 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf0ec0f..4785e8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ Released on Thursday, November 12 2026. - Improved deeply nested pages: what is nested too deep for the stack is left out instead of ending the process with a stack overflow (#55) @michaelthyregod - Improved the performance of nested tables and inline-blocks, which were laid out twice as often with every level (#54) @michaelthyregod - Improved performance with partial layouting due to an off-screen lookahead which avoids full style-map creation +- Improved the first frame of pages with long flex containers: the lines of a wrapping row and the items of a column that start below the viewport are not laid out +- Improved the performance of flex rows: an item with a definite width only measures its content when its line has to shrink +- Improved the garbage collection while building style maps (about 20% less allocated per frame) - Fixed adjacent text nodes (e.g. inserted by a script or a framework like Blazor) each starting a line of their own in a block, table cell or flex item: they are laid out as one text, like the single node the parser makes of the same text - Fixed the intrinsic width of a row flex container counting each of adjacent text nodes as an item of its own (with a gap between them) diff --git a/src/AngleSharp.Renderer.Benchmarks/BASELINE.md b/src/AngleSharp.Renderer.Benchmarks/BASELINE.md index 36e176d..05489bf 100644 --- a/src/AngleSharp.Renderer.Benchmarks/BASELINE.md +++ b/src/AngleSharp.Renderer.Benchmarks/BASELINE.md @@ -47,6 +47,35 @@ a per-run-deterministic number and does not have this problem. ## History +### 2026-11-13, flex cutoffs below the viewport, deferred flex minimums, fewer allocations + +Same probe as the entry below (Linux, .NET 10, Release, viewport 1280x800), but each result is +the median of the last 15 of 30 iterations. "Viewport" is `PrepareViewportFrame`, "full" is +`PrepareFrame`. The machine is shared, so a single run can be off by up to 70%; the numbers +below were repeated until they were stable. + +| Fixture | Before | After | +|---|---:|---:| +| Dashboard (flex/grid cards), viewport | 329 ms | 81 ms | +| Dashboard, full frame | 296 ms / 81.6 MiB | 281 ms / 66.1 MiB | +| 1,500 fixed-height flex-column rows, viewport | 349 ms | 128 ms | +| 1,500 block rows, viewport | 165 ms | 98 ms | + +- A wrapping flex row of an auto height stops laying out lines once a line starts below the + viewport, because its line positions depend only on the lines before it. A flex column that is + not reversed stops laying out items in the same case. A bottom-sticky box among the rest + disables both cutoffs, as it does in block flow. +- A row flex item with a definite width and an automatic `flex-basis` no longer measures its + min-content width up front, because its hypothetical size is its width. The automatic minimum + is resolved only for the items of a line that has to shrink. On the dashboard this halved the + text measurements, from 31,567 to 16,046. +- Allocation cuts in style-map parsing: span-based comma splitting, allocation-free pixel + parsing, fast paths for zero radii and for the initial `background-position`, and an early + return when there is no background image. They saved 15 MiB per dashboard frame with no + measurable change in time. + +The pixels of all three fixtures are identical to before. + ### 2026-11-12, viewport-only first-frame rendering Measured on Linux with .NET SDK 10.0.100, Release, viewport 1280x800. A fresh document was diff --git a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs index 88ed036..d6c8cd8 100644 --- a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs +++ b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs @@ -9214,6 +9214,27 @@ public async Task BuildDisplayList_FlexItemsDoNotShrinkBelowTheirContentUnlessAl Assert.Equal(expectedWidth, SingleFill(BuildDisplayList(document), Red).Rect.Width, precision: 2); } + [Theory] + [InlineData("nowrap", "width:100px;", 80f)] + [InlineData("nowrap", "width:100px; min-width:0;", 50f)] + [InlineData("nowrap", "width:100px; overflow:hidden;", 50f)] + [InlineData("nowrap", "width:30px;", 30f)] + [InlineData("wrap", "width:150px;", 100f)] + [InlineData("wrap", "width:40px;", 40f)] + public async Task BuildDisplayList_FlexItemsWithADefiniteWidthShrinkOnlyToTheirAutomaticMinimum(string wrap, string style, float expectedWidth) + { + var document = await ParseAsync($""" + +
+
+
+
+ + """); + + Assert.Equal(expectedWidth, SingleFill(BuildDisplayList(document), Red).Rect.Width, precision: 2); + } + [Fact] public async Task BuildDisplayList_FlexShrinkIsWeightedByTheBaseSize() { diff --git a/src/AngleSharp.Renderer.Tests/ViewportFrameTests.cs b/src/AngleSharp.Renderer.Tests/ViewportFrameTests.cs index 376f348..5f6f022 100644 --- a/src/AngleSharp.Renderer.Tests/ViewportFrameTests.cs +++ b/src/AngleSharp.Renderer.Tests/ViewportFrameTests.cs @@ -58,6 +58,36 @@ public async Task ViewportFrame_AutoHeightColumnWithDefiniteItemHeightsDoesNotTr AssertSamePixels(whole.Rasterize(), partial.Rasterize()); } + [Fact] + public async Task ViewportFrame_WrappingFlexRowLaysOutOnlyTheLinesInView() + { + var cards = string.Concat(Enumerable.Range(0, 120).Select(index => $"""

Card {index}

with text
""")); + var (_, harness) = await OpenAsync("""
""" + cards + "
" + TallPage); + + var partial = harness.PrepareViewportFrame(); + var whole = harness.PrepareFrame(); + + Assert.True(partial.IsPartial); + Assert.True(partial.Metrics.TrialLayouts * 4 < whole.Metrics.TrialLayouts, $"{partial.Metrics.TrialLayouts} trial layouts in view, {whole.Metrics.TrialLayouts} in all."); + AssertSamePixels(whole.Rasterize(), partial.Rasterize()); + } + + [Theory] + [InlineData("height: 40px")] + [InlineData("padding: 4px")] + public async Task ViewportFrame_ColumnLaysOutOnlyTheItemsInView(string itemStyle) + { + var items = string.Concat(Enumerable.Range(0, 200).Select(index => $"""
Item {index}
""")); + var (_, harness) = await OpenAsync("""
""" + items + "
" + TallPage); + + var partial = harness.PrepareViewportFrame(); + var whole = harness.PrepareFrame(); + + Assert.True(partial.IsPartial); + Assert.True(partial.Metrics.Boxes * 4 < whole.Metrics.Boxes, $"{partial.Metrics.Boxes} boxes laid out in view, {whole.Metrics.Boxes} in all."); + AssertSamePixels(whole.Rasterize(), partial.Rasterize()); + } + [Fact] public async Task ViewportFrame_LooksLikeAFrameOfTheWholePage() { diff --git a/src/AngleSharp.Renderer/HtmlRenderer.Incremental.cs b/src/AngleSharp.Renderer/HtmlRenderer.Incremental.cs index 938067d..afa4985 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.Incremental.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.Incremental.cs @@ -1658,7 +1658,8 @@ private bool LooksTheSameToContainer(ElementRenderNode node, LayoutEntry entry, return false; } - var info = CreateFlexItemLayoutInfo(node, item.IsRowDirection, flex.RelativeTo, flex.HeightRelativeTo, entry.TextStyle, _context, flex.DeferringAlignItems); + // an item whose automatic minimum its line resolved is compared with it resolved + var info = CreateFlexItemLayoutInfo(node, item.IsRowDirection, flex.RelativeTo, flex.HeightRelativeTo, entry.TextStyle, _context, flex.DeferringAlignItems, resolvesMinimum: !flex.Info.DefersMinimum); return info with { Node = null!, Style = null! } == flex.Info && (float.IsNaN(flex.EstimatedCrossSize) || EstimateFlexItemCrossSize(node, flex.FinalMainSize, item.IsRowDirection, entry.TextStyle, _context, flex.CrossAvailable) == flex.EstimatedCrossSize); diff --git a/src/AngleSharp.Renderer/HtmlRenderer.cs b/src/AngleSharp.Renderer/HtmlRenderer.cs index 0c6996d..a04168e 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.cs @@ -2635,7 +2635,8 @@ private readonly record struct FlexItemLayoutInfo( bool HasDeferredMainSize = false, float DeferredMeasureWidth = float.NaN, float CrossMarginStart = 0f, - float CrossMarginEnd = 0f) + float CrossMarginEnd = 0f, + bool DefersMinimum = false) { /// The margins along the cross axis, where an auto margin (NaN) counts as zero. public float CrossMargins => UsedMargin(CrossMarginStart) + UsedMargin(CrossMarginEnd); @@ -2936,6 +2937,22 @@ void MeasureDeferredItemsUpTo(float remainingSpace) lines.Add(currentLine); } + // Items with a deferred automatic minimum (see CreateFlexItemLayoutInfo) have the same + // hypothetical size with it, so the lines are the same; a line that has to shrink, where + // the minimum limits how far, gets it now. + for (var lineIndex = 0; lineIndex < lines.Count; lineIndex++) + { + var line = lines[lineIndex]; + + if (line.Exists(item => item.DefersMinimum) && + line.Sum(item => item.HypotheticalMainSize) >= containerMainSize - Math.Max(0, line.Count - 1) * mainGap - line.Sum(item => item.MainMargins)) + { + lines[lineIndex] = line.Select(item => item.DefersMinimum + ? CreateFlexItemLayoutInfo(item.Node, isRowDirection, containingWidth, definiteContentHeight, inheritedTextStyle, context, resolvesMinimum: true) + : item).ToList(); + } + } + if (string.Equals(flexWrap, "wrap", StringComparison.OrdinalIgnoreCase) && lines.Count > 1 && containerCrossSize > 0f) { var availableCrossSpace = Math.Max(0f, containerCrossSize - Math.Max(0, lines.Count - 1) * crossGap); @@ -2972,8 +2989,10 @@ float EstimateCrossSize(FlexItemLayoutInfo item, float mainSize) } // a line's cross size follows from its items' final main sizes (CSS Flexbox ยง9.4): text - // that wraps once shrunk is taller than at the hypothetical size, leaving less free space - var lineCrossSizes = lines.Select(line => + // that wraps once shrunk is taller than at the hypothetical size, leaving less free space; + // a container without a cross size has none to share, so the lines are only sized as they + // are laid out (which lets the lines below the viewport go unmeasured, see below) + var lineCrossSizes = containerCrossSize <= 0f ? [] : lines.Select(line => { if (line.Count == 0) { @@ -3038,6 +3057,16 @@ float EstimateCrossSize(FlexItemLayoutInfo item, float mainSize) for (var lineIndex = 0; lineIndex < lines.Count; lineIndex++) { var line = lines[lineIndex]; + + // As in a block flow, lines starting below the viewport are not laid out: without a + // cross size to share, the lines above do not depend on them. + if (lineIndex > 0 && isRowDirection && containerCrossSize <= 0f && containingY + currentCrossOffset > maxY && + !ContainsBottomStickyBox(lines.Skip(lineIndex).SelectMany(rest => rest).Select(item => item.Node), context)) + { + NoteCutOff(maxY); + break; + } + var lineGap = Math.Max(0, line.Count - 1) * mainGap; var lineMargins = line.Sum(item => item.MainMargins); var lineItems = new List<(FlexItemLayoutInfo Item, float FinalMainSize)>(line.Count); @@ -3100,6 +3129,18 @@ float EstimateCrossSize(FlexItemLayoutInfo item, float mainSize) for (var itemIndex = 0; itemIndex < lineItems.Count; itemIndex++) { var (item, finalMainSize) = lineItems[itemIndex]; + + // As in a block flow, the items of a column starting below the viewport are not + // laid out: their main sizes, and so the column's, are known (unless the ones + // laid out to measure them are needed to share the free space again). + if (!isRowDirection && !isReverseDirection && laidOutHeights is null && + containingY + lineMainStart + currentMainOffset + (float.IsNaN(item.MainMarginStart) ? autoMarginSize : item.MainMarginStart) > maxY && + !ContainsBottomStickyBox(lineItems.Skip(itemIndex).Select(entry => entry.Item.Node), context)) + { + NoteCutOff(maxY); + break; + } + var resolvedCrossSize = item.CrossSize; var estimatedCrossSize = item.HasDefiniteCrossSize ? 0f @@ -3286,7 +3327,7 @@ float EstimateCrossSize(FlexItemLayoutInfo item, float mainSize) } var autoContentHeight = isRowDirection - ? finalLineCrossSizes.Sum() + Math.Max(0, lines.Count - 1) * crossGap + ? finalLineCrossSizes.Sum() + Math.Max(0, finalLineCrossSizes.Count - 1) * crossGap : Math.Max(0f, containerMainSize); var specifiedContentHeight = ResolveAuthoredDimension(styleMap, "height", containingHeight, float.NaN, verticalBorderAndPadding); var minimumContentHeight = ResolveAuthoredDimension(styleMap, "min-height", containingHeight, 0f, verticalBorderAndPadding); @@ -6103,7 +6144,7 @@ private static string GetAlignSelf(Dictionary styleMap, string f return normalized == "auto" ? fallback : normalized; } - private static FlexItemLayoutInfo CreateFlexItemLayoutInfo(IRenderNode child, bool isRowDirection, float relativeTo, float heightRelativeTo, RenderTextStyle inheritedTextStyle, LayoutContext context, string? deferringAlignItems = null) + private static FlexItemLayoutInfo CreateFlexItemLayoutInfo(IRenderNode child, bool isRowDirection, float relativeTo, float heightRelativeTo, RenderTextStyle inheritedTextStyle, LayoutContext context, string? deferringAlignItems = null, bool resolvesMinimum = false) { if (child is TextRenderNode textChild) { @@ -6174,7 +6215,18 @@ private static FlexItemLayoutInfo CreateFlexItemLayoutInfo(IRenderNode child, bo var hasDeferredMainSize = false; var deferredMeasureWidth = float.NaN; - if (isRowDirection) + var defersMinimum = false; + var minRaw = childStyle.GetValueOrDefault(isRowDirection ? "min-width" : "min-height")?.Trim(); + + if (isRowDirection && !resolvesMinimum && CanSkipFlexItemIntrinsicWidths(childStyle, specifiedMainSize, mainRelativeTo, mainBorderAndPadding, minRaw, out defersMinimum)) + { + // A definite width with an automatic flex basis is the base size, and the automatic + // minimum can only be smaller, so the hypothetical size is the same without it. Only + // a line that has to shrink reads the minimum: LayoutFlexContainer resolves it there. + contentMainSize = specifiedMainSize; + contentMinimum = specifiedMainSize; + } + else if (isRowDirection) { var intrinsic = TryResolveReplacedContentWidth(elementChild, childStyle, relativeTo, out var replacedWidth) ? new IntrinsicWidths(replacedWidth, replacedWidth) @@ -6237,7 +6289,6 @@ private static FlexItemLayoutInfo CreateFlexItemLayoutInfo(IRenderNode child, bo var maxMainSize = float.IsNaN(keywordMaxSize) ? ResolveAuthoredDimension(childStyle, isRowDirection ? "max-width" : "max-height", mainRelativeTo, float.NaN, mainBorderAndPadding) : keywordMaxSize; - var minRaw = childStyle.GetValueOrDefault(minProperty)?.Trim(); float minMainSize; if (!float.IsNaN(keywordMinSize)) @@ -6281,7 +6332,32 @@ private static FlexItemLayoutInfo CreateFlexItemLayoutInfo(IRenderNode child, bo hasDeferredMainSize, deferredMeasureWidth, CrossMarginStart: ParseLength(childStyle, isRowDirection ? "margin-top" : "margin-left", relativeTo, isRowDirection ? childBox.Margin.Top : childBox.Margin.Left, allowAuto: true), - CrossMarginEnd: ParseLength(childStyle, isRowDirection ? "margin-bottom" : "margin-right", relativeTo, isRowDirection ? childBox.Margin.Bottom : childBox.Margin.Right, allowAuto: true)); + CrossMarginEnd: ParseLength(childStyle, isRowDirection ? "margin-bottom" : "margin-right", relativeTo, isRowDirection ? childBox.Margin.Bottom : childBox.Margin.Right, allowAuto: true), + DefersMinimum: defersMinimum); + } + + /// + /// Whether a row item's content widths are not needed for its sizes: its width is a definite + /// length and its flex basis automatic, so both its base size and its limits are known, and + /// its automatic minimum (the only other reader) is either not used - an authored + /// min-width, or a scroll container - or deferred (). + /// + private static bool CanSkipFlexItemIntrinsicWidths(Dictionary style, float specifiedWidth, float relativeTo, float horizontalBorderAndPadding, string? minRaw, out bool defersMinimum) + { + defersMinimum = false; + + if (float.IsNaN(specifiedWidth) || + GetContentSizeKeyword(style, "width") is not null || + GetContentSizeKeyword(style, "flex-basis") is not null || + GetContentSizeKeyword(style, "min-width") is not null || + GetContentSizeKeyword(style, "max-width") is not null || + !float.IsNaN(ResolveFlexBaseSize(style, relativeTo, horizontalBorderAndPadding))) + { + return false; + } + + defersMinimum = (string.IsNullOrEmpty(minRaw) || string.Equals(minRaw, "auto", StringComparison.OrdinalIgnoreCase)) && !ShouldClipOverflow(style); + return true; } /// @@ -7571,8 +7647,9 @@ private sealed class StyleMap : Dictionary { private Dictionary? _rawLengths; + // computed style maps of a page hold about 14 entries (31 at the 90th percentile) public StyleMap() - : base(StringComparer.OrdinalIgnoreCase) + : base(16, StringComparer.OrdinalIgnoreCase) { } @@ -8484,7 +8561,15 @@ private static bool IsSelfCollapsing(ElementRenderNode element, LayoutContext co return false; } - return element.Children.All(child => HasNoInFlowContent(child, styleMap, context)); + foreach (var child in element.Children) + { + if (!HasNoInFlowContent(child, styleMap, context)) + { + return false; + } + } + + return true; } private static bool IsFlowBlockDisplay(Dictionary styleMap) => @@ -8687,6 +8772,11 @@ public void Track(IRenderNode child, DisplayList displayList, int start, LayoutC public void Apply(DisplayList displayList) { + if (_raised.Count == 0) + { + return; + } + var ordered = _raised.OrderBy(m => m.Z).ThenBy(m => m.Order).ToList(); Applied = ordered.Select(m => new RaisedChild(m.Element, m.Z, m.First, m.Count)).ToList(); @@ -8978,17 +9068,20 @@ private static (float X, float Y) ParseCornerRadius(Dictionary s return (0f, 0f); } - var parts = value.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); + var trimmed = value.AsSpan().Trim(); - if (parts.Length == 0) + if (trimmed is "0px" or "0") { return (0f, 0f); } - var x = ParseRadiusComponent(parts[0], borderBoxWidth); - var y = parts.Length > 1 ? ParseRadiusComponent(parts[1], borderBoxHeight) : ParseRadiusComponent(parts[0], borderBoxHeight); + var separator = trimmed.IndexOf(' '); + var first = (separator < 0 ? trimmed : trimmed[..separator]).ToString(); + var rest = separator < 0 ? default : trimmed[(separator + 1)..].TrimStart(' '); + var secondEnd = rest.IndexOf(' '); + var second = rest.IsEmpty ? first : (secondEnd < 0 ? rest : rest[..secondEnd]).ToString(); - return (x, y); + return (ParseRadiusComponent(first, borderBoxWidth), ParseRadiusComponent(second, borderBoxHeight)); } private static float ParseRadiusComponent(string token, float reference) @@ -13069,9 +13162,9 @@ internal static float ParseLengthValue(string value, float defaultValue, bool al private static bool TryParsePixelValue(string value, out float pixels) { - var trimmed = value.Trim().ToLowerInvariant(); + var trimmed = value.AsSpan().Trim(); - if (trimmed.EndsWith("px", StringComparison.Ordinal)) + if (trimmed.EndsWith("px", StringComparison.OrdinalIgnoreCase)) { return float.TryParse(trimmed[..^2], NumberStyles.Float, CultureInfo.InvariantCulture, out pixels); } @@ -13105,7 +13198,9 @@ private static bool TryParsePixelValue(string value, out float pixels) /// to load, paints nothing. /// private static List ParseBackgroundLayers(Dictionary styleMap, IElement element) => - ParseImageLayers(styleMap, element, "background", url => TryGetOrLoadBackgroundImageResource(element, url, out var resource) ? resource : null); + !styleMap.TryGetValue("background-image", out var images) || string.IsNullOrWhiteSpace(images) || images.Equals("none", StringComparison.OrdinalIgnoreCase) + ? [] + : ParseImageLayers(styleMap, element, "background", url => TryGetOrLoadBackgroundImageResource(element, url, out var resource) ? resource : null); /// /// The layers of `background-image` or `mask-image` (), each with @@ -13115,8 +13210,9 @@ private static List ParseBackgroundLayers(Dictionary ParseImageLayers(Dictionary styleMap, IElement element, string prefix, Func loadImage) { var layers = new List(); + var names = prefix == "mask" ? ImageLayerPropertyNames.Mask : ImageLayerPropertyNames.Background; - if (!styleMap.TryGetValue($"{prefix}-image", out var images) || string.IsNullOrWhiteSpace(images)) + if (!styleMap.TryGetValue(names.Image, out var images) || string.IsNullOrWhiteSpace(images)) { return layers; } @@ -13132,9 +13228,9 @@ private static List ParseImageLayers(Dictionary continue; } - var size = ParseBackgroundSize(GetLayerValue(styleMap, $"{prefix}-size", index)); - var (positionX, positionY) = ParseBackgroundPosition(GetLayerValue(styleMap, $"{prefix}-position", index)); - var (repeatX, repeatY) = ParseBackgroundRepeat(GetLayerValue(styleMap, $"{prefix}-repeat", index)); + var size = ParseBackgroundSize(GetLayerValue(styleMap, names.Size, index)); + var (positionX, positionY) = ParseBackgroundPosition(GetLayerValue(styleMap, names.Position, index)); + var (repeatX, repeatY) = ParseBackgroundRepeat(GetLayerValue(styleMap, names.Repeat, index)); RenderPaint? paint = null; if (TryExtractCssUrl(item, out var url)) @@ -13172,15 +13268,25 @@ private static List ParseImageLayers(Dictionary layers.Add(new BackgroundLayer( paint, - (GetLayerValue(styleMap, $"{prefix}-clip", index) ?? string.Empty).ToLowerInvariant(), - (GetLayerValue(styleMap, $"{prefix}-origin", index) ?? string.Empty).ToLowerInvariant(), - ParseBlendModeKeyword(GetLayerValue(styleMap, $"{prefix}-blend-mode", index)), + (GetLayerValue(styleMap, names.Clip, index) ?? string.Empty).ToLowerInvariant(), + (GetLayerValue(styleMap, names.Origin, index) ?? string.Empty).ToLowerInvariant(), + ParseBlendModeKeyword(GetLayerValue(styleMap, names.BlendMode, index)), index)); } return layers; } + private sealed record ImageLayerPropertyNames(string Image, string Size, string Position, string Repeat, string Clip, string Origin, string BlendMode) + { + public static readonly ImageLayerPropertyNames Background = Create("background"); + + public static readonly ImageLayerPropertyNames Mask = Create("mask"); + + private static ImageLayerPropertyNames Create(string prefix) => + new($"{prefix}-image", $"{prefix}-size", $"{prefix}-position", $"{prefix}-repeat", $"{prefix}-clip", $"{prefix}-origin", $"{prefix}-blend-mode"); + } + private static bool TryExtractCssUrl(string value, out string url) { url = string.Empty; @@ -13252,6 +13358,12 @@ private static (RenderBackgroundPositionComponent X, RenderBackgroundPositionCom return (RenderBackgroundPositionComponent.Zero, RenderBackgroundPositionComponent.Zero); } + // the initial value + if (value is "0% 0%") + { + return (RenderBackgroundPositionComponent.Zero, RenderBackgroundPositionComponent.Zero); + } + var tokens = value.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); if (tokens.Length == 0) @@ -14315,12 +14427,19 @@ internal static string[] SplitTopLevelCommaList(string value) return []; } + if (!value.Contains(',')) + { + return [value.Trim()]; + } + var parts = new List(); - var current = new StringBuilder(); var depth = 0; + var start = 0; - foreach (var character in value) + for (var index = 0; index < value.Length; index++) { + var character = value[index]; + if (character == '(') { depth++; @@ -14331,26 +14450,23 @@ internal static string[] SplitTopLevelCommaList(string value) } else if (character == ',' && depth == 0) { - var part = current.ToString().Trim(); - if (part.Length > 0) - { - parts.Add(part); - } - - current.Clear(); - continue; + AddPart(value.AsSpan(start, index - start)); + start = index + 1; } - - current.Append(character); } - var last = current.ToString().Trim(); - if (last.Length > 0) + AddPart(value.AsSpan(start)); + return parts.ToArray(); + + void AddPart(ReadOnlySpan part) { - parts.Add(last); - } + part = part.Trim(); - return parts.ToArray(); + if (part.Length > 0) + { + parts.Add(part.ToString()); + } + } } ///