diff --git a/CHANGELOG.md b/CHANGELOG.md
index ff0ec63..4785e8d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,16 @@
+# 0.11.2
+
+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)
+
# 0.11.1
Released on Sunday, November 8 2026.
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:
diff --git a/src/AngleSharp.Renderer.Benchmarks/BASELINE.md b/src/AngleSharp.Renderer.Benchmarks/BASELINE.md
index 73b8517..05489bf 100644
--- a/src/AngleSharp.Renderer.Benchmarks/BASELINE.md
+++ b/src/AngleSharp.Renderer.Benchmarks/BASELINE.md
@@ -47,6 +47,52 @@ 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
+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.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/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);
+ }
+}
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/InteractiveFrameTests.cs b/src/AngleSharp.Renderer.Tests/InteractiveFrameTests.cs
index 61c0c22..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;
@@ -290,6 +292,52 @@ 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 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);
+ 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)
{
var configuration = Configuration.Default.WithCss().WithRenderDevice(Device);
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/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.Tests/ViewportFrameTests.cs b/src/AngleSharp.Renderer.Tests/ViewportFrameTests.cs
index c12ffb6..5f6f022 100644
--- a/src/AngleSharp.Renderer.Tests/ViewportFrameTests.cs
+++ b/src/AngleSharp.Renderer.Tests/ViewportFrameTests.cs
@@ -33,6 +33,61 @@ 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_WrappingFlexRowLaysOutOnlyTheLinesInView()
+ {
+ var cards = string.Concat(Enumerable.Range(0, 120).Select(index => $""""""));
+ 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 68c39aa..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);
@@ -1805,6 +1806,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 bef5199..ab62ea4 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;
@@ -64,10 +65,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
@@ -529,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;
}
@@ -2635,6 +2652,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
@@ -2653,6 +2746,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;
@@ -2712,7 +2817,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
{
@@ -2983,6 +3097,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)
@@ -3047,7 +3167,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.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 eebc839..a04168e 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; }
@@ -1237,6 +1241,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:
@@ -2624,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);
@@ -2779,6 +2791,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()
@@ -2924,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);
@@ -2960,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)
{
@@ -3026,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);
@@ -3088,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
@@ -3274,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);
@@ -3583,6 +3636,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()
@@ -5658,9 +5712,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 +5728,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;
}
@@ -5939,13 +5996,24 @@ private static IEnumerable FlattenDisplayContents(IEnumerable FlattenDisplayContents(IEnumerable styleMap)
{
return styleMap.TryGetValue("display", out var display) ? display : null;
@@ -6063,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)
{
@@ -6134,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)
@@ -6165,6 +6257,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;
@@ -6184,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))
@@ -6228,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;
}
///
@@ -6490,6 +6619,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 ).
///
@@ -7500,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)
{
}
@@ -7940,14 +8088,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");
@@ -7975,18 +8151,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);
- }
- }
}
///
@@ -8283,6 +8447,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;
@@ -8365,6 +8536,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)
@@ -8384,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) =>
@@ -8587,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();
@@ -8631,8 +8821,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) =>
@@ -8640,7 +8834,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;
}
@@ -8869,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)
@@ -12960,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);
}
@@ -12996,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
@@ -13006,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;
}
@@ -13023,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))
@@ -13063,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;
@@ -13143,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)
@@ -14206,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++;
@@ -14222,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());
+ }
+ }
}
///
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