From a8f259d919129cb6c62740915f61b1694d61728f Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sun, 25 Oct 2026 15:04:29 +0100 Subject: [PATCH 1/6] Improved layout rendering --- CHANGELOG.md | 36 + src/AngleSharp.Renderer.Docs/package.json | 2 +- .../FontFaceTests.cs | 29 + .../HtmlRendererTests.cs | 598 +++++++++++- .../SvgStyleAndFilterTests.cs | 50 + .../HtmlRenderer.Inline.cs | 114 ++- .../HtmlRenderer.Sticky.cs | 5 +- src/AngleSharp.Renderer/HtmlRenderer.cs | 853 +++++++++++++++--- .../Skia/SkiaTextShaping.cs | 12 + .../Skia/Svg/SvgFilterBuilder.cs | 34 +- src/Directory.Build.props | 2 +- 11 files changed, 1566 insertions(+), 169 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8aff9f2..fa57ca5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,39 @@ +# 0.8.2 + +Released on Sunday, October 25 2026. + +- Improved the layout of pages with many inline SVGs: how the page styles an inline SVG is resolved once per layout pass instead of every time the SVG is measured or laid out +- Improved the layout of deep pages: the cascaded declarations of an element are taken from the render tree instead of running the cascade for the element and all of its ancestors +- Improved the layout of pages with large `style` elements: the rules declaring `-webkit-line-clamp` and `overflow-wrap` are collected once per layout pass instead of once per element +- Fixed percentage heights resolving against the containing block's width: they now refer to its height when it is definite (the viewport for the root element), and are `auto` otherwise +- Fixed a row flex container laid out as an item of a column flex container taking its width as its height +- Fixed a column flex container being at least as tall as it is wide, and using the height it gets from its own flex container +- Fixed flex items with an authored `height: auto` (or `fit-content`) being treated as having a definite height +- Fixed children of `display: contents` elements not becoming flex and grid items +- Fixed `", "Save")] + [InlineData("", "Save")] + [InlineData("", "Save")] + [InlineData("", "Save")] + public async Task BuildDisplayList_ButtonContentIsDrawnOnce(string markup, string label) + { + var document = await ParseAsync($"{markup}"); + + var displayList = new HtmlRenderer().BuildDisplayList(document, new DefaultRenderDevice { ViewPortWidth = 300, ViewPortHeight = 100 }); + var texts = displayList.Commands.OfType().Select(t => t.Text.Trim()).Where(t => t.Length > 0).ToList(); + + Assert.Equal([label], texts); + } + + [Fact] + public async Task BuildDisplayList_ButtonGeneratedContentIsDrawn() + { + // found on MDN: the chevrons of its menu buttons (:after) only took space + var document = await ParseAsync(""" + + + """); + + var displayList = new HtmlRenderer().BuildDisplayList(document, new DefaultRenderDevice { ViewPortWidth = 300, ViewPortHeight = 100 }); + var texts = displayList.Commands.OfType().Select(t => t.Text.Trim()).Where(t => t.Length > 0).ToList(); + + Assert.Equal(["Save"], texts); + Assert.Equal(10f, SingleFill(displayList, Red).Rect.Width, precision: 2); + } + + [Theory] + // found on MDN, whose icons are boxes with background-color: currentcolor and a mask + [InlineData("color:#ff0000; background-color:currentcolor")] + [InlineData("color:#ff0000; background-color:currentColor")] + [InlineData("color:#ff0000; background-color:CURRENTCOLOR")] + public async Task BuildDisplayList_CurrentColorIsTheElementsColor(string style) + { + var document = await ParseAsync($""" + +
+ + """); + + Assert.Equal(20f, SingleFill(BuildDisplayList(document), Red).Rect.Width, precision: 2); + } + [Fact] public async Task BuildDisplayList_HiddenInputPaintsNothingAtAll() { @@ -5714,6 +5972,21 @@ public async Task BuildDisplayList_PercentageTranslateResolvesAgainstOwnBorderBo Assert.Equal(10f, push.Transform.F, precision: 3); } + [Theory] + // custom properties are only substituted in the computed value + [InlineData("
", 15f, 8f)] + [InlineData("
", 15f, 8f)] + [InlineData("
", 20f, 0f)] + public async Task BuildDisplayList_TransformWithCustomPropertiesIsApplied(string body, float x, float y) + { + var document = await ParseAsync($"{body}"); + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { ViewPortWidth = 200, ViewPortHeight = 200 }); + var push = Assert.Single(displayList.Commands.OfType()); + Assert.Equal(x, push.Transform.E, precision: 3); + Assert.Equal(y, push.Transform.F, precision: 3); + } + [Fact] public async Task BuildDisplayList_RotateTransformProducesCorrectMatrixAroundDefaultCenter() { @@ -6430,6 +6703,27 @@ public async Task BuildDisplayList_WebkitLineClampTruncatesToNLinesWithEllipsisO Assert.EndsWith("…", textCommands[1].Text); } + [Fact] + public async Task BuildDisplayList_WebkitLineClampFromAStyleElementFollowsTheMatchingElements() + { + var document = await ParseAsync(""" + +
one two three four five six seven eight
+
one two three four five six seven eight
+ + """); + + var renderer = new HtmlRenderer(); + var device = new DefaultRenderDevice { ViewPortWidth = 200, ViewPortHeight = 400, FontSize = 16f }; + var before = renderer.BuildDisplayList(document, device).Commands.OfType().Count(); + document.GetElementById("b")!.ClassList.Add("clamp"); + var after = renderer.BuildDisplayList(document, device).Commands.OfType().Count(); + + // "a" is clamped to 2 lines in both renders; "b" only once the class reaches it + Assert.True(before > 4); + Assert.Equal(4, after); + } + [Fact] public async Task BuildDisplayList_WebkitLineClampHasNoEffectWhenTextFitsWithinTheLimit() { @@ -7985,13 +8279,31 @@ public async Task BuildDisplayList_MixBlendModeWrapsTheElementInABlendScope() [Fact] public async Task BuildDisplayList_IsolationIsolateOpensANormalBlendGroup() { - var document = await ParseAsync("
"); + var document = await ParseAsync("
"); var displayList = BuildDisplayList(document); - var push = Assert.Single(displayList.Commands.OfType()); - Assert.Equal(RenderBlendMode.Normal, push.Mode); - Assert.Single(displayList.Commands.OfType()); + var pushes = displayList.Commands.OfType().ToList(); + Assert.Equal([RenderBlendMode.Normal, RenderBlendMode.Multiply], pushes.Select(p => p.Mode)); + Assert.Equal(2, displayList.Commands.OfType().Count()); + } + + [Fact] + public async Task BuildDisplayList_IsolationWithoutBlendingInsideOpensNoGroup() + { + // found on GitHub: a group around the page hid its header from the backdrop-filter of its buttons + var document = await ParseAsync(""" + +
+
+
+ + """); + + var displayList = BuildDisplayList(document); + + Assert.Empty(displayList.Commands.OfType()); + Assert.Single(displayList.Commands.OfType()); } [Fact] @@ -8225,6 +8537,274 @@ public async Task BuildDisplayList_FlexItemsFrozenAtTheirMaximumLeaveTheRestToOt Assert.Equal(250f, SingleFill(displayList, Green).Rect.Width, precision: 2); } + [Fact] + public async Task BuildDisplayList_FlexItemMarginsAreOutsideTheirMainSize() + { + var document = await ParseAsync(""" + +
+
+
+
+
+ + """); + + var displayList = BuildDisplayList(document); + var green = SingleFill(displayList, Green).Rect; + + Assert.Equal(70f, green.X, precision: 2); + Assert.Equal(50f, green.Width, precision: 2); + Assert.Equal(130f, SingleFill(displayList, Blue).Rect.X, precision: 2); + } + + [Theory] + [InlineData("margin-right:auto", 0f, 250f)] + [InlineData("margin-left:auto", 200f, 250f)] + [InlineData("margin:0 auto", 100f, 250f)] + public async Task BuildDisplayList_FlexItemAutoMarginsTakeTheFreeSpace(string redMargin, float expectedRedX, float expectedGreenX) + { + // e.g. MDN's breadcrumbs (margin-right: auto) push the other items to the end + var document = await ParseAsync($""" + +
+
+
+
+ + """); + + var displayList = BuildDisplayList(document); + + Assert.Equal(expectedRedX, SingleFill(displayList, Red).Rect.X, precision: 2); + Assert.Equal(expectedGreenX, SingleFill(displayList, Green).Rect.X, precision: 2); + } + + [Theory] + [InlineData("justify-content:end")] + [InlineData("justify-content:flex-end")] + [InlineData("justify-content:safe end")] + [InlineData("justify-content:right")] + public async Task BuildDisplayList_FlexJustifyContentUnderstandsBoxAlignmentKeywords(string justifyContent) + { + var document = await ParseAsync($""" + +
+
+
+ + """); + + Assert.Equal(250f, SingleFill(BuildDisplayList(document), Red).Rect.X, precision: 2); + } + + [Fact] + public async Task BuildDisplayList_FlexTextRunsAreItemsOfTheirOwnSize() + { + var document = await ParseAsync(""" + +
Text
+
Text
+ + """); + + var displayList = BuildDisplayList(document); + var redX = SingleFill(displayList, Red).Rect.X; + + // after the text, which is about 30px wide at 16px + Assert.InRange(redX, 20f, 60f); + Assert.Equal(0f, displayList.Commands.OfType().First().X, precision: 2); + Assert.True(SingleFill(displayList, Green).Rect.Height >= 16f); + } + + [Fact] + public async Task BuildDisplayList_CenteredColumnFlexItemsAreAsWideAsTheirContent() + { + // found on GitHub: its repository tabs are centered in column flex items and vanished + var document = await ParseAsync(""" + +
+
+
+ + """); + + var red = SingleFill(BuildDisplayList(document), Red).Rect; + + Assert.Equal(40f, red.Width, precision: 2); + Assert.Equal(80f, red.X, precision: 2); + } + + [Fact] + public async Task BuildDisplayList_ZIndexedBoxKeepsItsPlaceButPaintsAbove() + { + // found on GitHub: its header (position: relative; z-index: 32) moved below the page + var document = await ParseAsync(""" + +
+
+ + """); + + var displayList = BuildDisplayList(document); + var fills = displayList.Commands.OfType().ToList(); + var red = SingleFill(displayList, Red); + var green = SingleFill(displayList, Green); + + Assert.Equal(0f, red.Rect.Y, precision: 2); + Assert.Equal(10f, green.Rect.Y, precision: 2); + Assert.True(fills.IndexOf(red) > fills.IndexOf(green)); + } + + [Fact] + public async Task BuildDisplayList_ZIndexedFlexItemsKeepTheirOrder() + { + var document = await ParseAsync(""" + +
+
+
+
+
+ + """); + + var displayList = BuildDisplayList(document); + + Assert.Equal(0f, SingleFill(displayList, Red).Rect.X, precision: 2); + Assert.Equal(20f, SingleFill(displayList, Green).Rect.X, precision: 2); + Assert.Equal(40f, SingleFill(displayList, Blue).Rect.X, precision: 2); + } + + [Fact] + public async Task BuildDisplayList_AbsolutelyPositionedBoxAfterAZIndexedBoxKeepsItsStaticPosition() + { + var document = await ParseAsync(""" + +
+
+
+ + """); + + Assert.Equal(20f, SingleFill(BuildDisplayList(document), Red).Rect.Y, precision: 2); + } + + [Fact] + public async Task BuildDisplayList_ShrinkToFitGridIsAsWideAsItsColumns() + { + // found on GitHub: the label and icons of its buttons (a grid in an inline-flex) overlapped + var document = await ParseAsync(""" + +
+
+
+
+
+
+
+ + """); + + var displayList = BuildDisplayList(document); + + Assert.Equal(50f, SingleFill(displayList, Blue).Rect.Width, precision: 2); + Assert.Equal(15f, SingleFill(displayList, Green).Rect.X, precision: 2); + Assert.Equal(40f, SingleFill(displayList, new RenderColor(255, 255, 0)).Rect.X, precision: 2); + } + + [Theory] + [InlineData("display:flex")] + [InlineData("display:block")] + [InlineData("display:grid")] + public async Task BuildDisplayList_BlockLevelButtonIsAsWideAsItsContent(string display) + { + var document = await ParseAsync($""" + + + + """); + + Assert.Equal(30f, SingleFill(BuildDisplayList(document), Red).Rect.Width, precision: 2); + } + + [Fact] + public async Task BuildDisplayList_AbsolutelyPositionedFlexChildrenAreNoFlexItems() + { + // found on MDN: the chevron of its Baseline box (position: absolute; right: 1rem) + var document = await ParseAsync(""" + +
+
+
+
+ + """); + + var displayList = BuildDisplayList(document, width: 500); + var red = SingleFill(displayList, Red).Rect; + + Assert.Equal(250f, SingleFill(displayList, Green).Rect.X, precision: 2); + Assert.Equal(310f, red.X, precision: 2); + // top: auto keeps it where it would be as the only (centered) item + Assert.Equal(10f, red.Y, precision: 2); + } + + [Fact] + public async Task BuildDisplayList_AbsolutelyPositionedBoxWithAutoOffsetsStaysInPlace() + { + var document = await ParseAsync(""" + +
+
+
+
+
+ + """); + + var displayList = BuildDisplayList(document); + var red = SingleFill(displayList, Red).Rect; + var green = SingleFill(displayList, Green).Rect; + + Assert.Equal(20f, red.X, precision: 2); + Assert.Equal(30f, red.Y, precision: 2); + Assert.Equal(20f, green.X, precision: 2); + Assert.Equal(0f, green.Y, precision: 2); + } + + [Fact] + public async Task BuildDisplayList_FlexContainerWithoutItemsKeepsItsSize() + { + var document = await ParseAsync(""" + +
+ + """); + + var red = SingleFill(BuildDisplayList(document), Red).Rect; + + Assert.Equal(100f, red.Width, precision: 2); + Assert.Equal(40f, red.Height, precision: 2); + } + + [Fact] + public async Task BuildDisplayList_LetterSpacingCountsInTheMaxContentWidth() + { + // found on MDN: its "Widely available" pill wrapped, being a little too narrow + var document = await ParseAsync(""" + +
aa bb
+
aa bb
+ + """); + + var displayList = BuildDisplayList(document); + + // the spacing goes between all of the five characters + Assert.Equal(SingleFill(displayList, Red).Rect.Width + 16f, SingleFill(displayList, Green).Rect.Width, precision: 1); + } + [Theory] [InlineData("justify-self:start", 0f, 30f)] [InlineData("justify-self:center", 35f, 30f)] diff --git a/src/AngleSharp.Renderer.Tests/SvgStyleAndFilterTests.cs b/src/AngleSharp.Renderer.Tests/SvgStyleAndFilterTests.cs index bef6dba..e794717 100644 --- a/src/AngleSharp.Renderer.Tests/SvgStyleAndFilterTests.cs +++ b/src/AngleSharp.Renderer.Tests/SvgStyleAndFilterTests.cs @@ -27,6 +27,18 @@ public async Task PageCss_StylesSvgElements() AssertColor(Green, pixel); } + [Fact] + public async Task PageCss_WithCustomPropertiesStylesSvgElements() + { + // found on MDN: the logo's paths are filled with var(--color-text-primary) + var pixel = await SampleAsync(""" + + + """, 10, 10); + + AssertColor(Green, pixel); + } + [Fact] public async Task PageCss_BeatsPresentationAttributes() { @@ -93,6 +105,38 @@ public async Task PageCssChanges_ReachAnAlreadyRenderedSvg() AssertColor(Blue, Sample(document, 10, 10)); } + [Fact] + public async Task PageCss_StylesSvgsLaidOutRepeatedly() + { + // flex items are measured before they are laid out, which resolves an SVG more than once per pass + var document = await OpenAsync(""" + +
+ """); + + AssertColor(Green, Sample(document, 10, 10)); + document.GetElementById("bar")!.ClassList.Add("on"); + AssertColor(Blue, Sample(document, 10, 10)); + } + + [Fact] + public async Task PageCss_StylesEachOfSeveralIdenticalSvgs() + { + var document = await OpenAsync(""" + +
+ + +
+ """); + var displayList = new HtmlRenderer().BuildDisplayList(document, new DefaultRenderDevice { ViewPortWidth = 300, ViewPortHeight = 200 }); + var images = displayList.Commands.OfType().OrderBy(command => command.Rect.X).ToArray(); + + Assert.Equal(2, images.Length); + AssertColor(Green, Center(images[0])); + AssertColor(Blue, Center(images[1])); + } + [Fact] public async Task FeFlood_FillsTheWholeFilterRegion() { @@ -360,6 +404,12 @@ private static SKColor Sample(IDocument document, float x, float y) return bitmap.GetPixel((int)((x + 0.5f) * scaleX), (int)((y + 0.5f) * scaleY)); } + private static SKColor Center(DrawImageCommand image) + { + using var bitmap = SKBitmap.Decode(image.Image.Data); + return bitmap.GetPixel(bitmap.Width / 2, bitmap.Height / 2); + } + private static async Task OpenAsync(string body) { var context = BrowsingContext.New(Configuration.Default.WithCss()); diff --git a/src/AngleSharp.Renderer/HtmlRenderer.Inline.cs b/src/AngleSharp.Renderer/HtmlRenderer.Inline.cs index 4c7d6a9..888ffe4 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.Inline.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.Inline.cs @@ -49,6 +49,7 @@ private static void LayoutFlowChildren( { var run = new List(); var runHasInlineContent = false; + var paintOrder = new PaintOrder(); void FlushRun(ref float cursorY, ref float previousBlockMarginBottom, ref bool suppressNextBlockTopMargin, FloatArea floats, ref bool textIndentConsumed) { @@ -86,7 +87,8 @@ void FlushRun(ref float cursorY, ref float previousBlockMarginBottom, ref bool s positionedContainingY, positionedContainingWidth, positionedContainingHeight, - negativeZIndexRanges); + negativeZIndexRanges, + paintOrder); } run.Clear(); @@ -150,6 +152,8 @@ void FlushRun(ref float cursorY, ref float previousBlockMarginBottom, ref bool s negativeZIndexRanges.Add((childRangeStart, displayList.Commands.Count - childRangeStart)); } + paintOrder.Track(child, displayList, childRangeStart, context); + // `text-indent` only applies to the first formatted line of the container. if (classification == FlowChildKind.Block) { @@ -160,11 +164,13 @@ void FlushRun(ref float cursorY, ref float previousBlockMarginBottom, ref bool s // down could still be pulled up into view. if (cursorY > maxY && !ContainsBottomStickyBox(children.Skip(index + 1), context)) { + paintOrder.Apply(displayList); return; } } FlushRun(ref cursorY, ref previousBlockMarginBottom, ref suppressNextBlockTopMargin, floats, ref textIndentConsumed); + paintOrder.Apply(displayList); } private enum FlowChildKind @@ -245,7 +251,7 @@ private static bool IsAtomicInlineDisplay(ICssStyleDeclaration computedStyle) private static bool IsAtomicInlineElement(ElementRenderNode element, Dictionary styleMap) => IsAtomicInlineDisplay(element.ComputedStyle) || - ResolveFormControlKind(element.Ref) != FormControlKind.None || + ResolveFormControlKind(element) != FormControlKind.None || (IsReplacedElement(element.Ref) && IsInlineLevelElement(element, styleMap)); private enum InlinePieceKind @@ -721,10 +727,12 @@ private static void LayoutInlineFormattingContext( float positionedContainingY, float positionedContainingWidth, float positionedContainingHeight, - List<(int StartIndex, int Count)> negativeZIndexRanges) + List<(int StartIndex, int Count)> negativeZIndexRanges, + PaintOrder? sharedPaintOrder = null) { var collector = new InlineContentCollector(context, contentWidth); var rootStyleId = collector.GetStyleId(nodes); + var paintOrder = sharedPaintOrder ?? new PaintOrder(); foreach (var node in nodes) { @@ -819,7 +827,7 @@ private static void LayoutInlineFormattingContext( if (!hasLineBox) { - LayoutOutOfFlowPieces(linePieces, contentX, lineTop, contentWidth, context, displayList, maxY, containingHeight, positionedContainingX, positionedContainingY, positionedContainingWidth, positionedContainingHeight); + LayoutOutOfFlowPieces(linePieces, contentX, lineTop, contentWidth, context, displayList, maxY, containingHeight, positionedContainingX, positionedContainingY, positionedContainingWidth, positionedContainingHeight, paintOrder); continue; } @@ -840,7 +848,7 @@ private static void LayoutInlineFormattingContext( positionedContainingHeight, negativeZIndexRanges); - LayoutOutOfFlowPieces(linePieces, contentX, lineTop, contentWidth, context, displayList, maxY, containingHeight, positionedContainingX, positionedContainingY, positionedContainingWidth, positionedContainingHeight); + LayoutOutOfFlowPieces(linePieces, contentX, lineTop, contentWidth, context, displayList, maxY, containingHeight, positionedContainingX, positionedContainingY, positionedContainingWidth, positionedContainingHeight, paintOrder); lineTop += lineHeight; emittedAnyLine = true; @@ -853,6 +861,11 @@ private static void LayoutInlineFormattingContext( } } + if (sharedPaintOrder is null) + { + paintOrder.Apply(displayList); + } + foreach (var inlineBox in collector.Boxes) { if (inlineBox.BoundsRight >= inlineBox.BoundsLeft) @@ -1811,7 +1824,8 @@ private static void LayoutOutOfFlowPieces( float positionedContainingX, float positionedContainingY, float positionedContainingWidth, - float positionedContainingHeight) + float positionedContainingHeight, + PaintOrder paintOrder) { foreach (var piece in linePieces) { @@ -1820,6 +1834,7 @@ private static void LayoutOutOfFlowPieces( continue; } + var rangeStart = displayList.Commands.Count; var staticCursorY = lineTop; var previousMarginBottom = 0f; var suppressTopMargin = false; @@ -1845,6 +1860,7 @@ private static void LayoutOutOfFlowPieces( positionedContainingY: positionedContainingY, positionedContainingWidth: positionedContainingWidth, positionedContainingHeight: positionedContainingHeight); + paintOrder.Track(piece.Element!, displayList, rangeStart, context); } } @@ -1961,7 +1977,7 @@ private static void RecordTextBaseline(float baselineY) private static float ResolveShrinkToFitBorderBoxWidth(ElementRenderNode element, float containingWidth, RenderTextStyle inheritedStyle, LayoutContext context, out float marginLeft, out float marginRight) { var styleMap = GetOrCreateStyleMap(context, element.Ref, element.ComputedStyle); - var formControlKind = ResolveFormControlKind(element.Ref); + var formControlKind = ResolveFormControlKind(element); var textStyle = ResolveTextStyle(styleMap, inheritedStyle); if (formControlKind != FormControlKind.None) @@ -2060,7 +2076,7 @@ private static IntrinsicWidths ComputeOuterIntrinsicWidths(ElementRenderNode ele return IntrinsicWidths.Zero; } - var formControlKind = ResolveFormControlKind(element.Ref); + var formControlKind = ResolveFormControlKind(element); var styleMap = GetOrCreateStyleMap(context, element.Ref, element.ComputedStyle); @@ -2122,7 +2138,7 @@ private static IntrinsicWidths ComputeContentIntrinsicWidths(ElementRenderNode e { var styleMap = GetOrCreateStyleMap(context, element.Ref, element.ComputedStyle); var children = OrderChildrenForPainting(element.Children, context).ToList(); - var formControlKind = ResolveFormControlKind(element.Ref); + var formControlKind = ResolveFormControlKind(element); if (element.Ref is not IPseudoElement && (string.Equals(element.Ref.LocalName, "svg", StringComparison.OrdinalIgnoreCase) || @@ -2152,6 +2168,11 @@ private static IntrinsicWidths ComputeContentIntrinsicWidths(ElementRenderNode e return new IntrinsicWidths(labelWidth, labelWidth); } + if (IsGridContainer(styleMap) && TryComputeGridIntrinsicWidths(element, styleMap, textStyle, percentageBase, context, out var gridWidths)) + { + return gridWidths; + } + if (IsFlexContainer(styleMap) && !(GetFlexDirection(styleMap) ?? string.Empty).StartsWith("column", StringComparison.OrdinalIgnoreCase)) { // A row flex container lays its items side by side. @@ -2284,6 +2305,76 @@ void AccumulateInline(IRenderNode node, RenderTextStyle style) /// The intrinsic widths of one text node, plus the max-content width of each line its /// preserved newlines force. /// + /// + /// The intrinsic widths of a grid with several columns: the sum of its columns, a fixed one + /// as wide as its size, any other as wide as the items placed in it alone (by a named area, + /// grid-column or in order), plus the gaps between them. + /// + private static bool TryComputeGridIntrinsicWidths(ElementRenderNode element, Dictionary styleMap, RenderTextStyle textStyle, float percentageBase, LayoutContext context, out IntrinsicWidths widths) + { + widths = IntrinsicWidths.Zero; + var items = FlattenDisplayContents(element.Children, context).OfType().ToList(); + var columns = ParseGridTrackListStructured(ResolveExplicitPropertyValue(element.Ref, element.ComputedStyle, "grid-template-columns"), percentageBase, 0f, items.Count); + var areas = ParseGridTemplateAreas(styleMap.GetValueOrDefault("grid-template-areas")); + var columnCount = Math.Max(columns.Count, areas.Count > 0 ? areas.Values.Max(area => area.Column + area.ColumnSpan) : 0); + + if (columnCount < 2) + { + return false; + } + + var min = new float[columnCount]; + var max = new float[columnCount]; + var nextColumn = 0; + + foreach (var item in items) + { + var itemStyle = GetOrCreateStyleMap(context, item.Ref, item.ComputedStyle); + + if (string.Equals(itemStyle.GetValueOrDefault("display"), "none", StringComparison.OrdinalIgnoreCase) || IsOutOfFlowPositioned(itemStyle)) + { + continue; + } + + GridPlacement placement; + + if (itemStyle.TryGetValue("grid-area", out var areaName) && areas.TryGetValue(areaName.Trim().Trim('"', '\''), out var area)) + { + placement = new GridPlacement(area.Column, area.ColumnSpan); + } + else if (HasGridPlacement(itemStyle, "grid-column")) + { + placement = ResolveGridPlacementFromMap(itemStyle, "grid-column", 0); + } + else + { + placement = new GridPlacement(nextColumn, 1); + nextColumn = (nextColumn + 1) % columnCount; + } + + // spanning items are left to the layout, which grows their tracks + if (placement.Span == 1 && placement.LineIndex >= 0 && placement.LineIndex < columnCount) + { + var itemWidths = ComputeOuterIntrinsicWidths(item, textStyle, percentageBase, context); + min[placement.LineIndex] = Math.Max(min[placement.LineIndex], itemWidths.Min); + max[placement.LineIndex] = Math.Max(max[placement.LineIndex], itemWidths.Max); + } + } + + for (var index = 0; index < columns.Count && index < columnCount; index++) + { + if (columns[index].Kind == GridTrackSizeKind.Fixed) + { + min[index] = max[index] = columns[index].Pixels; + } + } + + var gap = ResolveColumnGap(styleMap, 0f) ?? 0f; + var gaps = (columnCount - 1) * (float.IsNaN(gap) ? 0f : gap); + widths = new IntrinsicWidths(min.Sum() + gaps, max.Sum() + gaps); + return true; + } + private static IntrinsicWidths ComputeTextIntrinsicWidths(string data, RenderTextStyle style, LayoutContext context, out List forcedLines) { forcedLines = []; @@ -2303,6 +2394,9 @@ private static IntrinsicWidths ComputeTextIntrinsicWidths(string data, RenderTex var min = 0f; var max = 0f; var paragraphs = text.Split('\n'); + // a measured run has letter spacing between its characters only, so joining two words + // (measured on their own) also adds the spacing before and after the space between them + var joinWidth = spaceWidth + (2f * style.LetterSpacing); for (var paragraphIndex = 0; paragraphIndex < paragraphs.Length; paragraphIndex++) { @@ -2313,7 +2407,7 @@ private static IntrinsicWidths ComputeTextIntrinsicWidths(string data, RenderTex for (var index = 0; index < words.Length; index++) { var wordWidth = words[index].Length > 0 ? MeasureTextWidth(context, words[index], style) : 0f; - paragraphWidth += wordWidth + (index > 0 ? spaceWidth : 0f); + paragraphWidth += wordWidth + (index > 0 ? joinWidth : 0f); if (style.WordBreak == WordBreakMode.BreakAll || style.OverflowWrap == OverflowWrapMode.BreakWord) { diff --git a/src/AngleSharp.Renderer/HtmlRenderer.Sticky.cs b/src/AngleSharp.Renderer/HtmlRenderer.Sticky.cs index 3eeb901..de811cc 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.Sticky.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.Sticky.cs @@ -97,7 +97,8 @@ private static void LayoutStickyAwareElement( float forcedBorderBoxWidth, bool isAtomicInline, float forcedBorderBoxHeight, - float contentOffsetY) + float contentOffsetY, + bool mainAxisMarginsPlaced) { var styleMap = GetOrCreateStyleMap(context, node.Ref, node.ComputedStyle); var isSticky = IsStickyPositioned(styleMap); @@ -114,7 +115,7 @@ private static void LayoutStickyAwareElement( try { - LayoutElement(node, containingX, containingY, containingWidth, ref cursorY, ref previousBlockMarginBottom, ref suppressNextBlockTopMargin, floats, ref textIndentConsumed, inheritedTextStyle, context, displayList, maxY, isFlexItem, isRowDirection, flexMainSize, flexCrossSize, containingHeight, positionedContainingX, positionedContainingY, positionedContainingWidth, positionedContainingHeight, forcedBorderBoxWidth, isAtomicInline, forcedBorderBoxHeight, contentOffsetY); + LayoutElement(node, containingX, containingY, containingWidth, ref cursorY, ref previousBlockMarginBottom, ref suppressNextBlockTopMargin, floats, ref textIndentConsumed, inheritedTextStyle, context, displayList, maxY, isFlexItem, isRowDirection, flexMainSize, flexCrossSize, containingHeight, positionedContainingX, positionedContainingY, positionedContainingWidth, positionedContainingHeight, forcedBorderBoxWidth, isAtomicInline, forcedBorderBoxHeight, contentOffsetY, mainAxisMarginsPlaced); } finally { diff --git a/src/AngleSharp.Renderer/HtmlRenderer.cs b/src/AngleSharp.Renderer/HtmlRenderer.cs index 08cb6d3..e28562a 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.cs @@ -39,6 +39,27 @@ public sealed partial class HtmlRenderer /// private sealed record InlineSvgCacheEntry(string Fingerprint, CachedImageResource Resource); + /// + /// What one layout pass resolves from the page and needs again and again, while neither the + /// DOM nor the styles change during a pass: how the page styles its inline SVGs (a cascade for + /// every element of an SVG, which is laid out many times - trial layouts, intrinsic sizes, line + /// breaking), and the rules of its `style` elements declaring a raw authored property. + /// + private sealed class LayoutPass + { + public Dictionary StyleCollections { get; } = new(ReferenceEqualityComparer.Instance); + + public Dictionary Styles { get; } = new(ReferenceEqualityComparer.Instance); + + public Dictionary<(IDocument Document, string Property), List> AuthoredRules { get; } = new(); + + /// The computed styles of the render tree's elements. + public Dictionary ComputedStyles { get; } = new(ReferenceEqualityComparer.Instance); + } + + /// A rule of a `style` element declaring a property (see ResolveRawAuthoredPropertyValue). + private sealed record AuthoredRule(string[] Selectors, string Value); + /// /// `::before`/`::after` pseudo-elements whose `content` is a single `url()` image, which makes /// them replaced elements (CSS Generated Content). Filled per render by ; @@ -46,6 +67,7 @@ private sealed record InlineSvgCacheEntry(string Fingerprint, CachedImageResourc /// private static readonly ConditionalWeakTable s_generatedContentImages = new(); private static readonly AsyncLocal s_layoutCapture = new(); + private static readonly AsyncLocal s_layoutPass = new(); private static readonly ITextMeasurer s_defaultTextMeasurer = new SkiaTextMeasurer(); @@ -91,7 +113,8 @@ private readonly record struct LayoutContext( Stack> StickyScopes, Dictionary<(IElement Element, float Width), float> MeasuredHeights, Dictionary BoxSystems, - Dictionary ContentSystems); + Dictionary ContentSystems, + Dictionary SpecifiedStyles); private sealed class LayoutCapture { @@ -278,7 +301,9 @@ private static LayoutContext CreateLayoutContext(IDocument document, IRenderDevi MeasuredHeights: new Dictionary<(IElement Element, float Width), float>(), // The vertical writing-mode systems of boxes and their content (ResolveWritingModeSystems). BoxSystems: new Dictionary(ReferenceEqualityComparer.Instance), - ContentSystems: new Dictionary(ReferenceEqualityComparer.Instance)); + ContentSystems: new Dictionary(ReferenceEqualityComparer.Instance), + // The cascaded declarations of the render tree's elements (see GetExplicitDeclarations). + SpecifiedStyles: new Dictionary(ReferenceEqualityComparer.Instance)); } /// @@ -368,6 +393,21 @@ private static float ResolveRootScrollOffsetY(IDocument document) } private static DisplayList BuildDisplayList(IDocument document, RenderViewport viewport, LayoutContext context, IRenderDevice renderDevice, float scrollOffsetY = 0f, bool measureFullExtent = false) + { + var previous = s_layoutPass.Value; + s_layoutPass.Value = new LayoutPass(); + + try + { + return BuildDisplayListCore(document, viewport, context, renderDevice, scrollOffsetY, measureFullExtent); + } + finally + { + s_layoutPass.Value = previous; + } + } + + private static DisplayList BuildDisplayListCore(IDocument document, RenderViewport viewport, LayoutContext context, IRenderDevice renderDevice, float scrollOffsetY, bool measureFullExtent) { var displayList = new DisplayList { Fonts = context.Fonts }; displayList.FillRect(new RenderRect(0f, 0f, viewport.Width, viewport.Height), RenderColor.White); @@ -381,6 +421,7 @@ private static DisplayList BuildDisplayList(IDocument document, RenderViewport v PrepareDocumentForRendering(document); var renderTree = window.Render(renderDevice); + CollectSpecifiedStyles(renderTree, context); ResolveWritingModeSystems(renderTree, WritingModeKind.HorizontalTb, context); // CSS counters (`counter-reset`/`counter-increment`/`counter-set`, read back by @@ -467,7 +508,8 @@ private static DisplayList BuildDisplayList(IDocument document, RenderViewport v context, displayList, maxY, - containingHeight: float.NaN, + // the root's containing block is the viewport (CSS 2.1, 10.1), so `height: 100%` on it fills the viewport + containingHeight: Math.Max(0f, viewport.Height - (2f * context.Padding)), positionedContainingX: float.NaN, positionedContainingY: float.NaN, positionedContainingWidth: float.NaN, @@ -599,7 +641,8 @@ private static void LayoutNode( float forcedBorderBoxWidth = float.NaN, bool isAtomicInline = false, float forcedBorderBoxHeight = float.NaN, - float contentOffsetY = 0f) + float contentOffsetY = 0f, + bool mainAxisMarginsPlaced = false) { switch (node) { @@ -607,7 +650,7 @@ 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: - 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); + 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); return; default: return; @@ -640,7 +683,8 @@ private static void LayoutElement( float forcedBorderBoxWidth = float.NaN, bool isAtomicInline = false, float forcedBorderBoxHeight = float.NaN, - float contentOffsetY = 0f) + float contentOffsetY = 0f, + bool mainAxisMarginsPlaced = false) { var element = node.Ref; var computedStyle = node.ComputedStyle; @@ -659,7 +703,7 @@ private static void LayoutElement( return; } - var formControlKind = ResolveFormControlKind(element); + var formControlKind = ResolveFormControlKind(node); var display = GetDisplay(styleMap); @@ -794,6 +838,21 @@ private static void LayoutElement( marginRight = 0f; } + // a flex container places its items' border boxes after their main-axis margins itself + if (mainAxisMarginsPlaced) + { + if (isRowDirection) + { + marginLeft = 0f; + marginRight = 0f; + } + else + { + marginTop = 0f; + marginBottom = 0f; + } + } + // `auto` margins of atomic inlines and floats compute to zero: there is no free space on // a line to center them in. if (isAtomicInline || isFloat) @@ -834,7 +893,7 @@ private static void LayoutElement( // further down once `height` is auto instead). Only applies when `width` is genuinely // unauthored (not merely resolved to NaN through a flex/grid override, which already // has its own, more specific sizing story this must not second-guess). - var heightReferenceForAspectRatio = float.IsNaN(containingHeight) ? flowContainingWidth : containingHeight; + var heightReferenceForAspectRatio = containingHeight; var explicitContentHeight = ResolveAuthoredDimension( styleMap, "height", @@ -881,6 +940,12 @@ private static void LayoutElement( inheritedTextStyle, context); } + else if (element is not IPseudoElement && string.Equals(element.LocalName, "button", StringComparison.OrdinalIgnoreCase)) + { + // a button is as wide as its content (fit-content), also when it is block-level (HTML 15.5.2) + var fitContentWidth = ResolveShrinkToFitBorderBoxWidth(node, flowContainingWidth, inheritedTextStyle, context, out _, out _); + specifiedContentWidth = Math.Max(0f, fitContentWidth - horizontalBorderAndPadding); + } } var authoredMarginLeft = marginLeft; @@ -961,22 +1026,25 @@ private static void LayoutElement( var absoluteContainingY = float.IsNaN(positionedContainingY) ? containingY : positionedContainingY; var absoluteContainingWidth = float.IsNaN(positionedContainingWidth) ? containingWidth : positionedContainingWidth; + // with both offsets of an axis auto, an absolutely positioned box stays where it would be in flow + var hasStaticX = isAbsolute && float.IsNaN(rightOffset) && float.IsNaN(ParseLength(styleMap, "left", flowContainingWidth, float.NaN, allowAuto: true)); + var hasStaticY = isAbsolute && float.IsNaN(bottomOffset) && float.IsNaN(ParseLength(styleMap, "top", flowContainingWidth, float.NaN, allowAuto: true)); var borderBoxX = isFixed ? context.Padding + leftOffset : isAbsolute - ? absoluteContainingX + leftOffset + ? hasStaticX ? flowBorderBoxX : absoluteContainingX + leftOffset : flowBorderBoxX + (isRelative ? leftOffset : 0f); // `position: sticky` is laid out at its natural position; its container shifts it once // its own box is known (see ResolveStickyBoxes). var borderBoxY = isFixed ? context.Padding + topOffset : isAbsolute - ? absoluteContainingY + topOffset + ? hasStaticY ? flowBorderBoxY : absoluteContainingY + topOffset : flowBorderBoxY + (isRelative ? topOffset : 0f); var contentX = borderBoxX + borderLeft + paddingLeft; var contentY = borderBoxY + borderTop + paddingTop; var verticalBorderAndPadding = borderTop + borderBottom + paddingTop + paddingBottom; - var heightReference = float.IsNaN(containingHeight) ? flowContainingWidth : containingHeight; + var heightReference = containingHeight; var resolvedContentHeight = ResolveFlexibleContentDimension( styleMap, heightReference, @@ -1093,8 +1161,11 @@ private static void LayoutElement( formControlKind is FormControlKind.Select or FormControlKind.Button || (formControlKind == FormControlKind.TextArea && ResolveChangedTextAreaValue(element) is not null)) ? [] - : OrderChildrenForPainting(node.Children, context).ToList(); - if (IsFlexContainer(styleMap)) + : OrderChildrenForLayout(node.Children, context).ToList(); + // a control painting its own label (or selected option) has no children to lay out as items + var layoutsItems = formControlKind is not (FormControlKind.Select or FormControlKind.Button); + + if (layoutsItems && IsFlexContainer(styleMap)) { LayoutFlexContainer( node, @@ -1124,11 +1195,13 @@ formControlKind is FormControlKind.Select or FormControlKind.Button || flowBorderBoxY, borderBoxX, borderBoxY, - flexCrossSize); + // the height the box got from its own flex container (along either axis) or otherwise + float.IsNaN(resolvedContentHeight) ? null : resolvedContentHeight, + containingHeight); return; } - if (IsGridContainer(styleMap)) + if (layoutsItems && IsGridContainer(styleMap)) { LayoutGridContainer( node, @@ -1298,7 +1371,8 @@ formControlKind is FormControlKind.Select or FormControlKind.Button || new EdgeSizes(borderTop, borderRight, borderBottom, borderLeft), new EdgeSizes(paddingTop, paddingRight, paddingBottom, paddingLeft), box.BorderRadius, - currentTextStyle.FontSize); + currentTextStyle.FontSize, + BlendsInside(displayList, boxPaintInsertIndex)); var createsStackingContext = HasExplicitZIndex(styleMap) || effects.CreatesStackingContext; PaintBoxBackground(decorationBuffer, styleMap, box, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, new EdgeSizes(borderTop, borderRight, borderBottom, borderLeft), new EdgeSizes(paddingTop, paddingRight, paddingBottom, paddingLeft)); @@ -1380,9 +1454,27 @@ formControlKind is FormControlKind.Select or FormControlKind.Button || /// The whole-element effect scopes a box opened, outermost first: transform, blend mode, /// opacity, filter, clip-path, mask. /// - private readonly record struct ElementEffectScopes(bool Transform, bool BlendMode, bool Opacity, bool Filter, bool ClipPath, bool Mask, bool BackdropFilter) + private readonly record struct ElementEffectScopes(bool Transform, bool BlendMode, bool Isolation, bool Opacity, bool Filter, bool ClipPath, bool Mask, bool BackdropFilter) { - public bool CreatesStackingContext => Transform || BlendMode || Opacity || Filter || ClipPath || Mask || BackdropFilter; + public bool CreatesStackingContext => Transform || BlendMode || Isolation || Opacity || Filter || ClipPath || Mask || BackdropFilter; + } + + /// + /// Whether the commands from the given index on blend with what is behind them (`mix-blend-mode`). + /// + private static bool BlendsInside(DisplayList displayList, int startIndex) + { + var commands = displayList.Commands; + + for (var index = startIndex; index < commands.Count; index++) + { + if (commands[index] is PushBlendModeCommand { Mode: not RenderBlendMode.Normal }) + { + return true; + } + } + + return false; } /// @@ -1404,7 +1496,8 @@ private static ElementEffectScopes OpenElementEffectScopes( EdgeSizes border, EdgeSizes padding, RenderCornerRadii radii, - float fontSize) + float fontSize, + bool blendsInside = true) { var transform = ParseCssTransform(styleMap, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, fontSize); var filterFunctions = ParseCssFilter(styleMap); @@ -1417,7 +1510,10 @@ private static ElementEffectScopes OpenElementEffectScopes( var backdropFunctions = ParseCssFilter(styleMap, "backdrop-filter"); var scopes = new ElementEffectScopes( Transform: !transform.IsIdentity, - BlendMode: blendMode != RenderBlendMode.Normal || isIsolated, + // isolation only bounds the blend modes inside, so without any it needs no group of its own + // (which would also hide what is painted behind it from a backdrop-filter inside) + BlendMode: blendMode != RenderBlendMode.Normal || (isIsolated && blendsInside), + Isolation: isIsolated, Opacity: opacity < 1f, Filter: filterFunctions.Count > 0, ClipPath: clipPathShape is not null, @@ -1521,10 +1617,21 @@ private readonly record struct FlexItemLayoutInfo( string AlignSelf, float MinMainSize = 0f, float MaxMainSize = float.PositiveInfinity, - float MainBorderAndPadding = 0f) + float MainBorderAndPadding = 0f, + bool HasDefiniteCrossSize = false, + float MainMarginStart = 0f, + float MainMarginEnd = 0f) { /// The flex base size clamped by the item's min and max main size. public float HypotheticalMainSize => Math.Max(MinMainSize, Math.Min(MaxMainSize, BaseMainSize)); + + /// The margins along the main axis, where an auto margin (NaN) counts as zero. + public float MainMargins => UsedMargin(MainMarginStart) + UsedMargin(MainMarginEnd); + + /// The number of auto margins along the main axis. + public int AutoMainMargins => (float.IsNaN(MainMarginStart) ? 1 : 0) + (float.IsNaN(MainMarginEnd) ? 1 : 0); + + public static float UsedMargin(float margin) => float.IsNaN(margin) ? 0f : margin; } private readonly record struct GridPlacement(int LineIndex, int Span); @@ -1626,7 +1733,8 @@ private static void LayoutFlexContainer( float flowBorderBoxY, float borderBoxX, float borderBoxY, - float? flexCrossSize) + float? usedContentHeight, + float containingHeight) { // See the matching comment in LayoutElement: the container's own background/border must // paint behind its items, but its auto-sized height is only known after they are laid out @@ -1644,11 +1752,19 @@ private static void LayoutFlexContainer( var ignoresWhitespaceFlexItems = string.Equals(flexWrap, "wrap", StringComparison.OrdinalIgnoreCase) || string.Equals(justifyContent, "space-around", StringComparison.OrdinalIgnoreCase) || string.Equals(justifyContent, "space-evenly", StringComparison.OrdinalIgnoreCase); - var flexItems = OrderChildrenForPainting(node.Children, context) - .Where(child => child is ElementRenderNode || + // 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(); + // absolutely positioned children do not take part in the flex layout + var outOfFlowChildren = children + .OfType() + .Where(child => IsOutOfFlowPositioned(GetOrCreateStyleMap(context, child.Ref, child.ComputedStyle))) + .ToList(); + var flexItems = children + .Where(child => child is ElementRenderNode element && !outOfFlowChildren.Contains(element) || child is TextRenderNode { Ref.Data: var data } && !string.IsNullOrWhiteSpace(data)) - .Select(child => CreateFlexItemLayoutInfo(child, isRowDirection, containingWidth, inheritedTextStyle, context)) + .Select(child => CreateFlexItemLayoutInfo(child, isRowDirection, containingWidth, definiteContentHeight, inheritedTextStyle, context)) .OrderBy(item => item.Order) .ToList(); @@ -1656,24 +1772,22 @@ private static void LayoutFlexContainer( var rowGapValue = ResolveRowGap(styleMap, ResolveGapHeightReference(styleMap, containingWidth, paddingTop + paddingBottom + borderTop + borderBottom)) ?? 0f; var mainGap = isRowDirection ? columnGapValue : rowGapValue; var crossGap = isRowDirection ? rowGapValue : columnGapValue; - if (flexItems.Count == 0) - { - cursorY = flowBorderBoxY + borderTop + paddingTop + borderBottom + paddingBottom; - previousBlockMarginBottom = 0f; - suppressNextBlockTopMargin = false; - return; - } - var horizontalBorderAndPadding = borderLeft + borderRight + paddingLeft + paddingRight; var verticalBorderAndPadding = borderTop + borderBottom + paddingTop + paddingBottom; var containerMainSize = isRowDirection ? containingWidth - : ResolveAuthoredDimension(styleMap, "height", containingWidth, float.NaN, verticalBorderAndPadding); + : ResolveAuthoredDimension(styleMap, "height", containingHeight, float.NaN, verticalBorderAndPadding); + + // a column that got its height from its own flex container (e.g. stretched) distributes it + if (!isRowDirection && float.IsNaN(containerMainSize) && usedContentHeight.HasValue) + { + containerMainSize = usedContentHeight.Value; + } if (float.IsNaN(containerMainSize)) { - containerMainSize = flexItems.Sum(item => item.HypotheticalMainSize) + Math.Max(0, flexItems.Count - 1) * mainGap; + containerMainSize = flexItems.Sum(item => item.HypotheticalMainSize + item.MainMargins) + Math.Max(0, flexItems.Count - 1) * mainGap; } else if (containerMainSize <= 0f) { @@ -1682,13 +1796,13 @@ private static void LayoutFlexContainer( var specifiedCrossSize = isRowDirection - ? ResolveAuthoredDimension(styleMap, "height", containingWidth, float.NaN, verticalBorderAndPadding) + ? ResolveAuthoredDimension(styleMap, "height", containingHeight, float.NaN, verticalBorderAndPadding) : containingWidth; var minimumCrossSize = isRowDirection - ? ResolveAuthoredDimension(styleMap, "min-height", containingWidth, 0f, verticalBorderAndPadding) + ? ResolveAuthoredDimension(styleMap, "min-height", containingHeight, 0f, verticalBorderAndPadding) : ResolveAuthoredDimension(styleMap, "min-width", containingWidth, 0f, horizontalBorderAndPadding); var containerCrossSize = float.IsNaN(specifiedCrossSize) - ? Math.Max(minimumCrossSize, flexCrossSize ?? 0f) + ? Math.Max(minimumCrossSize, usedContentHeight ?? 0f) : Math.Max(specifiedCrossSize, minimumCrossSize); var contentWidth = containingWidth; @@ -1700,7 +1814,7 @@ private static void LayoutFlexContainer( foreach (var item in flexItems) { - if (string.Equals(flexWrap, "wrap", StringComparison.OrdinalIgnoreCase) && currentLine.Count > 0 && currentLineMainSize + mainGap + item.HypotheticalMainSize > containerMainSize && containerMainSize > 0f) + if (string.Equals(flexWrap, "wrap", StringComparison.OrdinalIgnoreCase) && currentLine.Count > 0 && currentLineMainSize + mainGap + item.HypotheticalMainSize + item.MainMargins > containerMainSize && containerMainSize > 0f) { lines.Add(currentLine); currentLine = new List(); @@ -1708,7 +1822,7 @@ private static void LayoutFlexContainer( } currentLine.Add(item); - currentLineMainSize += item.HypotheticalMainSize; + currentLineMainSize += item.HypotheticalMainSize + item.MainMargins; } if (currentLine.Count > 0) @@ -1723,7 +1837,7 @@ private static void LayoutFlexContainer( for (var lineIndex = 0; lineIndex < lines.Count; lineIndex++) { var line = lines[lineIndex]; - if (line.Count > 0 && line.All(item => !item.Style.ContainsKey("height"))) + if (line.Count > 0 && line.All(item => !item.HasDefiniteCrossSize)) { line = line.Select(item => item with { CrossSize = Math.Max(item.CrossSize, minimumLineCrossSize) }).ToList(); lines[lineIndex] = line; @@ -1731,10 +1845,16 @@ private static void LayoutFlexContainer( } } + // in a column, only items that are not stretched across a single line need their fit-content width + float ColumnCrossAvailable(FlexItemLayoutInfo item) => !isRowDirection + && (lines.Count > 1 || !string.Equals(string.Equals(item.AlignSelf, "auto", StringComparison.OrdinalIgnoreCase) ? alignItems : item.AlignSelf, "stretch", StringComparison.OrdinalIgnoreCase)) + ? containingWidth + : float.NaN; + var lineCrossSizes = lines.Select(line => line.Count > 0 - ? line.Max(item => item.Style.ContainsKey("height") + ? line.Max(item => item.HasDefiniteCrossSize ? item.CrossSize - : Math.Max(item.CrossSize, EstimateFlexItemCrossSize(item.Node, item.HypotheticalMainSize, isRowDirection, inheritedTextStyle, context))) + : Math.Max(item.CrossSize, EstimateFlexItemCrossSize(item.Node, item.HypotheticalMainSize, isRowDirection, inheritedTextStyle, context, ColumnCrossAvailable(item)))) : 0f).ToList(); var totalCrossSize = lineCrossSizes.Sum() + Math.Max(0, lines.Count - 1) * crossGap; var remainingCrossSize = Math.Max(0f, containerCrossSize - totalCrossSize); @@ -1765,21 +1885,19 @@ private static void LayoutFlexContainer( break; } - var childCursorY = containingY; var childPreviousBlockMarginBottom = 0f; - var childSuppressNextBlockTopMargin = false; - var childFloats = new FloatArea(); - var childTextIndentConsumed = false; var totalLineMainSize = 0f; + var paintOrder = new PaintOrder(); var finalLineCrossSizes = new List(lines.Count); for (var lineIndex = 0; lineIndex < lines.Count; lineIndex++) { var line = lines[lineIndex]; 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); - var finalMainSizes = ResolveFlexibleLengths(line, containerMainSize - lineGap); - var lineMainSize = lineGap; + var finalMainSizes = ResolveFlexibleLengths(line, containerMainSize - lineGap - lineMargins); + var lineMainSize = lineGap + lineMargins; for (var itemIndex = 0; itemIndex < line.Count; itemIndex++) { @@ -1790,8 +1908,12 @@ private static void LayoutFlexContainer( var spacerCount = Math.Max(0, lineItems.Count - 1); var lineMainSpacing = 0f; var lineMainStart = 0f; + // auto margins take the free space before justify-content gets it + var autoMarginCount = line.Sum(item => item.AutoMainMargins); + var autoMarginSize = autoMarginCount > 0 ? Math.Max(0f, containerMainSize - lineMainSize) / autoMarginCount : 0f; + lineMainSize += autoMarginSize * autoMarginCount; - switch (justifyContent) + switch (autoMarginCount > 0 ? "flex-start" : justifyContent) { case "center": lineMainStart = Math.Max(0f, containerMainSize - lineMainSize) / 2f; @@ -1816,9 +1938,9 @@ private static void LayoutFlexContainer( } var lineCrossSize = lineItems.Count > 0 - ? lineItems.Max(entry => entry.Item.Style.ContainsKey("height") + ? lineItems.Max(entry => entry.Item.HasDefiniteCrossSize ? entry.Item.CrossSize - : Math.Max(entry.Item.CrossSize, EstimateFlexItemCrossSize(entry.Item.Node, entry.FinalMainSize, isRowDirection, inheritedTextStyle, context))) + : Math.Max(entry.Item.CrossSize, EstimateFlexItemCrossSize(entry.Item.Node, entry.FinalMainSize, isRowDirection, inheritedTextStyle, context, ColumnCrossAvailable(entry.Item)))) : 0f; finalLineCrossSizes.Add(lineCrossSize); var lineCrossPosition = currentCrossOffset; @@ -1844,11 +1966,11 @@ private static void LayoutFlexContainer( { var (item, finalMainSize) = lineItems[itemIndex]; var resolvedCrossSize = item.CrossSize; - var estimatedCrossSize = item.Style.ContainsKey("height") + var estimatedCrossSize = item.HasDefiniteCrossSize ? 0f - : EstimateFlexItemCrossSize(item.Node, finalMainSize, isRowDirection, inheritedTextStyle, context); + : EstimateFlexItemCrossSize(item.Node, finalMainSize, isRowDirection, inheritedTextStyle, context, ColumnCrossAvailable(item)); resolvedCrossSize = Math.Max(resolvedCrossSize, estimatedCrossSize); - if (string.Equals(alignItems, "stretch", StringComparison.OrdinalIgnoreCase) && !item.Style.ContainsKey("height") && lineCrossSize > 0f) + if (string.Equals(alignItems, "stretch", StringComparison.OrdinalIgnoreCase) && !item.HasDefiniteCrossSize && lineCrossSize > 0f) { resolvedCrossSize = containerCrossSize > 0f ? containerCrossSize : lineCrossSize; } @@ -1890,9 +2012,11 @@ private static void LayoutFlexContainer( itemCrossPosition = lineCrossPosition + lineCrossStart; } - var itemOffset = isReverseDirection + var marginStart = float.IsNaN(item.MainMarginStart) ? autoMarginSize : item.MainMarginStart; + var marginEnd = float.IsNaN(item.MainMarginEnd) ? autoMarginSize : item.MainMarginEnd; + var itemOffset = (isReverseDirection ? mainOffset + currentMainOffset - : lineMainStart + currentMainOffset; + : lineMainStart + currentMainOffset) + marginStart; var childX = isRowDirection ? containingX + itemOffset : containingX + itemCrossPosition; var childY = isRowDirection ? containingY + itemCrossPosition : containingY + itemOffset; var childWidth = isRowDirection ? finalMainSize : resolvedCrossSize; @@ -1900,7 +2024,10 @@ private static void LayoutFlexContainer( if (item.Node is TextRenderNode textNode) { - LayoutTextNode(textNode.Ref, childX, containingWidth, ref childCursorY, ref childPreviousBlockMarginBottom, ref childSuppressNextBlockTopMargin, childFloats, ref childTextIndentConsumed, inheritedTextStyle, context, displayList, maxY); + // laid out on its own in its anonymous flex item + var textCursorY = childY; + var textWidth = isRowDirection ? finalMainSize : resolvedCrossSize > 0f ? resolvedCrossSize : containingWidth; + LayoutWrappedText(PrepareFlexItemText(textNode, inheritedTextStyle, context), childX, textWidth, ref textCursorY, inheritedTextStyle, context, displayList, maxY, 0f); } else if (item.Node is ElementRenderNode elementChild) { @@ -1915,6 +2042,7 @@ private static void LayoutFlexContainer( var childSuppressMargin = false; var childTextIndent = false; var itemFloats = new FloatArea(); + var itemRangeStart = displayList.Commands.Count; LayoutNode( node: elementChild, @@ -1933,10 +2061,12 @@ private static void LayoutFlexContainer( isFlexItem: true, isRowDirection: isRowDirection, flexMainSize: isRowDirection ? childContentWidth : childContentHeight, - flexCrossSize: isRowDirection ? childContentHeight : childContentWidth); + flexCrossSize: isRowDirection ? childContentHeight : childContentWidth, + mainAxisMarginsPlaced: true); + paintOrder.Track(elementChild, displayList, itemRangeStart, context); } - currentMainOffset += finalMainSize + lineMainSpacing + (itemIndex + 1 < lineItems.Count ? mainGap : 0f); + currentMainOffset += marginStart + finalMainSize + marginEnd + lineMainSpacing + (itemIndex + 1 < lineItems.Count ? mainGap : 0f); } currentCrossOffset += lineCrossSize + crossSpacing + (lineIndex + 1 < lines.Count ? crossGap : 0f); @@ -1946,17 +2076,21 @@ private static void LayoutFlexContainer( var autoContentHeight = isRowDirection ? finalLineCrossSizes.Sum() + Math.Max(0, lines.Count - 1) * crossGap : Math.Max(0f, containerMainSize); - var specifiedContentHeight = ResolveAuthoredDimension(styleMap, "height", containingWidth, float.NaN, verticalBorderAndPadding); - var minimumContentHeight = ResolveAuthoredDimension(styleMap, "min-height", containingWidth, 0f, verticalBorderAndPadding); - var maximumContentHeight = ResolveAuthoredDimension(styleMap, "max-height", containingWidth, float.NaN, verticalBorderAndPadding); + var specifiedContentHeight = ResolveAuthoredDimension(styleMap, "height", containingHeight, float.NaN, verticalBorderAndPadding); + var minimumContentHeight = ResolveAuthoredDimension(styleMap, "min-height", containingHeight, 0f, verticalBorderAndPadding); + var maximumContentHeight = ResolveAuthoredDimension(styleMap, "max-height", containingHeight, float.NaN, verticalBorderAndPadding); + // only a row's cross size is a height; a column's is its width contentHeight = float.IsNaN(specifiedContentHeight) - ? Math.Max(autoContentHeight, containerCrossSize) + ? isRowDirection ? Math.Max(autoContentHeight, containerCrossSize) : autoContentHeight : specifiedContentHeight; contentHeight = Math.Max(contentHeight, minimumContentHeight); if (!float.IsNaN(maximumContentHeight)) { contentHeight = Math.Min(contentHeight, maximumContentHeight); } + + LayoutOutOfFlowChildren(contentHeight); + paintOrder.Apply(displayList); var borderBoxWidth = borderLeft + paddingLeft + containingWidth + paddingRight + borderRight; var borderBoxHeight = borderTop + paddingTop + contentHeight + paddingBottom + borderBottom; var canCollapseWithLastChild = borderBottom <= 0f && paddingBottom <= 0f && float.IsNaN(specifiedContentHeight); @@ -1983,7 +2117,8 @@ private static void LayoutFlexContainer( new EdgeSizes(borderTop, borderRight, borderBottom, borderLeft), new EdgeSizes(paddingTop, paddingRight, paddingBottom, paddingLeft), box.BorderRadius, - inheritedTextStyle.FontSize); + inheritedTextStyle.FontSize, + BlendsInside(displayList, boxPaintInsertIndex)); if (box.BackgroundPaint is RenderColorPaint colorPaint && colorPaint.Color.A == 0) { @@ -2061,6 +2196,48 @@ private static void LayoutFlexContainer( cursorY = flowBorderBoxY + borderBoxHeight; previousBlockMarginBottom = effectiveMarginBottom + context.ParagraphSpacing; suppressNextBlockTopMargin = false; + + // laid out against the container's padding box, from where they would be as its only item + void LayoutOutOfFlowChildren(float usedContentHeight) + { + var contentY = borderBoxY + borderTop + paddingTop; + + foreach (var child in outOfFlowChildren) + { + var childCursorY = contentY; + + if (isRowDirection && alignItems is "center" or "flex-end") + { + var freeSpace = Math.Max(0f, usedContentHeight - MeasureBorderBoxHeight(child, containingWidth, inheritedTextStyle, context)); + childCursorY += alignItems == "center" ? freeSpace / 2f : freeSpace; + } + + var childPreviousBottom = 0f; + var childSuppressMargin = false; + var childTextIndent = false; + var childRangeStart = displayList.Commands.Count; + + LayoutNode( + child, + containingX, + contentY, + containingWidth, + ref childCursorY, + ref childPreviousBottom, + ref childSuppressMargin, + new FloatArea(), + ref childTextIndent, + inheritedTextStyle, + context, + displayList, + maxY, + positionedContainingX: borderBoxX + borderLeft, + positionedContainingY: borderBoxY + borderTop, + positionedContainingWidth: paddingLeft + containingWidth + paddingRight, + positionedContainingHeight: paddingTop + usedContentHeight + paddingBottom); + paintOrder.Track(child, displayList, childRangeStart, context); + } + } } private static float ResolveFlexibleContentDimension( @@ -2135,7 +2312,7 @@ private static void LayoutGridContainer( var rowGap = ResolveRowGap(styleMap, ResolveGapHeightReference(styleMap, containingWidth, paddingTop + paddingBottom + borderTop + borderBottom)); var resolvedColumnGap = columnGap ?? 0f; var resolvedRowGap = rowGap ?? 0f; - var gridItems = node.Children + var gridItems = FlattenDisplayContents(node.Children, context) .Where(child => child is ElementRenderNode || (child is TextRenderNode textNode && NormalizeWhitespace(textNode.Ref.Data).Length > 0)) .ToList(); @@ -2269,7 +2446,17 @@ void MarkPlaced(GridPlacement column, GridPlacement row) rows.Add(GridTrackSize.Auto()); } - MarkPlaced(new GridPlacement(area.Column, area.ColumnSpan), new GridPlacement(area.Row, area.RowSpan)); + var areaColumn = new GridPlacement(area.Column, area.ColumnSpan); + var areaRow = new GridPlacement(area.Row, area.RowSpan); + MarkPlaced(areaColumn, areaRow); + + // sized like any other item: content-sized columns fit it, rows its authored height + if (Enumerable.Range(areaColumn.LineIndex, areaColumn.Span).Any(i => columns[i].Kind == GridTrackSizeKind.Auto)) + { + columnContributions.Add((areaColumn, ComputeOuterIntrinsicWidths(elementChild, inheritedTextStyle, containingWidth, context))); + } + + GrowGridTrackSpan(rows, areaRow, ResolveGridItemAuthoredHeight(childStyleMap, containingWidth), resolvedRowGap); currentColumn = 0; currentRow = 0; @@ -2368,7 +2555,7 @@ void MarkPlaced(GridPlacement column, GridPlacement row) columnContributions.Add((effectivePlacementColumn, ComputeOuterIntrinsicWidths(elementChild, inheritedTextStyle, containingWidth, context))); } - GrowGridTrackSize(rows, effectivePlacementRow.LineIndex, authoredItemHeight); + GrowGridTrackSpan(rows, effectivePlacementRow, authoredItemHeight, resolvedRowGap); currentColumn++; if (currentColumn >= columns.Count) @@ -2424,13 +2611,19 @@ void MarkPlaced(GridPlacement column, GridPlacement row) } else { - GrowGridTrackSize(rows, placementRow.LineIndex, measuredHeight); + GrowGridTrackSpan(rows, placementRow, measuredHeight, resolvedRowGap); } } if (!float.IsNaN(containerHeight)) { ResolveGridFractionTracks(rows, resolvedRowGap, containerHeight); + + // auto rows fill a definite height like auto columns do (align-content: normal) + if (styleMap.GetValueOrDefault("align-content")?.Trim().ToLowerInvariant() is null or "" or "normal" or "stretch") + { + StretchAutoGridRows(rows, resolvedRowGap, containerHeight); + } } else { @@ -2567,7 +2760,8 @@ void MarkPlaced(GridPlacement column, GridPlacement row) new EdgeSizes(borderTop, borderRight, borderBottom, borderLeft), new EdgeSizes(paddingTop, paddingRight, paddingBottom, paddingLeft), box.BorderRadius, - inheritedTextStyle.FontSize); + inheritedTextStyle.FontSize, + BlendsInside(displayList, boxPaintInsertIndex)); if (box.BackgroundPaint is RenderColorPaint colorPaint && colorPaint.Color.A == 0) { @@ -3410,6 +3604,55 @@ void SpreadExcess(float[] sizes, float contribution) } } + private static void StretchAutoGridRows(List rows, float gap, float availableHeight) + { + var freeSpace = availableHeight - rows.Sum(t => t.Pixels) - (gap * Math.Max(0, rows.Count - 1)); + + if (freeSpace > 0.01f && rows.All(t => t.Kind != GridTrackSizeKind.Fraction)) + { + var stretchable = rows.Where(t => t.Kind == GridTrackSizeKind.Auto && t.MaxSizing == GridTrackMaxSizing.Auto).ToList(); + + foreach (var track in stretchable) + { + track.Pixels += freeSpace / stretchable.Count; + } + } + } + + /// + /// Grows the tracks an item spans to fit it: an item spanning a single track grows that one, + /// one spanning several spreads what those tracks still lack equally over the content-sized + /// ones, and one spanning an `fr` track is left to it (CSS Grid 1, 11.5). + /// + private static void GrowGridTrackSpan(List tracks, GridPlacement placement, float itemEstimate, float gap) + { + if (placement.Span <= 1) + { + GrowGridTrackSize(tracks, placement.LineIndex, itemEstimate); + return; + } + + var spanned = Enumerable.Range(placement.LineIndex, placement.Span).Where(i => i >= 0 && i < tracks.Count).ToList(); + + if (spanned.Any(i => tracks[i].Kind == GridTrackSizeKind.Fraction)) + { + return; + } + + var growable = spanned.Where(i => tracks[i].Kind == GridTrackSizeKind.Auto).ToList(); + var missing = itemEstimate - spanned.Sum(i => tracks[i].Pixels) - (gap * (spanned.Count - 1)); + + if (growable.Count == 0 || float.IsNaN(missing) || missing <= 0f) + { + return; + } + + foreach (var index in growable) + { + GrowGridTrackSize(tracks, index, tracks[index].Pixels + (missing / growable.Count)); + } + } + private static void GrowGridTrackSize(List tracks, int index, float itemEstimate) { while (tracks.Count <= index) @@ -3784,6 +4027,35 @@ private static IReadOnlyList WrapTextRespectingWhiteSpace(LayoutContext private static bool IsNoWrapWhiteSpace(WhiteSpaceMode whiteSpace) => whiteSpace is WhiteSpaceMode.Nowrap or WhiteSpaceMode.Pre; + /// + /// The children of a flex or grid container as items: a child with `display: none` is none, and + /// one with `display: contents` generates no box of its own, so its children take its place + /// (CSS Display 3, 2.5) - e.g. a hidden child must not occupy a grid cell. + /// + private static IEnumerable FlattenDisplayContents(IEnumerable children, LayoutContext context) + { + foreach (var child in children) + { + if (!GeneratesBoxes(child)) + { + continue; + } + + if (child is ElementRenderNode element && + string.Equals(GetDisplay(GetOrCreateStyleMap(context, element.Ref, element.ComputedStyle))?.Trim(), "contents", StringComparison.OrdinalIgnoreCase)) + { + foreach (var grandChild in FlattenDisplayContents(element.Children, context)) + { + yield return grandChild; + } + } + else + { + yield return child; + } + } + } + private static string? GetDisplay(Dictionary styleMap) { return styleMap.TryGetValue("display", out var display) ? display : null; @@ -3811,17 +4083,39 @@ private static string GetFlexDirection(Dictionary styleMap) private static string GetJustifyContent(Dictionary styleMap) { return styleMap.TryGetValue("justify-content", out var value) && !string.IsNullOrWhiteSpace(value) - ? value.Trim().ToLowerInvariant() + ? NormalizeFlexAlignment(value, "flex-start") : "flex-start"; } private static string GetAlignItems(Dictionary styleMap) { return styleMap.TryGetValue("align-items", out var value) && !string.IsNullOrWhiteSpace(value) - ? value.Trim().ToLowerInvariant() + ? NormalizeFlexAlignment(value, "stretch") : "stretch"; } + /// + /// Maps the keywords of CSS Box Alignment (e.g. end, safe center) to the flex + /// ones they are equivalent to for flex items, and normal to the given default. + /// + private static string NormalizeFlexAlignment(string value, string normal) + { + var normalized = value.Trim().ToLowerInvariant(); + + if (normalized.StartsWith("safe ", StringComparison.Ordinal) || normalized.StartsWith("unsafe ", StringComparison.Ordinal)) + { + normalized = normalized[(normalized.IndexOf(' ') + 1)..].Trim(); + } + + return normalized switch + { + "start" or "self-start" or "left" => "flex-start", + "end" or "self-end" or "right" => "flex-end", + "normal" => normal, + _ => normalized, + }; + } + private static string GetFlexWrap(Dictionary styleMap) { return styleMap.TryGetValue("flex-wrap", out var value) && !string.IsNullOrWhiteSpace(value) @@ -3832,7 +4126,7 @@ private static string GetFlexWrap(Dictionary styleMap) private static string GetAlignContent(Dictionary styleMap) { return styleMap.TryGetValue("align-content", out var value) && !string.IsNullOrWhiteSpace(value) - ? value.Trim().ToLowerInvariant() + ? NormalizeFlexAlignment(value, "stretch") : "stretch"; } @@ -3862,12 +4156,22 @@ private static string GetAlignSelf(Dictionary styleMap, string f return fallback; } - var normalized = value.Trim().ToLowerInvariant(); + var normalized = NormalizeFlexAlignment(value, "stretch"); return normalized == "auto" ? fallback : normalized; } - private static FlexItemLayoutInfo CreateFlexItemLayoutInfo(IRenderNode child, bool isRowDirection, float relativeTo, RenderTextStyle inheritedTextStyle, LayoutContext context) + private static FlexItemLayoutInfo CreateFlexItemLayoutInfo(IRenderNode child, bool isRowDirection, float relativeTo, float heightRelativeTo, RenderTextStyle inheritedTextStyle, LayoutContext context) { + if (child is TextRenderNode textChild) + { + // a run of text is wrapped in an anonymous flex item, sized by its content + var text = PrepareFlexItemText(textChild, inheritedTextStyle, context); + var textMainSize = isRowDirection ? ComputeTextIntrinsicWidths(text, inheritedTextStyle, context, out _) : default; + var textBaseSize = isRowDirection ? textMainSize.Max : MeasureFlexItemTextHeight(text, relativeTo, inheritedTextStyle, context); + var textMinSize = isRowDirection ? textMainSize.Min : textBaseSize; + return new FlexItemLayoutInfo(child, new Dictionary(StringComparer.OrdinalIgnoreCase), 0f, 0f, 1f, textBaseSize, 0f, "auto", textMinSize); + } + if (child is not ElementRenderNode elementChild) { return new FlexItemLayoutInfo(child, new Dictionary(StringComparer.OrdinalIgnoreCase), 0f, 0f, 1f, 0f, 0f, "auto"); @@ -3886,9 +4190,14 @@ private static FlexItemLayoutInfo CreateFlexItemLayoutInfo(IRenderNode child, bo var verticalBorderAndPadding = childBox.BorderWidth.Top + childBox.BorderWidth.Bottom + childBox.Padding.Top + childBox.Padding.Bottom; var mainBorderAndPadding = isRowDirection ? horizontalBorderAndPadding : verticalBorderAndPadding; var crossBorderAndPadding = isRowDirection ? verticalBorderAndPadding : horizontalBorderAndPadding; - var crossSize = ResolveFlexCrossSize(childStyle, isRowDirection, relativeTo, crossBorderAndPadding); + // widths refer to the container's width, heights to its height + var mainRelativeTo = isRowDirection ? relativeTo : heightRelativeTo; + var crossRelativeTo = isRowDirection ? heightRelativeTo : relativeTo; + var crossSize = ResolveFlexCrossSize(childStyle, isRowDirection, crossRelativeTo, crossBorderAndPadding); + // `auto` (and keywords like `fit-content`) leave the cross size to the content or to stretching + var hasDefiniteCrossSize = !float.IsNaN(ResolveAuthoredDimension(childStyle, isRowDirection ? "height" : "width", crossRelativeTo, float.NaN, crossBorderAndPadding)); var mainProperty = isRowDirection ? "width" : "height"; - var specifiedMainSize = ResolveAuthoredDimension(childStyle, mainProperty, relativeTo, float.NaN, mainBorderAndPadding); + var specifiedMainSize = ResolveAuthoredDimension(childStyle, mainProperty, mainRelativeTo, float.NaN, mainBorderAndPadding); // The content size along the main axis: max-content (or the image's width) for rows, the // laid-out height for columns; the min-content width feeds the automatic minimum. @@ -3912,7 +4221,7 @@ private static FlexItemLayoutInfo CreateFlexItemLayoutInfo(IRenderNode child, bo } // `flex-basis` when definite, else the main size property, else the content size. - var baseMainSize = ResolveFlexBaseSize(childStyle, relativeTo, mainBorderAndPadding); + var baseMainSize = ResolveFlexBaseSize(childStyle, mainRelativeTo, mainBorderAndPadding); if (float.IsNaN(baseMainSize)) { @@ -3920,7 +4229,7 @@ private static FlexItemLayoutInfo CreateFlexItemLayoutInfo(IRenderNode child, bo } var minProperty = isRowDirection ? "min-width" : "min-height"; - var maxMainSize = ResolveAuthoredDimension(childStyle, isRowDirection ? "max-width" : "max-height", relativeTo, float.NaN, mainBorderAndPadding); + var maxMainSize = ResolveAuthoredDimension(childStyle, isRowDirection ? "max-width" : "max-height", mainRelativeTo, float.NaN, mainBorderAndPadding); var minRaw = childStyle.GetValueOrDefault(minProperty)?.Trim(); float minMainSize; @@ -3939,7 +4248,7 @@ private static FlexItemLayoutInfo CreateFlexItemLayoutInfo(IRenderNode child, bo } else { - minMainSize = ResolveAuthoredDimension(childStyle, minProperty, relativeTo, 0f, mainBorderAndPadding); + minMainSize = ResolveAuthoredDimension(childStyle, minProperty, mainRelativeTo, 0f, mainBorderAndPadding); } return new FlexItemLayoutInfo( @@ -3953,7 +4262,11 @@ private static FlexItemLayoutInfo CreateFlexItemLayoutInfo(IRenderNode child, bo GetAlignSelf(childStyle, "auto"), minMainSize + mainBorderAndPadding, float.IsNaN(maxMainSize) ? float.PositiveInfinity : maxMainSize + mainBorderAndPadding, - mainBorderAndPadding); + mainBorderAndPadding, + hasDefiniteCrossSize, + // margins refer to the container's width on both axes; auto ones are NaN + ParseLength(childStyle, isRowDirection ? "margin-left" : "margin-top", relativeTo, isRowDirection ? childBox.Margin.Left : childBox.Margin.Top, allowAuto: true), + ParseLength(childStyle, isRowDirection ? "margin-right" : "margin-bottom", relativeTo, isRowDirection ? childBox.Margin.Right : childBox.Margin.Bottom, allowAuto: true)); } /// @@ -4065,10 +4378,43 @@ private static float ResolveFlexCrossSize(Dictionary styleMap, b return float.IsNaN(crossSize) ? 0f : crossSize; } - private static float EstimateFlexItemCrossSize(IRenderNode node, float mainSize, bool isRowDirection, RenderTextStyle inheritedTextStyle, LayoutContext context) => - isRowDirection && node is ElementRenderNode element - ? MeasureBorderBoxHeight(element, mainSize, inheritedTextStyle, context) - : 0f; + /// + /// The content cross size of a flex item: its height at the given main size for rows, its + /// fit-content width within for columns (NaN when the item + /// is stretched anyway, which skips the measurement). + /// + private static float EstimateFlexItemCrossSize(IRenderNode node, float mainSize, bool isRowDirection, RenderTextStyle inheritedTextStyle, LayoutContext context, float crossAvailable = float.NaN) => node switch + { + ElementRenderNode element when isRowDirection => MeasureBorderBoxHeight(element, mainSize, inheritedTextStyle, context), + TextRenderNode text when isRowDirection => MeasureFlexItemTextHeight(PrepareFlexItemText(text, inheritedTextStyle, context), mainSize, inheritedTextStyle, context), + _ when float.IsNaN(crossAvailable) => 0f, + ElementRenderNode element => ResolveShrinkToFitBorderBoxWidth(element, crossAvailable, inheritedTextStyle, context, out _, out _), + TextRenderNode text => MeasureFlexItemTextWidth(PrepareFlexItemText(text, inheritedTextStyle, context), crossAvailable, inheritedTextStyle, context), + _ => 0f, + }; + + private static float MeasureFlexItemTextWidth(string text, float available, RenderTextStyle textStyle, LayoutContext context) + { + var widths = ComputeTextIntrinsicWidths(text, textStyle, context, out _); + return Math.Min(widths.Max, Math.Max(widths.Min, available)); + } + + /// + /// The text of an anonymous flex item, without the collapsible white space at its start and + /// end (which would be removed at the start and end of its lines). + /// + private static string PrepareFlexItemText(TextRenderNode node, RenderTextStyle textStyle, LayoutContext context) + { + var text = ExpandTabs(context, ApplyTextTransform(NormalizeWhitespace(node.Ref.Data, textStyle.WhiteSpace), textStyle.TextTransform, previousEndsWord: false), textStyle); + return textStyle.WhiteSpace is WhiteSpaceMode.Normal or WhiteSpaceMode.Nowrap or WhiteSpaceMode.PreLine ? text.Trim() : text; + } + + private static float MeasureFlexItemTextHeight(string text, float width, RenderTextStyle textStyle, LayoutContext context) + { + var lines = WrapTextRespectingWhiteSpace(context, text, width, textStyle, new HashSet()).Count; + var shownLines = textStyle.LineClamp > 0 ? Math.Min(lines, textStyle.LineClamp) : lines; + return shownLines * textStyle.FontSize * textStyle.LineHeightMultiplier; + } /// /// The border-box height of an element laid out at the given border-box width, by a trial @@ -4519,7 +4865,8 @@ private static void PrepareDocumentForRendering(IDocument document) var currentStyle = styleAttribute; var changed = false; - if (TryExtractTransformDeclaration(currentStyle, out var transformValue, out var updatedStyle)) + // a transform using custom properties stays: only the computed value has them substituted + if (TryExtractTransformDeclaration(currentStyle, out var transformValue, out var updatedStyle) && UsesExplicitValue(transformValue)) { currentStyle = updatedStyle; changed = true; @@ -4649,7 +4996,7 @@ private static string ResolveExplicitPropertyValue(IElement? element, ICssStyleD var styleCollection = window.GetStyleCollection(device); var explicitValue = styleCollection.GetDeclarations(element!).GetPropertyValue(propertyName); - if (!string.IsNullOrWhiteSpace(explicitValue)) + if (UsesExplicitValue(explicitValue)) { return explicitValue; } @@ -4718,17 +5065,66 @@ private static Dictionary GetOrCreateStyleMap(LayoutContext cont /// resolution in ) whenever no context/style collection is /// available, exactly matching what that function already did for the same case. /// - private static ICssStyleDeclaration? GetExplicitDeclarations(LayoutContext context, IElement? element) => - element is not null && context.StyleCollection is not null - ? context.StyleCollection.GetDeclarations(element) - : null; + /// + /// The cascaded declarations of the element, merged with the inheritable ones of its ancestors + /// (StyleCollectionExtensions.GetDeclarations). The render tree already holds them as each + /// node's specified style - computing them again runs the cascade for the element and every one + /// of its ancestors, which made it the largest cost of laying out deep pages. + /// + private static ICssStyleDeclaration? GetExplicitDeclarations(LayoutContext context, IElement? element) + { + if (element is null) + { + return null; + } + + if (context.SpecifiedStyles.TryGetValue(element, out var specifiedStyle)) + { + return specifiedStyle; + } + + return context.StyleCollection?.GetDeclarations(element); + } + + private static void CollectSpecifiedStyles(IRenderNode node, LayoutContext context) + { + if (node is ElementRenderNode element) + { + context.SpecifiedStyles[element.Ref] = element.SpecifiedStyle; + + if (s_layoutPass.Value is { } pass) + { + pass.ComputedStyles[element.Ref] = element.ComputedStyle; + } + } + + foreach (var child in node.Children) + { + CollectSpecifiedStyles(child, context); + } + } + + /// + /// The computed style of the element, taken from the render tree of the current layout pass. + /// + private static ICssStyleDeclaration ComputeStyle(IStyleCollection styleCollection, IElement element) => + s_layoutPass.Value is { } pass && pass.ComputedStyles.TryGetValue(element, out var computed) + ? computed + : styleCollection.ComputeDeclarations(element); private static string ReadExplicitOrComputed(ICssStyleDeclaration explicitDeclarations, ICssStyleDeclaration computedStyle, string propertyName) { var explicitValue = explicitDeclarations.GetPropertyValue(propertyName); - return string.IsNullOrWhiteSpace(explicitValue) ? computedStyle.GetPropertyValue(propertyName) : explicitValue; + return UsesExplicitValue(explicitValue) ? explicitValue : computedStyle.GetPropertyValue(propertyName); } + /// + /// A cascaded value is only usable as long as it does not depend on custom properties, which + /// only the computed value has substituted (e.g. grid-template-columns: var(--columns)). + /// + private static bool UsesExplicitValue(string? explicitValue) => + !string.IsNullOrWhiteSpace(explicitValue) && explicitValue.IndexOf("var(", StringComparison.OrdinalIgnoreCase) < 0; + /// /// Backs every element's style map. Behaves exactly like a plain /// of string to string for every existing consumer - @@ -4790,7 +5186,7 @@ private static void AddLengthProperty(StyleMap map, ICssStyleDeclaration style, // against the viewport); any other unit (`em`, `rem`, `vw`, ...) needs the computed value. var explicitProperty = explicitDeclarations?.GetProperty(propertyName); - if (explicitProperty?.RawValue?.CssText is not { } explicitText || !explicitText.Contains('%')) + if (explicitProperty?.RawValue?.CssText is not { } explicitText || !explicitText.Contains('%') || !UsesExplicitValue(explicitText)) { explicitDeclarations = null; explicitProperty = null; @@ -4855,9 +5251,9 @@ private static Dictionary CreateStyleMap(ICssStyleDeclaration st AddIfPresent(map, "object-position", style.GetPropertyValue("object-position")); AddIfPresent(map, "clip-path", style.GetPropertyValue("clip-path")); var explicitAspectRatio = explicitDeclarations?.GetPropertyValue("aspect-ratio"); - AddIfPresent(map, "aspect-ratio", string.IsNullOrWhiteSpace(explicitAspectRatio) - ? style.GetPropertyValue("aspect-ratio") - : explicitAspectRatio); + AddIfPresent(map, "aspect-ratio", UsesExplicitValue(explicitAspectRatio) + ? explicitAspectRatio + : style.GetPropertyValue("aspect-ratio")); AddIfPresent(map, "position", style.GetPropertyValue("position")); AddLengthProperty(map, style, "left"); AddLengthProperty(map, style, "top"); @@ -4936,6 +5332,7 @@ private static Dictionary CreateStyleMap(ICssStyleDeclaration st AddIfPresent(map, "grid-template-rows", string.IsNullOrWhiteSpace(explicitGridTemplateRows) ? ParseStyleAttributeValue(inlineStyle, "grid-template-rows") : explicitGridTemplateRows); var explicitGridTemplateAreas = explicitDeclarations is not null ? ReadExplicitOrComputed(explicitDeclarations, style, "grid-template-areas") : ResolveExplicitPropertyValue(element, style, "grid-template-areas"); AddIfPresent(map, "grid-template-areas", string.IsNullOrWhiteSpace(explicitGridTemplateAreas) ? ParseStyleAttributeValue(inlineStyle, "grid-template-areas") : explicitGridTemplateAreas); + AddIfPresent(map, "align-content", explicitDeclarations is not null ? ReadExplicitOrComputed(explicitDeclarations, style, "align-content") : ResolveExplicitPropertyValue(element, style, "align-content")); var explicitGridAutoRows = explicitDeclarations is not null ? ReadExplicitOrComputed(explicitDeclarations, style, "grid-auto-rows") : ResolveExplicitPropertyValue(element, style, "grid-auto-rows"); AddIfPresent(map, "grid-auto-rows", string.IsNullOrWhiteSpace(explicitGridAutoRows) ? ParseStyleAttributeValue(inlineStyle, "grid-auto-rows") : explicitGridAutoRows); var explicitGridAutoFlow = explicitDeclarations is not null ? ReadExplicitOrComputed(explicitDeclarations, style, "grid-auto-flow") : ResolveExplicitPropertyValue(element, style, "grid-auto-flow"); @@ -5037,14 +5434,21 @@ private static Dictionary CreateStyleMap(ICssStyleDeclaration st AddIfPresent(map, "column-gap", style.GetPropertyValue("column-gap")); } } - // Never read from AngleSharp.Css's computed `transform`. Inline declarations are extracted - // to `data-render-transform` before style computation; stylesheet declarations are read - // from the cascaded-but-uncomputed declaration so rotate/scale/translate all retain their - // authored function list without entering CssTranslateValue.Compute(). + // Only read AngleSharp.Css's computed `transform` when the authored one uses custom + // properties. Inline declarations are extracted to `data-render-transform` before style + // computation; stylesheet declarations are read from the cascaded-but-uncomputed + // declaration so rotate/scale/translate all retain their authored function list without + // entering CssTranslateValue.Compute(). var rawTransformValue = element?.GetAttribute("data-render-transform"); if (string.IsNullOrWhiteSpace(rawTransformValue) && explicitDeclarations is not null) { rawTransformValue = explicitDeclarations.GetPropertyValue("transform"); + + // e.g. transform: translateX(var(--offset)), which only the computed value has substituted + if (!UsesExplicitValue(rawTransformValue) && !string.IsNullOrWhiteSpace(rawTransformValue)) + { + rawTransformValue = style.GetPropertyValue("transform"); + } } AddIfPresent(map, "transform", rawTransformValue); @@ -5052,9 +5456,9 @@ private static Dictionary CreateStyleMap(ICssStyleDeclaration st AddIfPresent(map, "rotate", style.GetPropertyValue("rotate")); AddIfPresent(map, "scale", style.GetPropertyValue("scale")); var explicitTransformOrigin = explicitDeclarations?.GetPropertyValue("transform-origin"); - AddIfPresent(map, "transform-origin", string.IsNullOrWhiteSpace(explicitTransformOrigin) - ? (string.IsNullOrWhiteSpace(style.GetPropertyValue("transform-origin")) ? ParseStyleAttributeValue(inlineStyle, "transform-origin") : style.GetPropertyValue("transform-origin")) - : explicitTransformOrigin); + AddIfPresent(map, "transform-origin", UsesExplicitValue(explicitTransformOrigin) + ? explicitTransformOrigin + : (string.IsNullOrWhiteSpace(style.GetPropertyValue("transform-origin")) ? ParseStyleAttributeValue(inlineStyle, "transform-origin") : style.GetPropertyValue("transform-origin"))); // AngleSharp.Css now parses `filter` into a structured CssFilterValue and preserves it // through computed style (fixed upstream - it used to always report an empty string here), // so this can now read the ordinary computed-style value directly, the same as any other @@ -5104,11 +5508,65 @@ private static Dictionary CreateStyleMap(ICssStyleDeclaration st ? ReadExplicitOrComputed(explicitDeclarations, style, "word-wrap") : style.GetPropertyValue("word-wrap")); + ResolveCurrentColor(map); ApplyActiveTransitionAndAnimationOverrides(map, style, element); return map; } + /// + /// Replaces currentcolor, which AngleSharp.Css keeps as a keyword in computed values + /// (e.g. MDN's icons: background-color: currentcolor with a mask), by the element's color. + /// + private static void ResolveCurrentColor(Dictionary map) + { + if (!map.TryGetValue("color", out var color) || string.IsNullOrWhiteSpace(color) || IndexOfCurrentColor(color, 0) >= 0) + { + return; + } + + foreach (var key in map.Keys.ToList()) + { + var value = map[key]; + var index = IndexOfCurrentColor(value, 0); + + if (index < 0 || string.Equals(key, "color", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var resolved = new StringBuilder(); + var start = 0; + + while (index >= 0) + { + resolved.Append(value, start, index - start).Append(color); + start = index + CurrentColorKeyword.Length; + index = IndexOfCurrentColor(value, start); + } + + map[key] = resolved.Append(value, start, value.Length - start).ToString(); + } + } + + private const string CurrentColorKeyword = "currentcolor"; + + private static int IndexOfCurrentColor(string value, int start) + { + var index = value.IndexOf(CurrentColorKeyword, start, StringComparison.OrdinalIgnoreCase); + + // only the keyword itself, not a part of another identifier + while (index >= 0 && (IsIdentifierChar(value, index - 1) || IsIdentifierChar(value, index + CurrentColorKeyword.Length))) + { + index = value.IndexOf(CurrentColorKeyword, index + 1, StringComparison.OrdinalIgnoreCase); + } + + return index; + + static bool IsIdentifierChar(string text, int position) => + position >= 0 && position < text.Length && (char.IsLetterOrDigit(text[position]) || text[position] is '-' or '_'); + } + // Overrides every style-map entry that is currently mid-`transition` or mid-`animation` with // its interpolated value, so the rest of this renderer's normal per-property parsing // (ParseLength, ParseColor, ...) never has to know either is even happening - it just sees @@ -5205,7 +5663,46 @@ private static void AddIfPresent(Dictionary map, string property } string? matchedValue = null; - foreach (var styleElement in element.Owner.QuerySelectorAll("style")) + + foreach (var rule in GetAuthoredRules(element.Owner, propertyName)) + { + foreach (var selector in rule.Selectors) + { + try + { + if (element.Matches(selector)) + { + matchedValue = rule.Value; + break; + } + } + catch + { + // Ignore selectors unsupported by AngleSharp's matcher. + } + } + } + + return matchedValue; + } + + /// + /// The rules of the document's `style` elements declaring the property, in source order. + /// Scanning every style sheet's text for every element made pages with large inline style + /// sheets take minutes to lay out, so the rules are collected once per layout pass. + /// + private static List GetAuthoredRules(IDocument document, string propertyName) + { + var pass = s_layoutPass.Value; + + if (pass is not null && pass.AuthoredRules.TryGetValue((document, propertyName), out var cached)) + { + return cached; + } + + var rules = new List(); + + foreach (var styleElement in document.QuerySelectorAll("style")) { var source = styleElement.TextContent ?? string.Empty; var cursor = 0; @@ -5230,28 +5727,15 @@ private static void AddIfPresent(Dictionary map, string property if (!selectorText.StartsWith('@') && !string.IsNullOrWhiteSpace(candidateValue)) { - foreach (var selector in selectorText.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - { - try - { - if (element.Matches(selector)) - { - matchedValue = candidateValue; - break; - } - } - catch - { - // Ignore selectors unsupported by AngleSharp's matcher. - } - } + rules.Add(new AuthoredRule(selectorText.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), candidateValue)); } cursor = blockEnd + 1; } } - return matchedValue; + pass?.AuthoredRules.Add((document, propertyName), rules); + return rules; } private static bool TryGetFirstCollapsibleChildTopMargin(ElementRenderNode node, float containingWidth, LayoutContext context, out float marginTop) @@ -5370,6 +5854,74 @@ private static IEnumerable OrderChildrenForPainting(IEnumerable + /// The children in the order they are laid out: the ones painted below the in-flow content + /// (negative z-index) first, all others in document order. Unlike with + /// , positioned children keep their place, which their + /// static position (and the position of a z-indexed in-flow box) depends on; their commands + /// are moved above the in-flow ones by once laid out. + /// + private static IEnumerable OrderChildrenForLayout(IEnumerable children, LayoutContext context) + { + var list = children as IReadOnlyCollection ?? children.ToList(); + var negatives = list.Where(child => IsNegativeZIndexChild(child, context)).ToList(); + + foreach (var child in OrderChildrenForPainting(negatives, context)) + { + yield return child; + } + + foreach (var child in list) + { + if (!IsNegativeZIndexChild(child, context)) + { + yield return child; + } + } + } + + /// + /// Moves the commands of the children painted above the in-flow content (positioned or + /// z-indexed, with a z-index of at least 0) to the end, ordered by z-index and then by + /// document order (CSS 2.2 Appendix E), once all siblings are laid out. + /// + private sealed class PaintOrder + { + private readonly List<(int Z, int Order, RenderCommand First, int Count)> _raised = []; + + public void Track(IRenderNode child, DisplayList displayList, int start, LayoutContext context) + { + var count = displayList.Commands.Count - start; + + if (count > 0 && child is ElementRenderNode element && !IsStickyElement(child, context)) + { + var styleMap = GetOrCreateStyleMap(context, element.Ref, element.ComputedStyle); + + if ((IsOutOfFlowPositioned(styleMap) || HasExplicitZIndex(styleMap)) && ParseZIndex(styleMap) >= 0) + { + _raised.Add((ParseZIndex(styleMap), _raised.Count, displayList.Commands[start], count)); + } + } + } + + public void Apply(DisplayList displayList) + { + foreach (var (_, _, first, count) in _raised.OrderBy(m => m.Z).ThenBy(m => m.Order)) + { + var commands = displayList.Commands; + + for (var index = commands.Count - 1; index >= 0; index--) + { + if (ReferenceEquals(commands[index], first)) + { + displayList.MoveRangeToEnd(index, Math.Min(count, commands.Count - index)); + break; + } + } + } + } + } + private static bool IsOutOfFlowPositioned(Dictionary styleMap) { var position = GetPosition(styleMap); @@ -6054,8 +6606,20 @@ private static (float PositionXFraction, float PositionXOffset, float PositionYF private static bool TryGetOrLoadInlineSvgResource(IElement element, out CachedImageResource? imageResource) { - var pageStyle = ResolveSvgPageStyle(element); - var fingerprint = BuildInlineSvgFingerprint(element, pageStyle); + var pass = s_layoutPass.Value; + SvgPageStyle? pageStyle; + string fingerprint; + + if (pass is not null && pass.Styles.TryGetValue(element, out var resolved)) + { + (pageStyle, fingerprint) = resolved; + } + else + { + pageStyle = ResolveSvgPageStyle(element); + fingerprint = BuildInlineSvgFingerprint(element, pageStyle); + pass?.Styles.Add(element, (pageStyle, fingerprint)); + } if (s_inlineSvgCacheByElement.TryGetValue(element, out var cached) && cached.Fingerprint == fingerprint) { @@ -6091,9 +6655,9 @@ private static bool TryGetOrLoadInlineSvgResource(IElement element, out CachedIm return null; } - var device = window.Document.Context.GetService() ?? new DefaultRenderDevice(); - var styleCollection = window.GetStyleCollection(device); - var rootStyle = svgRoot.ComputeCurrentStyle(); + var styleCollection = GetSvgStyleCollection(window); + // the same as ComputeCurrentStyle, which would gather (and index) all style rules once more + var rootStyle = ComputeStyle(styleCollection, svgRoot); var inherited = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var property in SvgPaintState.PresentationProperties) @@ -6119,6 +6683,12 @@ private static bool TryGetOrLoadInlineSvgResource(IElement element, out CachedIm { var value = cascaded.GetPropertyValue(property); + // custom properties are only substituted in the computed value, e.g. fill: var(--text) + if (!UsesExplicitValue(value) && !string.IsNullOrWhiteSpace(value)) + { + value = ComputeStyle(styleCollection, element).GetPropertyValue(property); + } + if (!string.IsNullOrWhiteSpace(value)) { (values ??= new Dictionary(StringComparer.OrdinalIgnoreCase))[property] = value; @@ -6134,6 +6704,20 @@ private static bool TryGetOrLoadInlineSvgResource(IElement element, out CachedIm return new SvgPageStyle(inherited, declarations); } + private static IStyleCollection GetSvgStyleCollection(IWindow window) + { + var pass = s_layoutPass.Value; + + if (pass is null || !pass.StyleCollections.TryGetValue(window, out var styleCollection)) + { + var device = window.Document.Context.GetService() ?? new DefaultRenderDevice(); + styleCollection = window.GetStyleCollection(device); + pass?.StyleCollections.Add(window, styleCollection); + } + + return styleCollection; + } + private static string BuildInlineSvgFingerprint(IElement svgRoot, SvgPageStyle? pageStyle) { var builder = new StringBuilder(svgRoot.OuterHtml); @@ -6557,8 +7141,10 @@ private static float ResolveTextBaselineOffset(float lineHeight, float fontSize) /// private const double FormControlCaretBlinkPeriodMs = 1000d; - private static FormControlKind ResolveFormControlKind(IElement element) + private static FormControlKind ResolveFormControlKind(ElementRenderNode node) { + var element = node.Ref; + // A ::before/::after pseudo-element's LocalName/GetAttribute proxy straight through to its // host (PseudoElement.cs, in AngleSharp.Css) - an /