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 => $"""

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 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