From 4cfddaf0d53f869a61cfc6256c2acf1c2b1fd8d8 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Mon, 7 Sep 2026 13:46:00 +0200 Subject: [PATCH 01/22] Changed version --- CHANGELOG.md | 6 ++++++ src/AngleSharp.Renderer.Docs/package.json | 2 +- src/Directory.Build.props | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 576734d..eccdaa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# 0.4.0 + +Released on Wednesday, September 9 2026. + +- ... + # 0.3.0 Released on Monday, September 7 2026. diff --git a/src/AngleSharp.Renderer.Docs/package.json b/src/AngleSharp.Renderer.Docs/package.json index 8f45842..fd7ef9b 100644 --- a/src/AngleSharp.Renderer.Docs/package.json +++ b/src/AngleSharp.Renderer.Docs/package.json @@ -1,6 +1,6 @@ { "name": "@anglesharp/renderer", - "version": "0.3.0", + "version": "0.4.0", "preview": true, "description": "The doclet for the AngleSharp.Renderer documentation.", "keywords": [ diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 6d96d9b..ce2f514 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ Adds rendering functionality to the core AngleSharp library. AngleSharp.Renderer - 0.3.0 + 0.4.0 enable latest true From 6eb4aa85ad7feb559c89f3e5aba924ecccf69672 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Mon, 7 Sep 2026 16:00:58 +0200 Subject: [PATCH 02/22] Added rounded corners --- AGENTS.md | 4 +- CHANGELOG.md | 2 +- .../HtmlRendererTests.cs | 248 ++++++++++++++++++ .../VisualConformanceTests.cs | 140 ++++++++++ ...s-for-mixed-width-rounded-border.macos.png | Bin 0 -> 459 bytes ...elliptical-border-radius-corners.macos.png | Bin 0 -> 1452 bytes ...er-corner-different-border-radii.macos.png | Bin 0 -> 837 bytes ...shape-from-clamped-border-radius.macos.png | Bin 0 -> 776 bytes ...ounded-box-background-and-border.macos.png | Bin 0 -> 994 bytes src/AngleSharp.Renderer/HtmlRenderer.cs | 88 ++++++- .../Rendering/DisplayList.cs | 33 ++- .../Rendering/RenderCornerRadii.cs | 67 +++++ .../Skia/SkiaRenderBackend.cs | 49 +++- 13 files changed, 614 insertions(+), 17 deletions(-) create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/falls-back-to-straight-edges-for-mixed-width-rounded-border.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/paints-elliptical-border-radius-corners.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/paints-per-corner-different-border-radii.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/paints-pill-shape-from-clamped-border-radius.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/paints-uniform-rounded-box-background-and-border.macos.png create mode 100644 src/AngleSharp.Renderer/Rendering/RenderCornerRadii.cs diff --git a/AGENTS.md b/AGENTS.md index 94cae8d..296cfe1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,9 @@ Font handling resolves each entry of a `font-family` list in order, and the firs Table spans: a cell covers the columns and rows it spans, and a spanning cell's height is shared across the rows it covers rather than imposed on each of them. With `border-collapse: collapse` each cell paints only its top and left edge and the table adds the frame, so shared edges are drawn once and no rule is painted across a spanning cell. Cell content honours `vertical-align` (`top`, `middle`, `bottom`), defaulting to the `middle` that AngleSharp.Css resolves for cells; `baseline` is treated as `top`, since baselines are not aligned across a row. Note that on a cell `vertical-align` positions the content box, which is a different meaning from the inline shift `ParseVerticalAlign` applies to `super`, `sub` and friends. -Current behavior includes block layout, margins, padding, borders, floats, inline-block, relative/fixed/absolute positioning, z-index ordering, outlines, text styling, text alignment, line-height, letter-spacing, text-indent, vertical-align, and generic font-family handling. +`border-radius` (rounded corners): the shorthand and all four longhands (`border-top-left-radius` etc.) are supported, each independently elliptical (`border-top-left-radius: 20px 10px`, or the whole-box `border-radius: 40px / 20px` slash shorthand) and represented backend-agnostically as `RenderCornerRadii` (`Rendering/RenderCornerRadii.cs`) - eight floats, X/Y per corner. `RenderCornerRadii.ClampToBox` implements the CSS corner-overlap-prevention algorithm (https://www.w3.org/TR/css-backgrounds-3/#corner-overlap): a single global scale factor, not a per-axis one, is applied to every radius so adjacent corners along the same edge never overlap - this is what turns a `border-radius` larger than half a box's height into a pill/stadium shape rather than a self-intersecting curve. A background fills with `SKRoundRect`/`DrawRoundRect` (`SkiaRenderBackend.DrawFillRect`) instead of `DrawRect` whenever `Radii` is non-zero. A border with rounded corners is painted differently depending on whether every edge shares the same width: a uniform-width border becomes a single `StrokeRoundedRectCommand` (one stroked ring, inset by half the border width, with the radius reduced by that same half-width since the stroke sits on the border's centerline); a mixed-width border (e.g. `border-right-width` wider than the others) has no single stroke width to give that ring, so it falls back to the existing four-straight-rectangle border path and the corners render square - a deliberate, documented limitation rather than an attempt at four independently-rounded quads. `outline` never rounds regardless of `border-radius`, matching the CSS spec, because `PaintOutline`'s internal `PaintBorder` call never passes the element's radii. Percentage radii resolve to pixels through AngleSharp.Css's computed-style engine before this renderer ever sees them, but that resolution has a verified quirk in the AngleSharp.Css version this repo builds against (1.1.0): both the horizontal and vertical component of a percentage radius resolve against the *containing block's width*, not per-axis against the element's own width and height as the CSS spec technically prescribes - confirmed by rendering the same `%` radius against different viewport widths and observing the resulting pixel value track the viewport, not the element's own box. This is a property of the upstream computed-style engine, not of this renderer's own parsing. + +Current behavior includes block layout, margins, padding, borders, floats, inline-block, relative/fixed/absolute positioning, z-index ordering, outlines, text styling, text alignment, line-height, letter-spacing, text-indent, vertical-align, border-radius, and generic font-family handling. SVG support: `` (including `data:` URIs) and inline `` markup both render, through two loading paths that converge on the same DOM-walking rasterizer (`Skia/Svg/`) - no third-party SVG parser is involved anywhere. An `` SVG source is sniffed and rasterized inside `TryLoadImageResource`, exactly where a PNG/JPEG source is decoded: `SvgRasterizer.TryRasterizeMarkup` parses the bytes with AngleSharp's own HTML/foreign-content parser (wrapped in a throwaway `` shell) and is cached per-document by URL like any other image. Inline `` has no URL and, more importantly, is already sitting in the host document's DOM - `SvgRasterizer.TryRasterizeElement` walks that element directly and is cached per-element in `s_inlineSvgCacheByElement`; it is never serialized back to text and re-parsed. `LayoutElement` empties `orderedChildren` for an `` root so its foreign-namespaced children are never walked as HTML flow content; both `` and inline `` are treated as a single replaced element. Rasterization always happens at the SVG's own natural (`viewBox`/`width`/`height`) size oversampled by a fixed factor (`SvgRasterizer.OversampleFactor`), not at the resolved CSS box size - the loader runs before CSS sizing is known, so this is a deliberate blur-vs-memory tradeoff rather than a per-render-size cache. diff --git a/CHANGELOG.md b/CHANGELOG.md index eccdaa5..14816e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ Released on Wednesday, September 9 2026. -- ... +- Added support for rounded corners # 0.3.0 diff --git a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs index 7773406..7e7c5de 100644 --- a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs +++ b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs @@ -1695,6 +1695,254 @@ public async Task BuildDisplayList_UsesZIndexOverSourceOrderForPositionedSibling Assert.True(blueIndex > redIndex); } + [Fact] + public async Task BuildDisplayList_AppliesUniformBorderRadiusToBackgroundFill() + { + var document = await ParseAsync(""" + +
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + }); + + var fills = displayList.Commands.OfType().ToArray(); + Assert.True(fills.Length >= 2); + + var boxBackground = fills[1]; + Assert.Equal(8f, boxBackground.Radii.TopLeftX); + Assert.Equal(8f, boxBackground.Radii.TopLeftY); + Assert.Equal(8f, boxBackground.Radii.TopRightX); + Assert.Equal(8f, boxBackground.Radii.TopRightY); + Assert.Equal(8f, boxBackground.Radii.BottomRightX); + Assert.Equal(8f, boxBackground.Radii.BottomRightY); + Assert.Equal(8f, boxBackground.Radii.BottomLeftX); + Assert.Equal(8f, boxBackground.Radii.BottomLeftY); + } + + [Fact] + public async Task BuildDisplayList_ParsesTwoValueBorderRadiusShorthand() + { + var document = await ParseAsync(""" + +
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + }); + + var fills = displayList.Commands.OfType().ToArray(); + var boxBackground = fills[1]; + + // border-radius: 20px 5px -> top-left/bottom-right get 20px, top-right/bottom-left get 5px. + Assert.Equal(20f, boxBackground.Radii.TopLeftX); + Assert.Equal(5f, boxBackground.Radii.TopRightX); + Assert.Equal(20f, boxBackground.Radii.BottomRightX); + Assert.Equal(5f, boxBackground.Radii.BottomLeftX); + } + + [Fact] + public async Task BuildDisplayList_ParsesEllipticalBorderRadiusPerCornerLonghand() + { + var document = await ParseAsync(""" + +
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + }); + + var fills = displayList.Commands.OfType().ToArray(); + var boxBackground = fills[1]; + + Assert.Equal(20f, boxBackground.Radii.TopLeftX); + Assert.Equal(10f, boxBackground.Radii.TopLeftY); + Assert.Equal(0f, boxBackground.Radii.TopRightX); + Assert.Equal(0f, boxBackground.Radii.BottomRightX); + Assert.Equal(0f, boxBackground.Radii.BottomLeftX); + } + + [Fact] + public async Task BuildDisplayList_ParsesSlashSeparatedBorderRadiusShorthand() + { + var document = await ParseAsync(""" + +
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + }); + + var fills = displayList.Commands.OfType().ToArray(); + var boxBackground = fills[1]; + + // border-radius: 40px / 20px -> every corner gets a 40px horizontal / 20px vertical + // elliptical radius (all four corners share the same X and share the same Y here, since + // no per-corner variation was given on either side of the slash). + Assert.Equal(40f, boxBackground.Radii.TopLeftX); + Assert.Equal(20f, boxBackground.Radii.TopLeftY); + Assert.Equal(40f, boxBackground.Radii.BottomRightX); + Assert.Equal(20f, boxBackground.Radii.BottomRightY); + } + + [Fact] + public async Task BuildDisplayList_ResolvesPercentageBorderRadiusAgainstBox() + { + var document = await ParseAsync(""" + +
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + }); + + var fills = displayList.Commands.OfType().ToArray(); + var boxBackground = fills[1]; + + // Verified against AngleSharp.Css 1.1.0: a border-radius percentage resolves against the + // containing block's width for both the horizontal and vertical component (here, the + // 300px viewport width -> 10% = 30px), not per-axis against the element's own width and + // height as the CSS spec technically prescribes - a quirk of the computed-style engine + // this renderer sits on top of, not something this renderer's own parsing controls. 30px + // on each side of a 200x80 box does not exceed either edge, so no overlap-clamping kicks + // in here (that path is covered separately by the pixel-radius clamp test below). + Assert.Equal(30f, boxBackground.Radii.TopLeftX); + Assert.Equal(30f, boxBackground.Radii.TopLeftY); + } + + [Fact] + public async Task BuildDisplayList_ClampsOverlappingBorderRadiiToBoxSize() + { + var document = await ParseAsync(""" + +
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + }); + + var fills = displayList.Commands.OfType().ToArray(); + var boxBackground = fills[1]; + + // A 100px radius on every corner of a 40x20 box would overlap; the CSS corner-overlap + // algorithm scales all radii down uniformly until the tightest edge (height=20, two + // 100px radii sharing it) just fits: scale = 20/200 = 0.1 -> 10px. + Assert.Equal(10f, boxBackground.Radii.TopLeftX, 0.001f); + Assert.Equal(10f, boxBackground.Radii.TopLeftY, 0.001f); + Assert.Equal(10f, boxBackground.Radii.BottomRightX, 0.001f); + Assert.Equal(10f, boxBackground.Radii.BottomRightY, 0.001f); + } + + [Fact] + public async Task BuildDisplayList_RendersUniformBorderRadiusBorderAsStrokedRoundedRect() + { + var document = await ParseAsync(""" + +
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + }); + + var strokedBorder = Assert.Single(displayList.Commands.OfType()); + Assert.Equal(new RenderColor(0, 0, 255), strokedBorder.Color); + Assert.Equal(4f, strokedBorder.StrokeWidth); + + // The stroke is drawn along the border's centerline, half the border width inside the + // outer edge, so its radius is the outer 12px radius minus half the 4px border width. + Assert.Equal(10f, strokedBorder.Radii.TopLeftX, 0.001f); + + // A rounded border is painted as a single ring, never as the four separate edge rectangles. + Assert.Empty(displayList.Commands.OfType().Where(f => f.Color.Equals(new RenderColor(0, 0, 255)))); + } + + [Fact] + public async Task BuildDisplayList_FallsBackToStraightEdgesForMixedWidthRoundedBorder() + { + var document = await ParseAsync(""" + +
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + }); + + // Mixed edge widths have no single stroke width for a rounded ring, so the renderer falls + // back to the un-rounded four-rectangle border path. + Assert.Empty(displayList.Commands.OfType()); + + var borderFills = displayList.Commands.OfType() + .Where(f => f.Color.Equals(new RenderColor(0, 0, 255))) + .ToArray(); + Assert.Equal(4, borderFills.Length); + } + + [Fact] + public async Task BuildDisplayList_DoesNotRoundOutlineWithBorderRadius() + { + var document = await ParseAsync(""" + +
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + }); + + // The outline is unaffected by border-radius per spec, so it stays a plain four-rectangle + // border rather than a StrokeRoundedRectCommand. + Assert.Empty(displayList.Commands.OfType()); + + var outlineFills = displayList.Commands.OfType() + .Where(f => f.Color.Equals(new RenderColor(255, 0, 255))) + .ToArray(); + Assert.Equal(4, outlineFills.Length); + } + private static async Task ParseAsync(string html, IConfiguration? configuration = null, string? address = null) { var context = BrowsingContext.New(configuration ?? Configuration.Default.WithCss()); diff --git a/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs b/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs index 4d13e25..ed61bc1 100644 --- a/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs +++ b/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs @@ -1391,6 +1391,146 @@ public async Task RenderToPng_RendersWebSafeFontFamiliesDifferently() maxDifferentPixels: TextRenderingToleranceMaxPixels); } + [Fact] + public async Task RenderToPng_PaintsUniformRoundedBoxBackgroundAndBorder() + { + var document = await ParseAsync(""" + + + + + +
+ + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 120, + ViewPortHeight = 90, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "paints-uniform-rounded-box-background-and-border.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_PaintsPillShapeFromClampedBorderRadius() + { + var document = await ParseAsync(""" + + + + + +
+ + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 120, + ViewPortHeight = 50, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "paints-pill-shape-from-clamped-border-radius.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_PaintsEllipticalBorderRadiusCorners() + { + var document = await ParseAsync(""" + + + + + +
+ + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 120, + ViewPortHeight = 80, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "paints-elliptical-border-radius-corners.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_PaintsPerCornerDifferentBorderRadii() + { + var document = await ParseAsync(""" + + + + + +
+ + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 120, + ViewPortHeight = 80, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "paints-per-corner-different-border-radii.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_FallsBackToStraightEdgesForMixedWidthRoundedBorder() + { + var document = await ParseAsync(""" + + + + + +
+ + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 120, + ViewPortHeight = 90, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "falls-back-to-straight-edges-for-mixed-width-rounded-border.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + private static async Task RenderCanvasSnapshotAsync(string html, Action draw) { var context = BrowsingContext.New(Configuration.Default.WithCss().WithRendering()); diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/falls-back-to-straight-edges-for-mixed-width-rounded-border.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/falls-back-to-straight-edges-for-mixed-width-rounded-border.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..740e5df78aa9ea3cd516899e1d6c9811a423677f GIT binary patch literal 459 zcmeAS@N?(olHy`uVBq!ia0vp^6+j%t!3HFY87934QY^(zo*^7SP{WbZ!N9=SG(ZJXBTrg<;m|0|jht#s+g5AM)^|Cs;5amXyf5Tb-P3eEdTG;=1?}*27bo)BnZH zpMK){_7fR#t=XAhMdwHs+PLLx7rg%ZN2f{1>%ZnN|M8vs-rsRI@0m}A?ytCYi|-x$ zdSG?wk+){gF8^P+l=-^+#bdQvb=_zdox`-7GNIM%_j1&F7Dm2m^k2@IoH4EH!C$*a z1v3n~(pJ1TtKR>+=HI=r8@7wR|NSXD6xEh)w!-`SO6_?@f9Av-#0UkVT@vri)jL6| Tc+D(e>@aw``njxgN@xNAAThw+ literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/paints-elliptical-border-radius-corners.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/paints-elliptical-border-radius-corners.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..dca1654a0036a51808b86999aa2d144f81eb88da GIT binary patch literal 1452 zcmah}XE+-O7>*Tb6g3*7MCstDb)2XRz1UQ9idK!Nk)RYIqB(n3iB+XZP<&MeV&^?th=V?|HxX{hsgr`+i@7CDMc+EC~hx0Q_dAhE{APu-g&@Wb;rq zHGwVMIDIo45W7M_=tlqmkDHkx!iJEuG=VZ54v}K4_0I)nk9{c(Ov1T$m7>R}v9rba zPGc7_;9n9bZrwjHVkn>!5CK9xveZX`@vJz+WfvXE;I!2{0&apXH_EGY8%+B8a6$|s z?&5LlN%+ati153x4cAs)!iLBGgM%A1I-M>ccV+9gU!($&lP~3GK-Paaw<{6RlWM6G z%LY~}X?{n+mFad8a@eqM_IS%%(aTx6@wJt2RLOYVT0LzXoc$gcQX;)={=4L9{=};O zm`YLKTkktP)b{Yg^kTvGz%vClpq2WS#$X4F?S<{vF4#7wd-22bh9-Dkfows)trv+h z+Np5&nR>$77Ef1eN{7hWc{)UO4w6_}*#egSAdR(sIsH7S<6eM1YSz~14Ofkubd5+& z1ozReDQ~+k$}umPtJu{YpBu*nAb2thy%i<3sZZMmhRy)Rc-2W{YcGlqE1z{fczbKA zWeL9N^~&{!g7B`-g7Q9d%S{0uYpBv55?pXc6XIC@DpCn=Tg$A?J2-UYD#P6yKQ)QC zTPvuvch+fm8Dvj`z@%Y=4otpk`(7U>-zXEkwJ)K_X%yGp9DS0U-BW^AwJ6rekXT*p){vq>G0@&n zo>b-3o8xlVzgf-aEN9>^Y4FkymQ~(Na8@{i8xI@F40Rspv3Annx0`cDAeo}xBJpGG zcbdlQ-q--;gsueRD;N^mCuP-Tcy#K=YJ0J|SulN8i7>kXdZWxSeV@$AjhSt$XS!p2 z9<4HlqB;Exdbm@pr>xqC^aGqrCU*=!QycOh+yehJO#iz?225ooN?zJQh}SlxNj0VQ zmkZPH*qaKr5IITUrw~!XIWJm+dHC~L!MsZ!g|SF&R$8XA`|#))ZD`~Ri!WyI^cv?U zU9lXWA{Uc|^ni|B=`);35ts2RtgecQGBV6)SKAr~oCs;AbgAVHQqvsUWcy{S=4>et zxwr~T+w)j>TWFQ=MskX=+l9;a^3EU0cJacLcaj=#AE=#&`&&JE@8vNmBr>maeW5OA z+OTlq?8~*oZk*`S=~%6D~FBr<`(6L4Dvq& zws=Lv_0mXZuk8@zZzAHZ(@1JZ-vbf;94YaSgXN%(hX-<;EI=wNsEVtLi$GW{s50o+ z>e$kr&O8L~M~5{@H{D>I(ip0w%~!7&`Gu)!Duy#N3%jGHjx%IFF&!D7N$Qiy>F_2P|z_@%>Sd?}0R{l|tD)%|4Gc zPnOWoUZDEZD_4VQP>%>*Vs4nOr+T9(rmrxf8mU8i2wRAg{~9I#wcREVObIO|J19sR zP&j-(YBku>ie6Ppv#0ry=ECKIi*#{sMXfl}V-opDi@4Vbhotk3sWdxPn-o!nRG#ql zz0C5jS`Z&@-O8{Mj)IAp$E)R$<5gK5p{`IO#RwfBf!-KRX53dH-q$0dRVuonNf_h# z5uN#-z<~<<0Eq>I{@BE0vvRN!sDIgJ{e&kWKAqoU_rKD&EMq5sTIK|%z<##?fSD1} J@VUNo^gkU|qo@D? literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/paints-per-corner-different-border-radii.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/paints-per-corner-different-border-radii.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..dbb799edcddd53a6b9dce269fb4eab71547a18b7 GIT binary patch literal 837 zcmeAS@N?(olHy`uVBq!ia0vp^6+j%o!3HER&ED7vq*#ibJVQ8upoSx*gMoqRkEe@c zNX4ADcfCDA0%eYWT;IQJp2PYxXr|LGHbX4%u7#?O1c|NB{c?fIOahsy!@J|9-IFF5g|N;EvD#Hukk2{wb3Cx`^D?!+%QrZi`xQy4`d9 z-#cfURzc97eXPdG6Sr#?1fA(~zW?>aZ4nDkw!Fhn)_X()Sz>7qK2HX*GVQ*)MXfvF z`S~sDpSi_xK-Ol9`MqyE8YgGG*!nq$)o;{u!Id@0u)7p|N;Z*~Bs z%={XAgA*Q$+_1pHyu-Zy42z7jQz3uMKE($TLS3*heb;Di&I*>ebAj!sHdCLH;tqDd z^n~o}1A!_n8RAxDGW;fOEke2ZwPfxC$*|ab4g?#h&$R9JBbL2kR$HpC%?^F%g$`j-n5zcwm zbVIf2nuu5LqzuL5L!Z?h*m=AlnIkPNwr6X_R1wE$3 zPn&LtCi_0~B%{8LCO__{2bkacx(Nne$XqC6VVr5y1rp@k!{91ZQFSauBrxMtWAI6h z;{sMCQ63LiH6|U94pv*-p>ZX2h5Txniuk!FHI%rlbf+-CUs1NX)#Zk!cI({LSNg;G zWb_-8dDWCTr}yQzT}t};cxxU1zhAOhRu7BsrI;kHR$@%f4OsTkZ`If0#7dc6(QnKe zIgJe;ysO*0BVj|TdMvki*~@i8+tw{vxYDhD?!|d)zU3AyxtFrkps-}}&vRa@b2rPL zf8aAe=V52ibvcWTM{D+eoy=>iT=0pp=XuUKrGKG&ZEB+PHvai{@Z@4`hV5+&iv{A+ zV&>$2T;<=o{>I_Hf2>yAf85RZzu!Ojng2r-)4i0Z$yfh}s3hd7u36vIHg!WeZ;0BR pxWknRhWk%^J~(gk0qp2EFWalVEagVRiom4J;OXk;vd$@?2>_P5X(|8! literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/paints-uniform-rounded-box-background-and-border.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/paints-uniform-rounded-box-background-and-border.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..5a5f70a19a8f5560e7b2b6c5ff6ae8fa87d616a2 GIT binary patch literal 994 zcmeAS@N?(olHy`uVBq!ia0vp^6+j%t!3HFY87934QY^(zo*^7SP{WbZ!N9;g)6>N< zq+-t7I~)Cl6J?HlT;3JPyiC83MQL_Y(y2wW7Zw&}CpT#yIp~{cq{vfJ+Vp=xb7A4m zN6VTf2wb>yP)eL_@?i;swi(t6YC@d$>@j`WCt`O_U%cP${u9}Ex4y*P^XCtmwMSA= z&|vFa?`ozqc`RXajZ@tdo(e`(A~9t>_ni1OJ@$-Uyz|qusXul<54q{+|kCN^QZ<`k9os>91I{h#H|VC!OivoA)BS>NQM;h81-7Nl2J7#Ow9yx`0> zZ-@T0oSR)9J`X1D`}twFz|kw>8$#QflU{uBn16S-h0%uS+g!eV<#~TDzVItsZGV}4 zo?INCk5+rttM02^hnqS7`T_;EvB~_jkDOXQxAC!(R%g9heml>f3%5d_?OGG7Z}egN z+;;~G<+?r3FU_^uRd(B2GW+_ejt-ZqM-we8UbHplC;PqnoOpp(zd&!6)1O&vVmGR< zZ*`VrnSNKyV83q=wm?P-Z?c(jmk-$aO!}O$+OPPn5_A0P8yqs%4QKpFxwUk0_VnF5 z(%wc*Z2Z0Xps%yoME@$i+{+iY8pr;=woX)XN5%K>2Nhzh|7PVW$+KphHu1k~(hu4-_|4QT6e( zztiSlJa6CjL@5ok4IKsE|5@C2K2XWMxnWs@tIM3uRVl`|SL%nX?#)Syo^vv4Qrq!s zCL20Bc5l*D-dns(QV6_o|jwD*x20azFO#KlpsFSHj#1NZE0e6)D=i~5S^SDsJcFtFbJ@wN(DLMJ-l|1t;t-EI**=b|<+(=vFv L`njxgN@xNAyG+dS literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer/HtmlRenderer.cs b/src/AngleSharp.Renderer/HtmlRenderer.cs index 05268a4..f78e4eb 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.cs @@ -823,8 +823,8 @@ private static void LayoutElement( paddingTop, paddingBottom); - PaintBackground(displayList, box.BackgroundPaint, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight); - PaintBorder(displayList, box.BorderColor, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderWidth); + PaintBackground(displayList, box.BackgroundPaint, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderRadius); + PaintBorder(displayList, box.BorderColor, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderWidth, box.BorderRadius); PaintOutline(displayList, styleMap, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight); if (TryResolveReplacedElementImage(node, styleMap, flowContainingWidth, borderBoxX + borderLeft + paddingLeft, borderBoxY + borderTop + paddingTop, out var image, out var imageRect)) @@ -1168,7 +1168,7 @@ private static void LayoutFlexContainer( } else { - PaintBackground(displayList, box.BackgroundPaint, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight); + PaintBackground(displayList, box.BackgroundPaint, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderRadius); } RecordLayoutMetrics( @@ -1186,7 +1186,7 @@ private static void LayoutFlexContainer( paddingTop, paddingBottom); - PaintBorder(displayList, box.BorderColor, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderWidth); + PaintBorder(displayList, box.BorderColor, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderWidth, box.BorderRadius); PaintOutline(displayList, styleMap, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight); if (TryResolveReplacedElementImage(node, styleMap, containingWidth, borderBoxX + borderLeft + paddingLeft, borderBoxY + borderTop + paddingTop, out var image, out var imageRect)) @@ -1780,7 +1780,7 @@ private static void LayoutGridContainer( } else { - PaintBackground(displayList, box.BackgroundPaint, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight); + PaintBackground(displayList, box.BackgroundPaint, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderRadius); } RecordLayoutMetrics( @@ -1798,7 +1798,7 @@ private static void LayoutGridContainer( paddingTop, paddingBottom); - PaintBorder(displayList, box.BorderColor, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderWidth); + PaintBorder(displayList, box.BorderColor, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderWidth, box.BorderRadius); PaintOutline(displayList, styleMap, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight); cursorY = flowBorderBoxY + borderBoxHeight; @@ -2683,6 +2683,11 @@ private static Dictionary CreateStyleMap(ICssStyleDeclaration st AddIfPresent(map, "border-bottom-color", style.GetBorderBottomColor()); AddIfPresent(map, "border-left-color", style.GetBorderLeftColor()); + AddIfPresent(map, "border-top-left-radius", style.GetBorderTopLeftRadius()); + AddIfPresent(map, "border-top-right-radius", style.GetBorderTopRightRadius()); + AddIfPresent(map, "border-bottom-right-radius", style.GetBorderBottomRightRadius()); + AddIfPresent(map, "border-bottom-left-radius", style.GetBorderBottomLeftRadius()); + AddIfPresent(map, "outline-width", style.GetPropertyValue("outline-width")); AddIfPresent(map, "outline-style", style.GetPropertyValue("outline-style")); AddIfPresent(map, "outline-color", style.GetPropertyValue("outline-color")); @@ -2929,7 +2934,43 @@ private static BoxStyle ResolveBoxStyle(Dictionary styleMap) null, RenderColor.Black); - return new BoxStyle(margin, padding, borderWidth, backgroundPaint, borderColor); + var (topLeftX, topLeftY) = ParseCornerRadius(styleMap, "border-top-left-radius"); + var (topRightX, topRightY) = ParseCornerRadius(styleMap, "border-top-right-radius"); + var (bottomRightX, bottomRightY) = ParseCornerRadius(styleMap, "border-bottom-right-radius"); + var (bottomLeftX, bottomLeftY) = ParseCornerRadius(styleMap, "border-bottom-left-radius"); + var borderRadius = new RenderCornerRadii( + topLeftX, topLeftY, + topRightX, topRightY, + bottomRightX, bottomRightY, + bottomLeftX, bottomLeftY); + + return new BoxStyle(margin, padding, borderWidth, backgroundPaint, borderColor, borderRadius); + } + + /// + /// Parses a `border-*-radius` longhand value, which AngleSharp.Css reports as one length + /// ("8px", for a circular corner) or two space-separated lengths ("8px 4px", horizontal then + /// vertical, for an elliptical corner). Percentages arrive already resolved to pixels by + /// AngleSharp.Css's computed style engine. + /// + private static (float X, float Y) ParseCornerRadius(Dictionary styleMap, string propertyName) + { + if (!styleMap.TryGetValue(propertyName, out var value) || string.IsNullOrWhiteSpace(value)) + { + return (0f, 0f); + } + + var parts = value.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); + + if (parts.Length == 0) + { + return (0f, 0f); + } + + var x = ParseLengthValue(parts[0], 0f, allowAuto: false); + var y = parts.Length > 1 ? ParseLengthValue(parts[1], 0f, allowAuto: false) : x; + + return (x, y); } private static EdgeBorderStyle ResolveBorderStyles(Dictionary styleMap) @@ -3314,13 +3355,15 @@ private static bool TryParseDataUri(string? source, out byte[]? bytes, out strin return bytes.Length > 0; } - private static void PaintBackground(DisplayList displayList, RenderPaint paint, float x, float y, float width, float height) + private static void PaintBackground(DisplayList displayList, RenderPaint paint, float x, float y, float width, float height, RenderCornerRadii radii = default) { if (width <= 0f || height <= 0f) { return; } + var clampedRadii = radii.ClampToBox(width, height); + if (paint is RenderColorPaint colorPaint) { if (colorPaint.Color.A == 0) @@ -3328,23 +3371,43 @@ private static void PaintBackground(DisplayList displayList, RenderPaint paint, return; } - displayList.FillRect(new RenderRect(x, y, width, height), colorPaint.Color); + displayList.FillRect(new RenderRect(x, y, width, height), colorPaint.Color, clampedRadii); return; } if (paint is RenderGradientPaint) { - displayList.FillRect(new RenderRect(x, y, width, height), paint); + displayList.FillRect(new RenderRect(x, y, width, height), paint, clampedRadii); } } - private static void PaintBorder(DisplayList displayList, RenderColor color, float x, float y, float width, float height, EdgeSizes border) + private static void PaintBorder(DisplayList displayList, RenderColor color, float x, float y, float width, float height, EdgeSizes border, RenderCornerRadii radii = default) { if (color.A == 0 || width <= 0f || height <= 0f) { return; } + var clampedRadii = radii.ClampToBox(width, height); + + // A uniform-width border with rounded corners can be drawn as a single stroked ring; a + // border whose edges differ in width has no single stroke width to give the ring, so it + // falls back to the un-rounded four-rectangle path (a documented limitation - mixed-width + // rounded borders are rare enough not to warrant a per-edge rounded-quad implementation). + if (!clampedRadii.IsZero && border.Top > 0f && border.Top == border.Right && border.Top == border.Bottom && border.Top == border.Left) + { + var half = border.Top / 2f; + var strokeRect = new RenderRect(x + half, y + half, width - border.Top, height - border.Top); + var strokeRadii = new RenderCornerRadii( + Math.Max(0f, clampedRadii.TopLeftX - half), Math.Max(0f, clampedRadii.TopLeftY - half), + Math.Max(0f, clampedRadii.TopRightX - half), Math.Max(0f, clampedRadii.TopRightY - half), + Math.Max(0f, clampedRadii.BottomRightX - half), Math.Max(0f, clampedRadii.BottomRightY - half), + Math.Max(0f, clampedRadii.BottomLeftX - half), Math.Max(0f, clampedRadii.BottomLeftY - half)); + + displayList.StrokeRoundedRect(strokeRect, color, border.Top, strokeRadii); + return; + } + if (border.Top > 0f) { displayList.FillRect(new RenderRect(x, y, width, border.Top), color); @@ -4500,5 +4563,6 @@ private readonly record struct BoxStyle( EdgeSizes Padding, EdgeSizes BorderWidth, RenderPaint BackgroundPaint, - RenderColor BorderColor); + RenderColor BorderColor, + RenderCornerRadii BorderRadius = default); } diff --git a/src/AngleSharp.Renderer/Rendering/DisplayList.cs b/src/AngleSharp.Renderer/Rendering/DisplayList.cs index d81d059..ebcb352 100644 --- a/src/AngleSharp.Renderer/Rendering/DisplayList.cs +++ b/src/AngleSharp.Renderer/Rendering/DisplayList.cs @@ -43,6 +43,29 @@ public void FillRect(RenderRect rect, RenderPaint paint) Add(new FillRectCommand(rect, paint)); } + /// + /// Adds a filled rectangle command with per-corner rounding (`border-radius`). + /// + public void FillRect(RenderRect rect, RenderColor color, RenderCornerRadii radii) => + Add(new FillRectCommand(rect, new RenderColorPaint(color), radii)); + + /// + /// Adds a filled rectangle command with per-corner rounding using a custom paint. + /// + public void FillRect(RenderRect rect, RenderPaint paint, RenderCornerRadii radii) + { + ArgumentNullException.ThrowIfNull(paint); + Add(new FillRectCommand(rect, paint, radii)); + } + + /// + /// Adds a stroked rounded rectangle command - used to paint a `border-radius` box's border + /// when all four edges share the same width, so the border can be drawn as a single ring + /// rather than four separate straight edges. + /// + public void StrokeRoundedRect(RenderRect rect, RenderColor color, float strokeWidth, RenderCornerRadii radii) => + Add(new StrokeRoundedRectCommand(rect, color, strokeWidth, radii)); + /// /// Adds an image draw command. /// @@ -83,9 +106,9 @@ public void DrawText( public abstract record RenderCommand; /// -/// Draws a filled rectangle. +/// Draws a filled rectangle, optionally with rounded corners (`border-radius`). /// -public sealed record FillRectCommand(RenderRect Rect, RenderPaint Paint) : RenderCommand +public sealed record FillRectCommand(RenderRect Rect, RenderPaint Paint, RenderCornerRadii Radii = default) : RenderCommand { /// /// Gets the solid color for this command when it uses a simple color paint. @@ -93,6 +116,12 @@ public sealed record FillRectCommand(RenderRect Rect, RenderPaint Paint) : Rende public RenderColor Color => Paint is RenderColorPaint colorPaint ? colorPaint.Color : RenderColor.Transparent; } +/// +/// Strokes a rounded rectangle's outline - used for a `border-radius` box's border when every +/// edge shares the same width. +/// +public sealed record StrokeRoundedRectCommand(RenderRect Rect, RenderColor Color, float StrokeWidth, RenderCornerRadii Radii) : RenderCommand; + /// /// Draws a single line of text at a baseline position. /// diff --git a/src/AngleSharp.Renderer/Rendering/RenderCornerRadii.cs b/src/AngleSharp.Renderer/Rendering/RenderCornerRadii.cs new file mode 100644 index 0000000..3d0aab6 --- /dev/null +++ b/src/AngleSharp.Renderer/Rendering/RenderCornerRadii.cs @@ -0,0 +1,67 @@ +namespace AngleSharp.Renderer.Rendering; + +/// +/// Describes the four independent corner radii of a rounded box (`border-radius`), each as a +/// separate horizontal/vertical pair so elliptical corners (`border-radius: 8px / 4px`) are +/// representable, not just circular ones. +/// +public readonly record struct RenderCornerRadii( + float TopLeftX, + float TopLeftY, + float TopRightX, + float TopRightY, + float BottomRightX, + float BottomRightY, + float BottomLeftX, + float BottomLeftY) +{ + /// + /// No rounding on any corner - the common case, and the default for elements without + /// `border-radius`. + /// + public static RenderCornerRadii Zero { get; } = new(0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f); + + /// + /// Whether every corner is unrounded, so callers can take the cheaper plain-rectangle path. + /// + public bool IsZero => + TopLeftX <= 0f && TopLeftY <= 0f && + TopRightX <= 0f && TopRightY <= 0f && + BottomRightX <= 0f && BottomRightY <= 0f && + BottomLeftX <= 0f && BottomLeftY <= 0f; + + /// + /// Scales every radius down (uniformly) just enough that adjacent corners along the same edge + /// never overlap, per the CSS corner-overlap-prevention algorithm + /// (https://www.w3.org/TR/css-backgrounds-3/#corner-overlap). A radius pair whose sum already + /// fits within the edge it shares is left untouched. + /// + public RenderCornerRadii ClampToBox(float width, float height) + { + if (width <= 0f || height <= 0f) + { + return Zero; + } + + var scale = 1f; + + scale = Math.Min(scale, SafeRatio(width, TopLeftX + TopRightX)); + scale = Math.Min(scale, SafeRatio(height, TopRightY + BottomRightY)); + scale = Math.Min(scale, SafeRatio(width, BottomRightX + BottomLeftX)); + scale = Math.Min(scale, SafeRatio(height, BottomLeftY + TopLeftY)); + + if (scale >= 1f) + { + return this; + } + + return new RenderCornerRadii( + TopLeftX * scale, TopLeftY * scale, + TopRightX * scale, TopRightY * scale, + BottomRightX * scale, BottomRightY * scale, + BottomLeftX * scale, BottomLeftY * scale); + } + + private static float SafeRatio(float available, float requested) => + requested > 0f ? available / requested : float.MaxValue; +} diff --git a/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs b/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs index 1ec32d4..833c800 100644 --- a/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs +++ b/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs @@ -55,6 +55,9 @@ private static void DrawCommand(SKCanvas canvas, RenderCommand command, FontFace case FillRectCommand fill: DrawFillRect(canvas, fill); break; + case StrokeRoundedRectCommand strokeRoundedRect: + DrawStrokeRoundedRect(canvas, strokeRoundedRect); + break; case DrawImageCommand image: DrawImage(canvas, image); break; @@ -79,9 +82,53 @@ private static void DrawFillRect(SKCanvas canvas, FillRectCommand command) command.Rect.X + command.Rect.Width, command.Rect.Y + command.Rect.Height); - canvas.DrawRect(rect, paint); + if (command.Radii.IsZero) + { + canvas.DrawRect(rect, paint); + } + else + { + using var roundRect = new SKRoundRect(); + roundRect.SetRectRadii(rect, ToSkPoints(command.Radii)); + canvas.DrawRoundRect(roundRect, paint); + } + } + + private static void DrawStrokeRoundedRect(SKCanvas canvas, StrokeRoundedRectCommand command) + { + if (command.Rect.IsEmpty || command.Color.A == 0 || command.StrokeWidth <= 0f) + { + return; + } + + var rect = new SKRect( + command.Rect.X, + command.Rect.Y, + command.Rect.X + command.Rect.Width, + command.Rect.Y + command.Rect.Height); + + using var roundRect = new SKRoundRect(); + roundRect.SetRectRadii(rect, ToSkPoints(command.Radii)); + + using var paint = new SKPaint + { + Color = ToSkColor(command.Color), + IsAntialias = true, + Style = SKPaintStyle.Stroke, + StrokeWidth = command.StrokeWidth, + }; + + canvas.DrawRoundRect(roundRect, paint); } + private static SKPoint[] ToSkPoints(RenderCornerRadii radii) => + [ + new SKPoint(radii.TopLeftX, radii.TopLeftY), + new SKPoint(radii.TopRightX, radii.TopRightY), + new SKPoint(radii.BottomRightX, radii.BottomRightY), + new SKPoint(radii.BottomLeftX, radii.BottomLeftY), + ]; + private static SKPaint CreateFillPaint(RenderPaint paint, RenderRect rect) { return paint switch From 3e3b5956ae43d0636a9f96e0ce1ce61bc9dfcf94 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Mon, 7 Sep 2026 16:22:32 +0200 Subject: [PATCH 03/22] Added text- and box-shadow --- AGENTS.md | 4 +- CHANGELOG.md | 2 + .../HtmlRendererTests.cs | 191 +++++++++++++++ .../VisualConformanceTests.cs | 170 ++++++++++++++ ...x-shadow-following-border-radius.macos.png | Bin 0 -> 1551 bytes .../paints-inset-box-shadow.macos.png | Bin 0 -> 880 bytes ...nts-multiple-box-shadows-layered.macos.png | Bin 0 -> 376 bytes ...ts-multiple-text-shadows-layered.macos.png | Bin 0 -> 871 bytes .../paints-outset-box-shadow.macos.png | Bin 0 -> 1338 bytes .../paints-text-shadow.macos.png | Bin 0 -> 1127 bytes src/AngleSharp.Renderer/HtmlRenderer.cs | 217 +++++++++++++++++- .../Rendering/DisplayList.cs | 55 +++++ .../Rendering/RenderShadow.cs | 35 +++ .../Skia/SkiaRenderBackend.cs | 120 ++++++++++ 14 files changed, 782 insertions(+), 12 deletions(-) create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/paints-box-shadow-following-border-radius.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/paints-inset-box-shadow.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/paints-multiple-box-shadows-layered.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/paints-multiple-text-shadows-layered.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/paints-outset-box-shadow.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/paints-text-shadow.macos.png create mode 100644 src/AngleSharp.Renderer/Rendering/RenderShadow.cs diff --git a/AGENTS.md b/AGENTS.md index 296cfe1..31fd2d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,9 @@ Table spans: a cell covers the columns and rows it spans, and a spanning cell's `border-radius` (rounded corners): the shorthand and all four longhands (`border-top-left-radius` etc.) are supported, each independently elliptical (`border-top-left-radius: 20px 10px`, or the whole-box `border-radius: 40px / 20px` slash shorthand) and represented backend-agnostically as `RenderCornerRadii` (`Rendering/RenderCornerRadii.cs`) - eight floats, X/Y per corner. `RenderCornerRadii.ClampToBox` implements the CSS corner-overlap-prevention algorithm (https://www.w3.org/TR/css-backgrounds-3/#corner-overlap): a single global scale factor, not a per-axis one, is applied to every radius so adjacent corners along the same edge never overlap - this is what turns a `border-radius` larger than half a box's height into a pill/stadium shape rather than a self-intersecting curve. A background fills with `SKRoundRect`/`DrawRoundRect` (`SkiaRenderBackend.DrawFillRect`) instead of `DrawRect` whenever `Radii` is non-zero. A border with rounded corners is painted differently depending on whether every edge shares the same width: a uniform-width border becomes a single `StrokeRoundedRectCommand` (one stroked ring, inset by half the border width, with the radius reduced by that same half-width since the stroke sits on the border's centerline); a mixed-width border (e.g. `border-right-width` wider than the others) has no single stroke width to give that ring, so it falls back to the existing four-straight-rectangle border path and the corners render square - a deliberate, documented limitation rather than an attempt at four independently-rounded quads. `outline` never rounds regardless of `border-radius`, matching the CSS spec, because `PaintOutline`'s internal `PaintBorder` call never passes the element's radii. Percentage radii resolve to pixels through AngleSharp.Css's computed-style engine before this renderer ever sees them, but that resolution has a verified quirk in the AngleSharp.Css version this repo builds against (1.1.0): both the horizontal and vertical component of a percentage radius resolve against the *containing block's width*, not per-axis against the element's own width and height as the CSS spec technically prescribes - confirmed by rendering the same `%` radius against different viewport widths and observing the resulting pixel value track the viewport, not the element's own box. This is a property of the upstream computed-style engine, not of this renderer's own parsing. -Current behavior includes block layout, margins, padding, borders, floats, inline-block, relative/fixed/absolute positioning, z-index ordering, outlines, text styling, text alignment, line-height, letter-spacing, text-indent, vertical-align, border-radius, and generic font-family handling. +`box-shadow` and `text-shadow`: both are parsed from AngleSharp.Css's `GetBoxShadow()`/`GetTextShadow()` computed-style accessors, which return every comma-separated layer already normalized with the color as `rgba(r, g, b, a)` regardless of how it was authored (`red`, `#f00`, ...) - `ExtractColorToken` (`HtmlRenderer.cs`) relies on that normalization to split a layer's color from its lengths by locating the `rgba(...)` call rather than naively splitting on whitespace, which the spaces after that function's commas would otherwise break. `text-shadow` is an inherited property and AngleSharp.Css already resolves that inheritance before this renderer sees it (a child with no `text-shadow` of its own reports its ancestor's value verbatim, and an explicit `text-shadow: none` reports the literal string `"none"`, not an empty one) - `ParseTextShadows` still takes an `inherited` fallback for defense in depth, mirroring how `color` and the other inherited text properties are already handled in `ResolveTextStyle`. `box-shadow` is not inherited, matching spec, so `ResolveBoxStyle` parses it directly with no inherited fallback. Multiple shadows paint back-to-front in reverse authoring order (`PaintBoxShadows`/`PaintTextShadows`), because CSS defines the first-listed shadow as topmost. A `box-shadow` layer is `RenderBoxShadow` (`Rendering/RenderShadow.cs`) - offset, blur, spread, color, and `inset` - painted via `DrawBoxShadowCommand`/`SkiaRenderBackend.DrawBoxShadow`, which reuses the same `RenderCornerRadii`/`SKRoundRect` machinery `border-radius` already established so a shadow follows the box's own corner rounding. An outset shadow is the border box's `SKRoundRect` grown by `SpreadRadius` (via `SKRoundRect.Inflate`/`Deflate` - verified functionally to adjust the rect *and* clamp each radius down to zero together, exactly matching the CSS spread semantics for free) and offset, blurred with `SKMaskFilter.CreateBlur`, then clipped with `SKClipOperation.Difference` against the *un-inflated* border box so the shadow never bleeds into the box's own interior even when its background is transparent - this clip-out is required by spec, not an approximation. An inset shadow is the mirror image: the border box shrunk by spread and offset, painted as the even-odd "ring" between that shrunk shape and a rect well outside the box, clipped with `SKClipOperation.Intersect` to the border box so it only shows inside. Both cases derive the Gaussian blur sigma as half the CSS blur radius (`shadow.BlurRadius / 2f`), the same approximation browsers commonly use; there is no dedicated conformance test pinning that constant, so treat it as adjustable if a real blurred-shadow comparison ever disagrees with it. `text-shadow` has no spread or inset, and paints as a second, blurred copy of the same text run (`DrawTextShadowCommand`/`SkiaRenderBackend.DrawTextShadow`) offset behind the real glyphs, shaped through the same `SkiaTextShaping` path as ordinary text so a shadow never drifts out of alignment with what it is shadowing. Painting order for a box is background, then `box-shadow` (all layers), then border, then outline - box-shadow must sit on top of the background (not behind it) even though it is visually "behind" the box's own content, because an inset shadow paints inside the box and would otherwise be hidden by an opaque background; this was a real bug caught by rendering the inset-shadow visual test and seeing no shadow at all, fixed by reordering `PaintBoxShadows` after `PaintBackground` at all three box-painting call sites. + +Current behavior includes block layout, margins, padding, borders, floats, inline-block, relative/fixed/absolute positioning, z-index ordering, outlines, text styling, text alignment, line-height, letter-spacing, text-indent, vertical-align, border-radius, box-shadow, text-shadow, and generic font-family handling. SVG support: `` (including `data:` URIs) and inline `` markup both render, through two loading paths that converge on the same DOM-walking rasterizer (`Skia/Svg/`) - no third-party SVG parser is involved anywhere. An `` SVG source is sniffed and rasterized inside `TryLoadImageResource`, exactly where a PNG/JPEG source is decoded: `SvgRasterizer.TryRasterizeMarkup` parses the bytes with AngleSharp's own HTML/foreign-content parser (wrapped in a throwaway `` shell) and is cached per-document by URL like any other image. Inline `` has no URL and, more importantly, is already sitting in the host document's DOM - `SvgRasterizer.TryRasterizeElement` walks that element directly and is cached per-element in `s_inlineSvgCacheByElement`; it is never serialized back to text and re-parsed. `LayoutElement` empties `orderedChildren` for an `` root so its foreign-namespaced children are never walked as HTML flow content; both `` and inline `` are treated as a single replaced element. Rasterization always happens at the SVG's own natural (`viewBox`/`width`/`height`) size oversampled by a fixed factor (`SvgRasterizer.OversampleFactor`), not at the resolved CSS box size - the loader runs before CSS sizing is known, so this is a deliberate blur-vs-memory tradeoff rather than a per-render-size cache. diff --git a/CHANGELOG.md b/CHANGELOG.md index 14816e2..0ecf205 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ Released on Wednesday, September 9 2026. - Added support for rounded corners +- Added support for `text-shadow` declarations +- Added support for `box-shadow` declarations # 0.3.0 diff --git a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs index 7e7c5de..d0dbec6 100644 --- a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs +++ b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs @@ -1943,6 +1943,197 @@ public async Task BuildDisplayList_DoesNotRoundOutlineWithBorderRadius() Assert.Equal(4, outlineFills.Length); } + [Fact] + public async Task BuildDisplayList_ParsesSingleOutsetBoxShadow() + { + var document = await ParseAsync(""" + +
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + }); + + var shadow = Assert.Single(displayList.Commands.OfType()); + Assert.Equal(2f, shadow.Shadow.OffsetX); + Assert.Equal(3f, shadow.Shadow.OffsetY); + Assert.Equal(4f, shadow.Shadow.BlurRadius); + Assert.Equal(1f, shadow.Shadow.SpreadRadius); + Assert.Equal(new RenderColor(0, 0, 0, 128), shadow.Shadow.Color); + Assert.False(shadow.Shadow.Inset); + + // Per spec, box-shadow paints after the background (and before the border) - not before + // it - so an inset shadow is not hidden underneath an opaque background. For an outset + // shadow like this one, the shadow shape is clipped to never overlap the border box + // anyway, so this ordering has no visible effect here; it matters for the inset case. + var shadowIndex = Array.IndexOf(displayList.Commands.ToArray(), (RenderCommand)shadow); + var backgroundIndex = Array.FindIndex(displayList.Commands.ToArray(), c => c is FillRectCommand fill && fill.Color.Equals(new RenderColor(0, 255, 0))); + Assert.True(shadowIndex > backgroundIndex); + } + + [Fact] + public async Task BuildDisplayList_ParsesInsetBoxShadow() + { + var document = await ParseAsync(""" + +
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + }); + + var shadow = Assert.Single(displayList.Commands.OfType()); + Assert.True(shadow.Shadow.Inset); + Assert.Equal(0f, shadow.Shadow.OffsetX); + Assert.Equal(0f, shadow.Shadow.OffsetY); + Assert.Equal(5f, shadow.Shadow.BlurRadius); + Assert.Equal(0f, shadow.Shadow.SpreadRadius); + Assert.Equal(new RenderColor(255, 0, 0), shadow.Shadow.Color); + } + + [Fact] + public async Task BuildDisplayList_PaintsMultipleBoxShadowsWithFirstListedOnTop() + { + var document = await ParseAsync(""" + +
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + }); + + var shadows = displayList.Commands.OfType().ToArray(); + Assert.Equal(2, shadows.Length); + + // The first-listed shadow (red) paints on top of the second (blue), so it is added to + // the display list last, even though it appears first in the CSS source. + Assert.Equal(new RenderColor(0, 0, 255), shadows[0].Shadow.Color); + Assert.Equal(new RenderColor(255, 0, 0), shadows[1].Shadow.Color); + } + + [Fact] + public async Task BuildDisplayList_TreatsBoxShadowNoneAsNoShadow() + { + var document = await ParseAsync(""" + +
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + }); + + Assert.Empty(displayList.Commands.OfType()); + } + + [Fact] + public async Task BuildDisplayList_ParsesTextShadowAndPaintsItBeforeTheText() + { + var document = await ParseAsync(""" + +

Hi

+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + }); + + var commands = displayList.Commands.ToArray(); + var shadow = Assert.Single(commands.OfType()); + var text = Assert.Single(commands.OfType()); + + Assert.Equal("Hi", shadow.Text); + Assert.Equal(text.X + 1f, shadow.X); + Assert.Equal(text.Y + 1f, shadow.Y); + Assert.Equal(2f, shadow.BlurRadius); + Assert.Equal(new RenderColor(0, 0, 0), shadow.Color); + Assert.True(Array.IndexOf(commands, (RenderCommand)shadow) < Array.IndexOf(commands, (RenderCommand)text)); + } + + [Fact] + public async Task BuildDisplayList_PaintsMultipleTextShadowsWithFirstListedOnTop() + { + var document = await ParseAsync(""" + +

Hi

+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + }); + + var shadows = displayList.Commands.OfType().ToArray(); + Assert.Equal(2, shadows.Length); + Assert.Equal(new RenderColor(0, 0, 255), shadows[0].Color); + Assert.Equal(new RenderColor(255, 0, 0), shadows[1].Color); + } + + [Fact] + public async Task BuildDisplayList_InheritsTextShadowToChildrenWithoutTheirOwn() + { + var document = await ParseAsync(""" + +
Hi
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + }); + + var shadow = Assert.Single(displayList.Commands.OfType()); + Assert.Equal(new RenderColor(255, 0, 0), shadow.Color); + } + + [Fact] + public async Task BuildDisplayList_TextShadowNoneOverridesInheritedShadow() + { + var document = await ParseAsync(""" + +
Hi
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + }); + + Assert.Empty(displayList.Commands.OfType()); + } + private static async Task ParseAsync(string html, IConfiguration? configuration = null, string? address = null) { var context = BrowsingContext.New(configuration ?? Configuration.Default.WithCss()); diff --git a/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs b/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs index ed61bc1..92f3750 100644 --- a/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs +++ b/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs @@ -1531,6 +1531,176 @@ public async Task RenderToPng_FallsBackToStraightEdgesForMixedWidthRoundedBorder maxDifferentPixels: 0); } + [Fact] + public async Task RenderToPng_PaintsOutsetBoxShadow() + { + var document = await ParseAsync(""" + + + + + +
+ + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 130, + ViewPortHeight = 110, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "paints-outset-box-shadow.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_PaintsInsetBoxShadow() + { + var document = await ParseAsync(""" + + + + + +
+ + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 130, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "paints-inset-box-shadow.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_PaintsBoxShadowFollowingBorderRadius() + { + var document = await ParseAsync(""" + + + + + +
+ + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 130, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "paints-box-shadow-following-border-radius.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_PaintsMultipleBoxShadowsLayered() + { + var document = await ParseAsync(""" + + + + + +
+ + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 110, + ViewPortHeight = 90, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "paints-multiple-box-shadows-layered.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_PaintsTextShadow() + { + var document = await ParseAsync(""" + + + + + +

Hi

+ + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 120, + ViewPortHeight = 70, + FontSize = 16f, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "paints-text-shadow.png", + actualPng: image.Data, + perChannelTolerance: TextRenderingToleranceChannel, + maxDifferentPixels: TextRenderingToleranceMaxPixels); + } + + [Fact] + public async Task RenderToPng_PaintsMultipleTextShadowsLayered() + { + var document = await ParseAsync(""" + + + + + +

Hi

+ + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 120, + ViewPortHeight = 70, + FontSize = 16f, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "paints-multiple-text-shadows-layered.png", + actualPng: image.Data, + perChannelTolerance: TextRenderingToleranceChannel, + maxDifferentPixels: TextRenderingToleranceMaxPixels); + } + private static async Task RenderCanvasSnapshotAsync(string html, Action draw) { var context = BrowsingContext.New(Configuration.Default.WithCss().WithRendering()); diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/paints-box-shadow-following-border-radius.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/paints-box-shadow-following-border-radius.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..83932c9ca73511abd763e6242c6dc3c6938a9e50 GIT binary patch literal 1551 zcmbVMc`(}v6iyx6ABxbHUP4-mmdXlBvaYzIB2sspTj!#pOX?^~WfZLnN=+TnHAEd- zT1BI1tyWXiJ*l`ii6g4&il7?VbUL$t?H@b)X5RbWn>XK^_s2KiZ5-B4R{DfA2n3R~ zM_W6KkR-|=;0IB?1mOmVKq3ZZ?+S<_2?)vrfh6DBTU)xum#$1Xq6-pEb*?uU!9Tik z;2heEYTc#+MXYt(*+1O@2x%xy4IwG%_4pH&kUC_WFd-+S5Zm)R;fH3a5$F?AbxRB5 zmRvYb%!Y$7sX)R%-bEyG!!B-ilr8R>%L~jj{nvy-s6RclY_X`PH%R~Z8)ZH3El?Lk zUE%;5Kxsk&3ltb3WeHLw4cP-?x!^P`3Vbt7ji^WhsY{gSgC*3U03}TgBxT9{PFZwr z-QD6|N2DX;?}M0rjzsWSF_t1(7Raj2yDn=`dQ>BU8he(|I`4-g^%e zyKpx$M;n|@G+!{>pyS;2nj$UWmq-SItb%I}fZN;_ce_PyoB_|Hfp_`=ax3j|* zLgE3IE0e3C62;sTR#=3FYOd1+a+TTgP+e%!)ptnVEWO6{j$IB!yEKH5o&n1%g59vE zmt5=rN||bla~f9P%tsa+;Vv(`n0DzXlEk8BU%GwoMBwb!Tzr#+fVOAPhP)78?#8T5ZWBoCdvKbYDjZmYNKX_&%* z8JZY!BK)lZkme=qyHWIvX=Eu)f3GnqA)%ENrWA4s=q?M8Q&uqw46~~dbQH#4xnCbHE-r3hWTcow*PeFn z<;GPUQj*hWt3ZpDHI^C|OC07L7g(yB71?1&1iopjPjfuv{dB*?6ju$;%wX~OB1xBI zM4uJt*zw~m?r;T<&-dzRrqQMX3uuGz&YsgFTg|LR}q3ty>-QS*ZL$c}UQF@BLajUTGeh z#2ug@r`i+pLkG(gvbr<71McPwrhu!Q34{y9RpntPBF5^eEiHvBlh5lQ%lYHyB+AL~ m{Zy&Z|7K(V!}Izn9%OHWwXR3~O!^zgxY1bv literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/paints-inset-box-shadow.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/paints-inset-box-shadow.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..70699f2d02a85d484a3a81938ab99fd4ac96d40e GIT binary patch literal 880 zcmeAS@N?(olHy`uVBq!ia0vp^O+cK&!3HF~BQtsf96Pu@jv|dv6$!e*DZ2#%UIvGPEdDh+i>4s`+Ks? z^58|&jygxI*H6BYqc>mfSDl?`)FglI%J%2Msxk8fw(7_oGB~5|AnE$kYT;G!?n)(@ z{?DBiR%feCb?Wl}+BTcn@BJ05_=NSDh3yP^*1iXSZJNxNZ%P*d>GqS> zS1!=_{@r9|g*GpZ>V1y*c^U!{>&7ci$~B%Gv&P-~OY{C%QG~bN@Xm zyZ`@D*{vndi_g{{yRbLoO~bWvv=m5M;2_d;YrSRL$~*E_^M3-f5QC?ypUXO@geCyy CGkKE$ literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/paints-multiple-box-shadows-layered.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/paints-multiple-box-shadows-layered.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..93d3b2d8cca8833ebabe74ee549674cba5c17d43 GIT binary patch literal 376 zcmeAS@N?(olHy`uVBq!ia0vp^c|aV+!3HEFHzYd%DVAa<&kznEsNqQIU|?X>_jGX# zshIQjjv-%DfCz)*zK?2m{(H<}^^>_Uf+eW`Id7y3!`;i9@~mfFqXdYerB4<7+K^6U2XrRP4G jm5Hj6W-=aUykeA}FSa>!y|WZBC>cCm{an^LB{Ts5MaG)G literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/paints-multiple-text-shadows-layered.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/paints-multiple-text-shadows-layered.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..3c2af23fdecd9a861b78e43e39c4c7d8248d016f GIT binary patch literal 871 zcmeAS@N?(olHy`uVBq!ia0vp^6+rC9!3HGRMFRMN6icy_X9x!n)NrJ9FfcI7dAc}; zRLpsM*WO#YQ0Dl@?N>GZw%>WeEbvL8#B+J{rjkor8Kni(3s+v*d19s~&ywir4W=hL zJL;p1JTx_~nBCy(S}6I6Vb|7}c46Puz2fh)-dsP=?Q&Az{&TZ=y#Dl~jRrin)}DU*ZTSUSKCf1x%ipIeD>My%wie!Yx5Q_UfgZF zGiKfI$>CuM+1y;L7gW@zy^-3poqZm=p}J%TQrQ`9oPIr9lDCK}s8alHmg9x4Uq`NHI9zCm;cHS@5Vo4pVDIkJud=30NB{nI+HK#-`P9f{_Fub`|8+02^fP}pocCRbW73`pccK;@Po%{Ed^bJB{)L2m`_F8)o8K2MV|>>g!rb5E zGVy=O-Mt&{^j25TR(Tna@L1%=j(N=dys>fH9>*QXzjb-1-Hq3cOIFWg_V@D>YEw*H zdT8~G>S@!aEwT*`w%MIlo0q2-6(8}Sa6S8^b$S2QcO}27J2Lm_>~AG&{$J5quI#fS zh|{@apPJgEw=-w$y7TyV_;qnHz3b*?ljeO$^!}r^I{8K2Z_AVO&K+k1CRm=9h5yo< qQ+N3q`8Dx1MGJQY^(zo*^7SP{WbZ0px!4ba4!+ znDch->%Q4u0&J&ebbl}X%<+7p>;#7d3wtBqTbpl$CEbuc6fP7sIfBzT_+6gHEC*MsNf^l?epUretcbBuYI-i^9{p!e#?(u|7W}M)P`9c+X`;h zo1OT=wwv|3y!~_6@6ppz%YRSU#vhOeDX@MgR8p=TqJ#1qHAvdY5M(3ajC|GpV@-b#Qt_D z#jL+>$rTcM^le$PjC+Is(Z?2jC;x5AyqeGJF0yc4cGsc>I^KEPZ@*uEabccBTcV%r zN1NL3kE%nI_jGm!gkFtR`OumiC{p||Bic*oK->OE8!q3=XD%|-xie|Y)GqPZ+4pPH zYr%h^x3}FsvYsixd#iQ2aDhY4yFklN*IYGqIId9SGBX!o|DF$-&ERM zDunOAY((eJj04Qh5pTO3UjYSZ2F)$YUI$v!;#M_0dFqq*T#?#IHzp7(Rl z_(w!e`@Hsv*~0!j3141&yL#z8rsp@EJ-uOyR#5WSe;jr?$8?{c+BD}k&%yW0gg)+V z))el}Qwj-n4Go==`cu>Bho$G#vr0|7=d?D-~TO|3$IfkGb`KroNDG(~hTe{5kjSY*Bxj&s(7X zC+M*6?LELW>ZJAf_7b-bt_$*=Ul+d$s-Ageaiie!_H^Bf_}ZyHt)ba0zcc5acx<-i zu)&$hTT~9pxkhyH*r^^_sTeAGPwb%itZlWziswK3zdW?a@lF!krS<$h7T4{wFYj;P z!XUkbO}Wml(O_?$cLsKH#2zeZzMD6Bh$;l_7Z36ShJJs|JRD?7vK8Ja?S( S_y8;n89ZJ6T-G@yGywoQ8%yp0 literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/paints-text-shadow.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/paints-text-shadow.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..edaa0402f73d968910a03092c7948fd3981875fe GIT binary patch literal 1127 zcmeAS@N?(olHy`uVBq!ia0vp^6+rC9!3HGRMFRMN6icy_X9x!n)NrJ90J(CWE{-7; zbKcI`@Aue2puPV0`D0BY9FEv&I0pU{!WI4YF$5Uc5i;n`{>1s6OD@9e_rw*_!AppP)IrY;$P3e zP;kyymRqsw`KjYRwprZkCNch*7ND9u&EdI>)m*;5{{Dw^4&S{i8y6q1*aCF(Q?n)0 z5}wQW`TJjdS;D}OpP#>D&mJ8Y56y>jq$fX`bNK65Q$9XE28RCr{)&o{@y z+uJj?-)3ki+ub{Ty12&D1zX#KxERtumam+9uuvwswpNy5!{*J+DxM3bG#RonaAl^J zm6vzVu;`np!nx&IGLvoOv_u051s>;fk615MbiDiio1=+=LCd(pD9uC)B*Vev63D{v zqHOnuYfKFH?%$udOmoQ;gJTY_>lgX_$X{ve;{VT1hA(k#t0CL587q0_^=!PBWFd3N zl0BJcO3;p7yAB;c&i?DK-6fM&fh4WIpK`s%7N?Ce)+Vl<>)o|gEOOdG3FEMWpFLZz zIeBreEqz*W{>tKy@fV`3x5qsAyy(OF@Am2G=@nH~hdzHc_Ve=-XiGGd`tav=kG%K9 zPpljF@9%&6Hup$j&)&VZ9EuTdnj$<{gU`9mU--1R^u%YG%vnqfQ-W;7&6iAk(A&#< z=gu96Y}>FD-*wH~w{6>3!(Sg*u}Q~yl8S(n$orEu^WB=l!@~>f>f)^P!os4m4W)V~ zJpVbRW|^J${O12EIh#0UtGE4}DDu;%h>nE#grC(r>V?ke^taya{#^X}iOlJu zda-@6&N5;?b8a=cFAgl6^ZaMb>3KHik3TwX=+trQ^O+=p(tq(=wu)8GQ}z`4QnfcJ z-A$q`V{25$qKvHY_V>E{?ap7SY*jp&7&;bltPNXz@Jawyn*8r<;u{ zcz%@YwDUIiZ!p+NOF9aixziZf_i@o7gO;FGH%gXo)jP+ns5M*7r#376Vx4tw{AVVO zve2-vi%#DzWDt>jop1Q-`tQ&GEbsq%l52f-h3<{?-5A%({;N;y)_jV2^Pcgy zL|(Sp@^1oft51j8KKqm$dS*>u*wizT_opR1dmJqpQx}uM(;Z`Db5O9IRhW46T--XR W%V style var letterSpacing = ParseLength(styleMap, "letter-spacing", inherited.FontSize, inherited.LetterSpacing, allowAuto: false); var textIndent = ParseLength(styleMap, "text-indent", inherited.FontSize, 0f, allowAuto: false); var verticalAlignOffset = ParseVerticalAlign(styleMap, fontSize); + var textShadows = ParseTextShadows(styleMap.TryGetValue("text-shadow", out var textShadowValue) ? textShadowValue : null, inherited.TextShadows); - return new RenderTextStyle(fontSize, color, fontFamily, lineHeight, fontWeight, isItalic, underline, strikeThrough, decorationColor, decorationStyle, textAlign, letterSpacing, textIndent, verticalAlignOffset); + return new RenderTextStyle(fontSize, color, fontFamily, lineHeight, fontWeight, isItalic, underline, strikeThrough, decorationColor, decorationStyle, textAlign, letterSpacing, textIndent, verticalAlignOffset, textShadows); } /// @@ -2688,6 +2700,9 @@ private static Dictionary CreateStyleMap(ICssStyleDeclaration st AddIfPresent(map, "border-bottom-right-radius", style.GetBorderBottomRightRadius()); AddIfPresent(map, "border-bottom-left-radius", style.GetBorderBottomLeftRadius()); + AddIfPresent(map, "box-shadow", style.GetBoxShadow()); + AddIfPresent(map, "text-shadow", style.GetTextShadow()); + AddIfPresent(map, "outline-width", style.GetPropertyValue("outline-width")); AddIfPresent(map, "outline-style", style.GetPropertyValue("outline-style")); AddIfPresent(map, "outline-color", style.GetPropertyValue("outline-color")); @@ -2944,7 +2959,151 @@ private static BoxStyle ResolveBoxStyle(Dictionary styleMap) bottomRightX, bottomRightY, bottomLeftX, bottomLeftY); - return new BoxStyle(margin, padding, borderWidth, backgroundPaint, borderColor, borderRadius); + var boxShadows = ParseBoxShadows(styleMap.TryGetValue("box-shadow", out var boxShadowValue) ? boxShadowValue : null); + + return new BoxStyle(margin, padding, borderWidth, backgroundPaint, borderColor, borderRadius, boxShadows); + } + + /// + /// Parses a `box-shadow` value into its comma-separated layers, in CSS authoring order + /// (first-listed shadow paints topmost among shadows). AngleSharp.Css's computed style + /// normalizes every layer's color to `rgba(r, g, b, a)` regardless of how it was authored + /// (`red`, `#f00`, ...), which relies on to split a layer's + /// color from its lengths without being confused by the commas inside `rgba(...)`. + /// + private static IReadOnlyList ParseBoxShadows(string? value) + { + if (string.IsNullOrWhiteSpace(value) || string.Equals(value.Trim(), "none", StringComparison.OrdinalIgnoreCase)) + { + return []; + } + + var layers = SplitTopLevelCommaList(value); + var shadows = new List(layers.Length); + + foreach (var layer in layers) + { + if (ParseSingleBoxShadow(layer) is { } shadow) + { + shadows.Add(shadow); + } + } + + return shadows; + } + + private static RenderBoxShadow? ParseSingleBoxShadow(string layer) + { + var trimmed = layer.Trim(); + var inset = false; + + if (trimmed.StartsWith("inset", StringComparison.OrdinalIgnoreCase) && (trimmed.Length == 5 || char.IsWhiteSpace(trimmed[5]))) + { + inset = true; + trimmed = trimmed[5..].TrimStart(); + } + else if (trimmed.EndsWith("inset", StringComparison.OrdinalIgnoreCase) && (trimmed.Length == 5 || char.IsWhiteSpace(trimmed[^6]))) + { + inset = true; + trimmed = trimmed[..^5].TrimEnd(); + } + + var (colorToken, remainder) = ExtractColorToken(trimmed); + var color = ParseColor(colorToken, RenderColor.Black); + var tokens = remainder.Split(' ', StringSplitOptions.RemoveEmptyEntries); + + if (tokens.Length < 2) + { + return null; + } + + var offsetX = ParseLengthValue(tokens[0], 0f, allowAuto: false); + var offsetY = ParseLengthValue(tokens[1], 0f, allowAuto: false); + var blurRadius = tokens.Length > 2 ? Math.Max(0f, ParseLengthValue(tokens[2], 0f, allowAuto: false)) : 0f; + var spreadRadius = tokens.Length > 3 ? ParseLengthValue(tokens[3], 0f, allowAuto: false) : 0f; + + return new RenderBoxShadow(offsetX, offsetY, blurRadius, spreadRadius, color, inset); + } + + /// + /// Parses a `text-shadow` value into its comma-separated layers, in CSS authoring order + /// (first-listed shadow paints topmost among shadows). Unlike `box-shadow`, a layer has no + /// `inset` keyword and no spread component. + /// + private static IReadOnlyList ParseTextShadows(string? value, IReadOnlyList inherited) + { + if (string.IsNullOrWhiteSpace(value)) + { + return inherited; + } + + if (string.Equals(value.Trim(), "none", StringComparison.OrdinalIgnoreCase)) + { + return []; + } + + var layers = SplitTopLevelCommaList(value); + var shadows = new List(layers.Length); + + foreach (var layer in layers) + { + if (ParseSingleTextShadow(layer) is { } shadow) + { + shadows.Add(shadow); + } + } + + return shadows; + } + + private static RenderTextShadow? ParseSingleTextShadow(string layer) + { + var (colorToken, remainder) = ExtractColorToken(layer.Trim()); + var color = ParseColor(colorToken, RenderColor.Black); + var tokens = remainder.Split(' ', StringSplitOptions.RemoveEmptyEntries); + + if (tokens.Length < 2) + { + return null; + } + + var offsetX = ParseLengthValue(tokens[0], 0f, allowAuto: false); + var offsetY = ParseLengthValue(tokens[1], 0f, allowAuto: false); + var blurRadius = tokens.Length > 2 ? Math.Max(0f, ParseLengthValue(tokens[2], 0f, allowAuto: false)) : 0f; + + return new RenderTextShadow(offsetX, offsetY, blurRadius, color); + } + + /// + /// Splits a shadow layer's color from its offset/blur/spread lengths. AngleSharp.Css always + /// normalizes a shadow's color to `rgba(r, g, b, a)`, so the color is located by its + /// parenthesized function call rather than by naive whitespace splitting, which would + /// otherwise be misled by the spaces after the commas inside `rgba(...)`. Falls back to + /// treating the last whitespace-separated token as the color for any value that reaches this + /// parser without going through AngleSharp.Css's own normalization. + /// + private static (string? ColorToken, string Remainder) ExtractColorToken(string value) + { + var functionStart = value.IndexOf("rgba(", StringComparison.OrdinalIgnoreCase); + + if (functionStart < 0) + { + var lastSpace = value.TrimEnd().LastIndexOf(' '); + return lastSpace < 0 + ? (value.Length > 0 ? value.Trim() : null, string.Empty) + : (value[(lastSpace + 1)..].Trim(), value[..lastSpace]); + } + + var functionEnd = value.IndexOf(')', functionStart); + + if (functionEnd < 0) + { + return (value[functionStart..].Trim(), value[..functionStart]); + } + + var colorToken = value[functionStart..(functionEnd + 1)]; + var remainder = value[..functionStart] + value[(functionEnd + 1)..]; + return (colorToken, remainder); } /// @@ -3355,6 +3514,40 @@ private static bool TryParseDataUri(string? source, out byte[]? bytes, out strin return bytes.Length > 0; } + private static void PaintTextShadows(DisplayList displayList, IReadOnlyList shadows, string text, float x, float y, RenderTextStyle textStyle) + { + if (shadows.Count == 0 || string.IsNullOrEmpty(text)) + { + return; + } + + // The first-listed shadow paints topmost among shadows (though always behind the text + // itself), so shadows are added back-to-front: last-authored first, first-authored last. + for (var i = shadows.Count - 1; i >= 0; i--) + { + var shadow = shadows[i]; + displayList.DrawTextShadow(text, x + shadow.OffsetX, y + shadow.OffsetY, shadow.Color, textStyle.FontSize, textStyle.FontFamily, textStyle.FontWeight, textStyle.IsItalic, shadow.BlurRadius, textStyle.LetterSpacing); + } + } + + private static void PaintBoxShadows(DisplayList displayList, IReadOnlyList shadows, float x, float y, float width, float height, RenderCornerRadii radii) + { + if (shadows.Count == 0 || width <= 0f || height <= 0f) + { + return; + } + + var clampedRadii = radii.ClampToBox(width, height); + var borderBoxRect = new RenderRect(x, y, width, height); + + // The first-listed shadow paints topmost among shadows (though always behind the box + // itself), so shadows are added back-to-front: last-authored first, first-authored last. + for (var i = shadows.Count - 1; i >= 0; i--) + { + displayList.DrawBoxShadow(borderBoxRect, clampedRadii, shadows[i]); + } + } + private static void PaintBackground(DisplayList displayList, RenderPaint paint, float x, float y, float width, float height, RenderCornerRadii radii = default) { if (width <= 0f || height <= 0f) @@ -3615,7 +3808,7 @@ private static RenderPaint ParseGradientPaint(string rawValue, RenderColor fallb private static RenderGradient ParseLinearGradient(string rawValue, string functionName, bool repeating, RenderColor fallbackColor) { var inner = ExtractGradientInnerExpression(rawValue, functionName); - var parts = SplitGradientArguments(inner); + var parts = SplitTopLevelCommaList(inner); var startIndex = 0; var angleDegrees = 90f; @@ -3637,7 +3830,7 @@ private static RenderGradient ParseLinearGradient(string rawValue, string functi private static RenderGradient ParseRadialGradient(string rawValue, string functionName, bool repeating, RenderColor fallbackColor) { var inner = ExtractGradientInnerExpression(rawValue, functionName); - var parts = SplitGradientArguments(inner); + var parts = SplitTopLevelCommaList(inner); var startIndex = 0; var isCircle = false; @@ -3680,7 +3873,7 @@ private static RenderGradient ParseRadialGradient(string rawValue, string functi private static RenderGradient ParseConicGradient(string rawValue, string functionName, bool repeating, RenderColor fallbackColor) { var inner = ExtractGradientInnerExpression(rawValue, functionName); - var parts = SplitGradientArguments(inner); + var parts = SplitTopLevelCommaList(inner); var startIndex = 0; var angleDegrees = 0f; var centerX = 0.5f; @@ -3873,7 +4066,7 @@ private static string ExtractGradientInnerExpression(string rawValue, string fun return rawValue[(opening + 1)..closing].Trim(); } - private static string[] SplitGradientArguments(string value) + private static string[] SplitTopLevelCommaList(string value) { if (string.IsNullOrWhiteSpace(value)) { @@ -4538,7 +4731,8 @@ private readonly record struct RenderTextStyle( TextAlign TextAlign, float LetterSpacing, float TextIndent, - float VerticalAlignOffset); + float VerticalAlignOffset, + IReadOnlyList TextShadows); private enum TextAlign { @@ -4564,5 +4758,6 @@ private readonly record struct BoxStyle( EdgeSizes BorderWidth, RenderPaint BackgroundPaint, RenderColor BorderColor, - RenderCornerRadii BorderRadius = default); + RenderCornerRadii BorderRadius, + IReadOnlyList BoxShadows); } diff --git a/src/AngleSharp.Renderer/Rendering/DisplayList.cs b/src/AngleSharp.Renderer/Rendering/DisplayList.cs index ebcb352..97cb4d4 100644 --- a/src/AngleSharp.Renderer/Rendering/DisplayList.cs +++ b/src/AngleSharp.Renderer/Rendering/DisplayList.cs @@ -66,6 +66,12 @@ public void FillRect(RenderRect rect, RenderPaint paint, RenderCornerRadii radii public void StrokeRoundedRect(RenderRect rect, RenderColor color, float strokeWidth, RenderCornerRadii radii) => Add(new StrokeRoundedRectCommand(rect, color, strokeWidth, radii)); + /// + /// Adds a `box-shadow` layer, painted relative to an element's border box. + /// + public void DrawBoxShadow(RenderRect borderBoxRect, RenderCornerRadii borderBoxRadii, RenderBoxShadow shadow) => + Add(new DrawBoxShadowCommand(borderBoxRect, borderBoxRadii, shadow)); + /// /// Adds an image draw command. /// @@ -98,6 +104,27 @@ public void DrawText( Add(new DrawTextCommand(text, x, y, color, fontSize, fontFamily, fontWeight, isItalic, underline, strikeThrough, decorationColor ?? color, decorationStyle, letterSpacing)); } + + /// + /// Adds a `text-shadow` layer, painted as a blurred copy of a text run behind it. + /// + public void DrawTextShadow( + string text, + float x, + float y, + RenderColor color, + float fontSize, + string fontFamily, + float fontWeight, + bool isItalic, + float blurRadius, + float letterSpacing) + { + ArgumentException.ThrowIfNullOrWhiteSpace(text); + ArgumentException.ThrowIfNullOrWhiteSpace(fontFamily); + + Add(new DrawTextShadowCommand(text, x, y, color, fontSize, fontFamily, fontWeight, isItalic, blurRadius, letterSpacing)); + } } /// @@ -122,6 +149,11 @@ public sealed record FillRectCommand(RenderRect Rect, RenderPaint Paint, RenderC /// public sealed record StrokeRoundedRectCommand(RenderRect Rect, RenderColor Color, float StrokeWidth, RenderCornerRadii Radii) : RenderCommand; +/// +/// Paints one `box-shadow` layer relative to an element's border box. +/// +public sealed record DrawBoxShadowCommand(RenderRect BorderBoxRect, RenderCornerRadii BorderBoxRadii, RenderBoxShadow Shadow) : RenderCommand; + /// /// Draws a single line of text at a baseline position. /// @@ -146,6 +178,29 @@ public sealed record DrawTextCommand( public bool IsBold => FontWeight >= 600f; } +/// +/// Paints a blurred copy of a text run behind the actual text (`text-shadow`). Carries only the +/// fields needed to shape and rasterize glyphs identically to - a +/// shadow has no underline/strike-through/decoration of its own. +/// +public sealed record DrawTextShadowCommand( + string Text, + float X, + float Y, + RenderColor Color, + float FontSize, + string FontFamily, + float FontWeight, + bool IsItalic, + float BlurRadius, + float LetterSpacing) : RenderCommand +{ + /// + /// Indicates whether the command should be rendered with a bold typeface. + /// + public bool IsBold => FontWeight >= 600f; +} + /// /// Describes the decoration stroke style for text. /// diff --git a/src/AngleSharp.Renderer/Rendering/RenderShadow.cs b/src/AngleSharp.Renderer/Rendering/RenderShadow.cs new file mode 100644 index 0000000..03be1bb --- /dev/null +++ b/src/AngleSharp.Renderer/Rendering/RenderShadow.cs @@ -0,0 +1,35 @@ +namespace AngleSharp.Renderer.Rendering; + +/// +/// Describes a single `box-shadow` layer. +/// +/// Horizontal offset in pixels; positive moves the shadow right. +/// Vertical offset in pixels; positive moves the shadow down. +/// The Gaussian blur radius in pixels (`0` for a hard-edged shadow). +/// +/// How far the shadow shape grows (positive) or shrinks (negative) from the border box before +/// blurring, in pixels. +/// +/// The shadow's color. +/// Whether the shadow is painted inside the border box (`inset`) rather than outside it. +public sealed record RenderBoxShadow( + float OffsetX, + float OffsetY, + float BlurRadius, + float SpreadRadius, + RenderColor Color, + bool Inset); + +/// +/// Describes a single `text-shadow` layer. Unlike , `text-shadow` +/// has no spread and no `inset` variant. +/// +/// Horizontal offset in pixels; positive moves the shadow right. +/// Vertical offset in pixels; positive moves the shadow down. +/// The Gaussian blur radius in pixels (`0` for a hard-edged shadow). +/// The shadow's color. +public sealed record RenderTextShadow( + float OffsetX, + float OffsetY, + float BlurRadius, + RenderColor Color); diff --git a/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs b/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs index 833c800..9079076 100644 --- a/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs +++ b/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs @@ -58,15 +58,113 @@ private static void DrawCommand(SKCanvas canvas, RenderCommand command, FontFace case StrokeRoundedRectCommand strokeRoundedRect: DrawStrokeRoundedRect(canvas, strokeRoundedRect); break; + case DrawBoxShadowCommand boxShadow: + DrawBoxShadow(canvas, boxShadow); + break; case DrawImageCommand image: DrawImage(canvas, image); break; case DrawTextCommand text: DrawText(canvas, text, fonts); break; + case DrawTextShadowCommand textShadow: + DrawTextShadow(canvas, textShadow, fonts); + break; } } + private static void DrawBoxShadow(SKCanvas canvas, DrawBoxShadowCommand command) + { + var shadow = command.Shadow; + + if (command.BorderBoxRect.IsEmpty || shadow.Color.A == 0) + { + return; + } + + var borderBoxRect = new SKRect( + command.BorderBoxRect.X, + command.BorderBoxRect.Y, + command.BorderBoxRect.X + command.BorderBoxRect.Width, + command.BorderBoxRect.Y + command.BorderBoxRect.Height); + + using var borderBoxRRect = new SKRoundRect(); + borderBoxRRect.SetRectRadii(borderBoxRect, ToSkPoints(command.BorderBoxRadii)); + + using var paint = new SKPaint + { + Color = ToSkColor(shadow.Color), + IsAntialias = true, + Style = SKPaintStyle.Fill, + }; + + if (shadow.BlurRadius > 0f) + { + // Browsers commonly derive the Gaussian sigma as half the CSS blur radius. + paint.MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, shadow.BlurRadius / 2f); + } + + canvas.Save(); + + if (shadow.Inset) + { + // Per spec, an inset shadow is clipped to the border box, then painted as the area + // between a rect well outside the box and the spread/offset inner rect - i.e. the + // "ring" that is inside the border box but outside the (shrunk, moved) inner shape. + canvas.ClipRoundRect(borderBoxRRect, SKClipOperation.Intersect, antialias: true); + + using var innerRRect = new SKRoundRect(borderBoxRRect); + ApplySpread(innerRRect, -shadow.SpreadRadius); + innerRRect.SetRectRadii(OffsetRect(innerRRect.Rect, shadow.OffsetX, shadow.OffsetY), innerRRect.Radii); + + // A generous fixed outset keeps the even-odd path's outer boundary outside the + // blurred region in every direction, regardless of blur radius, spread, or offset. + var outerPadding = Math.Abs(shadow.OffsetX) + Math.Abs(shadow.OffsetY) + shadow.BlurRadius + Math.Abs(shadow.SpreadRadius) + 32f; + var outerRect = SKRect.Inflate(borderBoxRect, outerPadding, outerPadding); + + using var path = new SKPath { FillType = SKPathFillType.EvenOdd }; + path.AddRect(outerRect, SKPathDirection.Clockwise); + path.AddRoundRect(innerRRect, SKPathDirection.Clockwise); + + canvas.DrawPath(path, paint); + } + else + { + // Clip OUT the border box's own shape so a transparent-background box does not show + // the shadow bleeding through its own interior, per spec. + canvas.ClipRoundRect(borderBoxRRect, SKClipOperation.Difference, antialias: true); + + using var shadowRRect = new SKRoundRect(borderBoxRRect); + ApplySpread(shadowRRect, shadow.SpreadRadius); + shadowRRect.SetRectRadii(OffsetRect(shadowRRect.Rect, shadow.OffsetX, shadow.OffsetY), shadowRRect.Radii); + + canvas.DrawRoundRect(shadowRRect, paint); + } + + canvas.Restore(); + } + + /// + /// Grows or shrinks a rounded rect by a `box-shadow` spread amount, which may be negative + /// (shrink) - routed to or + /// explicitly, since neither is verified to + /// accept a negative argument as "the other operation". + /// + private static void ApplySpread(SKRoundRect rrect, float spread) + { + if (spread >= 0f) + { + rrect.Inflate(spread, spread); + } + else + { + rrect.Deflate(-spread, -spread); + } + } + + private static SKRect OffsetRect(SKRect rect, float dx, float dy) => + new(rect.Left + dx, rect.Top + dy, rect.Right + dx, rect.Bottom + dy); + private static void DrawFillRect(SKCanvas canvas, FillRectCommand command) { if (command.Rect.IsEmpty) @@ -386,6 +484,28 @@ private static void DrawText(SKCanvas canvas, DrawTextCommand command, FontFaceS } } + private static void DrawTextShadow(SKCanvas canvas, DrawTextShadowCommand command, FontFaceSet fonts) + { + var font = new RenderFont( + command.FontFamily, + command.FontSize, + command.FontWeight, + command.IsItalic, + command.LetterSpacing, + fonts); + + using var paint = SkiaTextShaping.CreateTextPaint(font); + paint.Color = ToSkColor(command.Color); + + if (command.BlurRadius > 0f) + { + // Matches the same CSS-blur-radius-to-Gaussian-sigma approximation used for box-shadow. + paint.MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, command.BlurRadius / 2f); + } + + DrawTextWithLetterSpacing(canvas, paint, command.Text, command.X, command.Y, command.LetterSpacing); + } + private static void DrawDecorationLine(SKCanvas canvas, SKPaint paint, float x, float y, float width, RenderTextDecorationStyle style) { switch (style) From 636a2f979391fd7876e4ae0a428e0e9d9fee510b Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Mon, 7 Sep 2026 16:57:21 +0200 Subject: [PATCH 04/22] Added support for ol/ul lists --- AGENTS.md | 10 +- CHANGELOG.md | 2 + .../HtmlRendererTests.cs | 320 +++++++++++++++++- .../VisualConformanceTests.cs | 230 +++++++++++++ .../absolute-positioned-out-of-flow.macos.png | Bin 377 -> 400 bytes ...tyle-position-inside-and-outside.macos.png | Bin 0 -> 9325 bytes .../negative-z-index-behind-in-flow.macos.png | Bin 364 -> 372 bytes ...s-own-background-behind-own-text.macos.png | Bin 0 -> 438 bytes ...-list-with-independent-numbering.macos.png | Bin 0 -> 3741 bytes ...rdered-list-with-decimal-markers.macos.png | Bin 0 -> 3819 bytes ...uare-and-circle-list-style-types.macos.png | Bin 0 -> 2744 bytes ...list-items-with-background-color.macos.png | Bin 0 -> 4798 bytes ...unordered-list-with-disc-bullets.macos.png | Bin 0 -> 2179 bytes src/AngleSharp.Renderer/HtmlRenderer.cs | 265 ++++++++++++++- .../Rendering/DisplayList.cs | 15 + 15 files changed, 825 insertions(+), 17 deletions(-) create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/compares-list-style-position-inside-and-outside.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/paints-own-background-behind-own-text.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-nested-list-with-independent-numbering.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-ordered-list-with-decimal-markers.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-square-and-circle-list-style-types.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-styled-list-items-with-background-color.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-unordered-list-with-disc-bullets.macos.png diff --git a/AGENTS.md b/AGENTS.md index 31fd2d2..ae741ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,15 @@ Table spans: a cell covers the columns and rows it spans, and a spanning cell's `box-shadow` and `text-shadow`: both are parsed from AngleSharp.Css's `GetBoxShadow()`/`GetTextShadow()` computed-style accessors, which return every comma-separated layer already normalized with the color as `rgba(r, g, b, a)` regardless of how it was authored (`red`, `#f00`, ...) - `ExtractColorToken` (`HtmlRenderer.cs`) relies on that normalization to split a layer's color from its lengths by locating the `rgba(...)` call rather than naively splitting on whitespace, which the spaces after that function's commas would otherwise break. `text-shadow` is an inherited property and AngleSharp.Css already resolves that inheritance before this renderer sees it (a child with no `text-shadow` of its own reports its ancestor's value verbatim, and an explicit `text-shadow: none` reports the literal string `"none"`, not an empty one) - `ParseTextShadows` still takes an `inherited` fallback for defense in depth, mirroring how `color` and the other inherited text properties are already handled in `ResolveTextStyle`. `box-shadow` is not inherited, matching spec, so `ResolveBoxStyle` parses it directly with no inherited fallback. Multiple shadows paint back-to-front in reverse authoring order (`PaintBoxShadows`/`PaintTextShadows`), because CSS defines the first-listed shadow as topmost. A `box-shadow` layer is `RenderBoxShadow` (`Rendering/RenderShadow.cs`) - offset, blur, spread, color, and `inset` - painted via `DrawBoxShadowCommand`/`SkiaRenderBackend.DrawBoxShadow`, which reuses the same `RenderCornerRadii`/`SKRoundRect` machinery `border-radius` already established so a shadow follows the box's own corner rounding. An outset shadow is the border box's `SKRoundRect` grown by `SpreadRadius` (via `SKRoundRect.Inflate`/`Deflate` - verified functionally to adjust the rect *and* clamp each radius down to zero together, exactly matching the CSS spread semantics for free) and offset, blurred with `SKMaskFilter.CreateBlur`, then clipped with `SKClipOperation.Difference` against the *un-inflated* border box so the shadow never bleeds into the box's own interior even when its background is transparent - this clip-out is required by spec, not an approximation. An inset shadow is the mirror image: the border box shrunk by spread and offset, painted as the even-odd "ring" between that shrunk shape and a rect well outside the box, clipped with `SKClipOperation.Intersect` to the border box so it only shows inside. Both cases derive the Gaussian blur sigma as half the CSS blur radius (`shadow.BlurRadius / 2f`), the same approximation browsers commonly use; there is no dedicated conformance test pinning that constant, so treat it as adjustable if a real blurred-shadow comparison ever disagrees with it. `text-shadow` has no spread or inset, and paints as a second, blurred copy of the same text run (`DrawTextShadowCommand`/`SkiaRenderBackend.DrawTextShadow`) offset behind the real glyphs, shaped through the same `SkiaTextShaping` path as ordinary text so a shadow never drifts out of alignment with what it is shadowing. Painting order for a box is background, then `box-shadow` (all layers), then border, then outline - box-shadow must sit on top of the background (not behind it) even though it is visually "behind" the box's own content, because an inset shadow paints inside the box and would otherwise be hidden by an opaque background; this was a real bug caught by rendering the inset-shadow visual test and seeing no shadow at all, fixed by reordering `PaintBoxShadows` after `PaintBackground` at all three box-painting call sites. -Current behavior includes block layout, margins, padding, borders, floats, inline-block, relative/fixed/absolute positioning, z-index ordering, outlines, text styling, text alignment, line-height, letter-spacing, text-indent, vertical-align, border-radius, box-shadow, text-shadow, and generic font-family handling. +A box's own background/border/`box-shadow`/outline must paint *behind* its children, but an auto-sized box's height is only known once its children have been laid out (and their commands already appended to the display list) - so `LayoutElement`, `LayoutFlexContainer`, and `LayoutGridContainer` each capture `displayList.Commands.Count` as `boxPaintInsertIndex` before laying out their children/items, build the box's own paint commands into a scratch `DisplayList` buffer, and splice that buffer in at the captured index via `DisplayList.InsertRange` once the box's final size is known - rather than simply appending, which used to paint the box's own background *after*, and therefore on top of, its children. This was a real, confirmed bug (not hypothetical): a `
` with both a `background-color` and direct text content rendered the text completely invisible, and an absolutely-positioned or negative-`z-index` child was fully hidden behind its own containing block's background. Fixed by the splice approach and locked in by `BuildDisplayList_PaintsOwnBackgroundBehindOwnDirectTextContent`/`RenderToPng_PaintsOwnBackgroundBehindOwnDirectTextContent`; two pre-existing tests (`negative-z-index-behind-in-flow`/`absolute-positioned-out-of-flow` and their structural counterpart) had encoded the old, incorrect order as "expected" and were corrected to match CSS 2.1 Appendix E's actual stacking order (a stacking context's own background paints first, *then* its negative-z-index descendants, on top of it). + +`display: list-item` (`
    `/`
      `/`
    • ` and any element explicitly given this display value) renders a marker without any dedicated layout mode of its own - AngleSharp.Css's `ShouldRenderAsBlock` already treats `list-item` as block-level (the `_ => true` default case), so an `
    • ` flows through the ordinary block-flow path in `LayoutElement`; `PaintListItemMarker` is called once box geometry is known (before children are laid out - it doesn't depend on their size) and paints a bullet shape or number/letter text just like any other box content. `list-style-type` and `list-style-position` are read via `style.GetPropertyValue(...)` rather than a typed accessor (AngleSharp.Css has none for either), and both default to their CSS initial values (`disc`, `outside`) in this renderer's own code rather than relying on AngleSharp.Css to supply them: verified empirically that unlike `border-radius` or `box-shadow`, an *unset* `list-style-type` on a `
        ` (as opposed to `
          `, whose UA rule explicitly sets `decimal`) reports an empty string, not `disc` - the initial value is simply never serialized into the computed declaration when nothing in the cascade set it explicitly. `disc`/`circle`/`square` are shape markers reusing the exact `RenderCornerRadii`/`SKRoundRect` machinery `border-radius` already established - a `disc` is a `FillRect` with full corner radii (a circle), a `circle` is the `StrokeRoundedRect` equivalent (a ring), and a `square` is a plain zero-radius `FillRect` - so no new rasterization code was needed for markers at all. Every other keyword (`decimal`, `decimal-leading-zero`, `lower-alpha`/`upper-alpha`/`lower-latin`/`upper-latin`, `lower-roman`/`upper-roman`) is a text marker drawn with the ordinary `DrawText` command, formatted by `FormatListMarkerText`/`FormatAlphaListMarker`/`FormatRomanListMarker`; an unrecognized keyword falls back to `decimal`, and Roman numerals outside 1-3999 (which have no standard representation) fall back to a plain decimal number - both matching typical browser behavior for out-of-range/unsupported values. `list-style-type: none` suppresses the marker entirely. + +An ordered item's ordinal comes from `ResolveListItemOrdinal`, which walks the `
        1. `'s actual DOM parent's direct `
        2. ` children in document order (via plain `IElement.ParentElement`/`Children`, not the render tree, which can reorder for z-index/absolute positioning) - honoring `
            `, `
              `, and a per-`
            1. ` override exactly as browsers do (each subsequent item continues counting from an overridden value, in either direction). This is DOM-position counting, not a general CSS counter implementation (`counter-reset`/`counter-increment` are not implemented) - it happens to match the spec's built-in `list-item` counter behavior for the overwhelmingly common case (an `
                `/`
                  ` with direct `
                • ` children) without needing one. A nested list's own `
                • ` children are scoped to their own immediate parent list, so nesting numbers independently without any extra bookkeeping. Since this DOM walk is O(children) and re-run once per `
                • `, a list is O(n²) in child count - negligible for realistic list sizes, and avoided the alternative (threading a precomputed ordinal down through every `LayoutElement`/`LayoutNode` call signature, or an `AsyncLocal` ambient dictionary in the style of `s_layoutCapture`) for a simpler, self-contained implementation. + +Marker placement approximates rather than exactly replicates browser metrics, since AngleSharp.Css's UA stylesheet gives `
                    `/`
                      ` their default 40px indent via `margin-left` rather than `padding-left` as real browsers do (verified empirically) - there is no distinct padding "gutter" region to place an outside marker inside. For `list-style-position: outside` (the default), the marker's right edge sits a fixed 6px gutter gap before the `
                    1. `'s own border-box edge, vertically aligned to the first line's baseline - this fixed gap is a visual approximation, not derived from any spec metric, and works within AngleSharp.Css's 40px default margin for any font size in normal ranges. For `list-style-position: inside`, the marker is placed at the `
                    2. `'s own content edge and the *first line only* is indented to make room for it (reusing the exact mechanism `text-indent` already uses - `textIndentConsumed`/`RenderTextStyle.TextIndent` - so wrapped continuation lines fall back to the unindented content edge, not a hanging indent under the marker; this matches how `text-indent` itself behaves and is a defensible, common interpretation of the property, verified visually rather than assumed). Boosting `TextIndent` locally for the "inside" case is safe against leaking into nested descendants' own indent because `ResolveTextStyle` never inherits `TextIndent` - every element re-derives its own from its own style map, defaulting to 0 rather than falling back to the parent's value. A `
                    3. ` whose content starts with a nested block rather than inline text is a known, accepted simplification: the marker still paints at an approximated first-line baseline, but the "inside" indent boost has no effect on that nested block's own independent layout. + +Current behavior includes block layout, margins, padding, borders, floats, inline-block, relative/fixed/absolute positioning, z-index ordering, outlines, text styling, text alignment, line-height, letter-spacing, text-indent, vertical-align, border-radius, box-shadow, text-shadow, list-item markers, and generic font-family handling. SVG support: `` (including `data:` URIs) and inline `` markup both render, through two loading paths that converge on the same DOM-walking rasterizer (`Skia/Svg/`) - no third-party SVG parser is involved anywhere. An `` SVG source is sniffed and rasterized inside `TryLoadImageResource`, exactly where a PNG/JPEG source is decoded: `SvgRasterizer.TryRasterizeMarkup` parses the bytes with AngleSharp's own HTML/foreign-content parser (wrapped in a throwaway `` shell) and is cached per-document by URL like any other image. Inline `` has no URL and, more importantly, is already sitting in the host document's DOM - `SvgRasterizer.TryRasterizeElement` walks that element directly and is cached per-element in `s_inlineSvgCacheByElement`; it is never serialized back to text and re-parsed. `LayoutElement` empties `orderedChildren` for an `` root so its foreign-namespaced children are never walked as HTML flow content; both `` and inline `` are treated as a single replaced element. Rasterization always happens at the SVG's own natural (`viewBox`/`width`/`height`) size oversampled by a fixed factor (`SvgRasterizer.OversampleFactor`), not at the resolved CSS box size - the loader runs before CSS sizing is known, so this is a deliberate blur-vs-memory tradeoff rather than a per-render-size cache. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ecf205..68753c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ Released on Wednesday, September 9 2026. - Added support for rounded corners - Added support for `text-shadow` declarations - Added support for `box-shadow` declarations +- Added support for numeric sorted lists (e.g., using `ol`) +- Added support for bullet point lists (e.g., using `ul`) # 0.3.0 diff --git a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs index d0dbec6..5316a53 100644 --- a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs +++ b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs @@ -1641,7 +1641,7 @@ public async Task BuildDisplayList_PaintsHigherZIndexAfterLowerForPositionedOver } [Fact] - public async Task BuildDisplayList_PaintsNegativeZIndexBeforeInFlowBackground() + public async Task BuildDisplayList_PaintsNegativeZIndexOnTopOfOwnStackingContextBackground() { var document = await ParseAsync(""" @@ -1664,7 +1664,13 @@ public async Task BuildDisplayList_PaintsNegativeZIndexBeforeInFlowBackground() Assert.True(greenIndex >= 0); Assert.True(redIndex >= 0); - Assert.True(redIndex < greenIndex); + + // Per CSS 2.1 Appendix E, a stacking context's own background/border paints first, and + // its negative-z-index descendants paint immediately after (on top of) that background - + // not before/behind it. So the red z-index:-1 child paints after the green box's own + // background, even though it still paints before the green box's normal in-flow content + // (there is none here) and before any positive/zero-z-index descendants. + Assert.True(redIndex > greenIndex); } [Fact] @@ -2134,6 +2140,316 @@ public async Task BuildDisplayList_TextShadowNoneOverridesInheritedShadow() Assert.Empty(displayList.Commands.OfType()); } + [Fact] + public async Task BuildDisplayList_PaintsOwnBackgroundBehindOwnDirectTextContent() + { + // Regression test: an auto-height box's own background can only be sized once its + // children are measured, which used to mean it was appended to the display list (and so + // painted) AFTER its children's commands - silently hiding any direct text content behind + // an opaque background. The fix splices the box's own background/border/shadow/outline + // commands in before its children's once the box's final size is known. + var document = await ParseAsync(""" + +
                      Hi
                      + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + }); + + var commands = displayList.Commands.ToArray(); + var backgroundIndex = Array.FindIndex(commands, c => c is FillRectCommand fill && fill.Color.Equals(new RenderColor(255, 0, 0))); + var textIndex = Array.FindIndex(commands, c => c is DrawTextCommand text && text.Text == "Hi"); + + Assert.True(backgroundIndex >= 0); + Assert.True(textIndex >= 0); + Assert.True(backgroundIndex < textIndex); + } + + [Fact] + public async Task BuildDisplayList_RendersUnorderedListWithDiscMarkers() + { + var document = await ParseAsync(""" + +
                        +
                      • One
                      • +
                      • Two
                      • +
                      + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + FontSize = 16f, + }); + + // A disc marker is a small circular FillRectCommand (full corner radii), distinguishable + // from an ordinary square background fill. + var discMarkers = displayList.Commands.OfType() + .Where(f => !f.Radii.IsZero && f.Rect.Width < 10f) + .ToArray(); + Assert.Equal(2, discMarkers.Length); + + Assert.Contains(displayList.Commands, c => c is DrawTextCommand t && t.Text == "One"); + Assert.Contains(displayList.Commands, c => c is DrawTextCommand t && t.Text == "Two"); + } + + [Fact] + public async Task BuildDisplayList_RendersOrderedListWithDecimalMarkers() + { + var document = await ParseAsync(""" + +
                        +
                      1. First
                      2. +
                      3. Second
                      4. +
                      + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + FontSize = 16f, + }); + + var texts = displayList.Commands.OfType().ToArray(); + Assert.Contains(texts, t => t.Text == "1."); + Assert.Contains(texts, t => t.Text == "2."); + Assert.Contains(texts, t => t.Text == "First"); + Assert.Contains(texts, t => t.Text == "Second"); + + // The marker for "First" sits to the left of the "First" text it labels. + var marker = texts.Single(t => t.Text == "1."); + var content = texts.Single(t => t.Text == "First"); + Assert.True(marker.X < content.X); + } + + [Fact] + public async Task BuildDisplayList_RespectsOlStartAttribute() + { + var document = await ParseAsync(""" + +
                        +
                      1. a
                      2. +
                      3. b
                      4. +
                      + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + FontSize = 16f, + }); + + var texts = displayList.Commands.OfType().ToArray(); + Assert.Contains(texts, t => t.Text == "5."); + Assert.Contains(texts, t => t.Text == "6."); + } + + [Fact] + public async Task BuildDisplayList_RespectsLiValueAttributeOverride() + { + var document = await ParseAsync(""" + +
                        +
                      1. a
                      2. +
                      3. b
                      4. +
                      5. c
                      6. +
                      + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + FontSize = 16f, + }); + + var texts = displayList.Commands.OfType().ToArray(); + Assert.Contains(texts, t => t.Text == "1."); + Assert.Contains(texts, t => t.Text == "10."); + Assert.Contains(texts, t => t.Text == "11."); + } + + [Fact] + public async Task BuildDisplayList_RespectsOlReversedAttribute() + { + var document = await ParseAsync(""" + +
                        +
                      1. a
                      2. +
                      3. b
                      4. +
                      5. c
                      6. +
                      + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + FontSize = 16f, + }); + + var texts = displayList.Commands.OfType().ToArray(); + Assert.Contains(texts, t => t.Text == "3."); + Assert.Contains(texts, t => t.Text == "2."); + Assert.Contains(texts, t => t.Text == "1."); + } + + [Fact] + public async Task BuildDisplayList_RendersLowerRomanMarkers() + { + var document = await ParseAsync(""" + +
                        +
                      1. a
                      2. +
                      3. b
                      4. +
                      5. c
                      6. +
                      7. d
                      8. +
                      + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + FontSize = 16f, + }); + + var texts = displayList.Commands.OfType().ToArray(); + Assert.Contains(texts, t => t.Text == "i."); + Assert.Contains(texts, t => t.Text == "ii."); + Assert.Contains(texts, t => t.Text == "iii."); + Assert.Contains(texts, t => t.Text == "iv."); + } + + [Fact] + public async Task BuildDisplayList_RendersUpperAlphaMarkers() + { + var document = await ParseAsync(""" + +
                        +
                      1. a
                      2. +
                      3. b
                      4. +
                      5. c
                      6. +
                      + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + FontSize = 16f, + }); + + var texts = displayList.Commands.OfType().ToArray(); + Assert.Contains(texts, t => t.Text == "A."); + Assert.Contains(texts, t => t.Text == "B."); + Assert.Contains(texts, t => t.Text == "C."); + } + + [Fact] + public async Task BuildDisplayList_SuppressesMarkerWhenListStyleTypeIsNone() + { + var document = await ParseAsync(""" + +
                        +
                      • One
                      • +
                      + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + FontSize = 16f, + }); + + Assert.Empty(displayList.Commands.OfType().Where(f => !f.Radii.IsZero && f.Rect.Width < 10f)); + Assert.Contains(displayList.Commands, c => c is DrawTextCommand t && t.Text == "One"); + } + + [Fact] + public async Task BuildDisplayList_NestedListsNumberIndependently() + { + var document = await ParseAsync(""" + +
                        +
                      1. a +
                          +
                        1. nested
                        2. +
                        +
                      2. +
                      3. b
                      4. +
                      + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + FontSize = 16f, + }); + + var texts = displayList.Commands.OfType().Select(t => t.Text).ToArray(); + + // Two "1." markers: the outer list's first item and the (independently-numbered) nested + // list's own first item. The outer list's second item is "2.", unaffected by nesting. + Assert.Equal(2, texts.Count(t => t == "1.")); + Assert.Contains("2.", texts); + } + + [Fact] + public async Task BuildDisplayList_ListStylePositionInsideStartsMarkerAtContentEdge() + { + var document = await ParseAsync(""" + +
                        +
                      • One
                      • +
                      + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + FontSize = 16f, + }); + + var marker = displayList.Commands.OfType().Single(f => !f.Radii.IsZero && f.Rect.Width < 10f); + var text = displayList.Commands.OfType().Single(t => t.Text == "One"); + + // "inside" starts the marker at the li's own content edge and pushes the text to make + // room for it, rather than placing the marker in the gutter to the left of that edge. + Assert.True(text.X > marker.Rect.X); + } + private static async Task ParseAsync(string html, IConfiguration? configuration = null, string? address = null) { var context = BrowsingContext.New(configuration ?? Configuration.Default.WithCss()); diff --git a/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs b/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs index 92f3750..51aca5f 100644 --- a/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs +++ b/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs @@ -1701,6 +1701,236 @@ public async Task RenderToPng_PaintsMultipleTextShadowsLayered() maxDifferentPixels: TextRenderingToleranceMaxPixels); } + [Fact] + public async Task RenderToPng_PaintsOwnBackgroundBehindOwnDirectTextContent() + { + var document = await ParseAsync(""" + + + + + +
                      Hi
                      + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 120, + ViewPortHeight = 60, + FontSize = 16f, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "paints-own-background-behind-own-text.png", + actualPng: image.Data, + perChannelTolerance: TextRenderingToleranceChannel, + maxDifferentPixels: TextRenderingToleranceMaxPixels); + } + + [Fact] + public async Task RenderToPng_RendersUnorderedListWithDiscBullets() + { + var document = await ParseAsync(""" + + + + + +
                        +
                      • One
                      • +
                      • Two
                      • +
                      + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 160, + ViewPortHeight = 90, + FontSize = 16f, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-unordered-list-with-disc-bullets.png", + actualPng: image.Data, + perChannelTolerance: TextRenderingToleranceChannel, + maxDifferentPixels: TextRenderingToleranceMaxPixels); + } + + [Fact] + public async Task RenderToPng_RendersOrderedListWithDecimalMarkers() + { + var document = await ParseAsync(""" + + + + + +
                        +
                      1. First
                      2. +
                      3. Second
                      4. +
                      5. Third
                      6. +
                      + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 160, + ViewPortHeight = 110, + FontSize = 16f, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-ordered-list-with-decimal-markers.png", + actualPng: image.Data, + perChannelTolerance: TextRenderingToleranceChannel, + maxDifferentPixels: TextRenderingToleranceMaxPixels); + } + + [Fact] + public async Task RenderToPng_RendersSquareAndCircleListStyleTypes() + { + var document = await ParseAsync(""" + + + + + +
                        +
                      • Square
                      • +
                      +
                        +
                      • Circle
                      • +
                      + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 160, + ViewPortHeight = 90, + FontSize = 16f, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-square-and-circle-list-style-types.png", + actualPng: image.Data, + perChannelTolerance: TextRenderingToleranceChannel, + maxDifferentPixels: TextRenderingToleranceMaxPixels); + } + + [Fact] + public async Task RenderToPng_RendersNestedListWithIndependentNumbering() + { + var document = await ParseAsync(""" + + + + + +
                        +
                      1. Parent +
                          +
                        1. Child
                        2. +
                        +
                      2. +
                      3. Sibling
                      4. +
                      + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 180, + ViewPortHeight = 130, + FontSize = 16f, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-nested-list-with-independent-numbering.png", + actualPng: image.Data, + perChannelTolerance: TextRenderingToleranceChannel, + maxDifferentPixels: TextRenderingToleranceMaxPixels); + } + + [Fact] + public async Task RenderToPng_RendersStyledListItemsWithBackgroundColor() + { + var document = await ParseAsync(""" + + + + + +
                        +
                      • Styled item one
                      • +
                      • Styled item two
                      • +
                      + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 220, + ViewPortHeight = 100, + FontSize = 16f, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-styled-list-items-with-background-color.png", + actualPng: image.Data, + perChannelTolerance: TextRenderingToleranceChannel, + maxDifferentPixels: TextRenderingToleranceMaxPixels); + } + + [Fact] + public async Task RenderToPng_ComparesListStylePositionInsideAndOutside() + { + var document = await ParseAsync(""" + + + + + +
                        +
                      • Outside position keeps the marker in the gutter
                      • +
                      +
                        +
                      • Inside position puts the marker on the first line
                      • +
                      + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 160, + ViewPortHeight = 280, + FontSize = 16f, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "compares-list-style-position-inside-and-outside.png", + actualPng: image.Data, + perChannelTolerance: TextRenderingToleranceChannel, + maxDifferentPixels: TextRenderingToleranceMaxPixels); + } + private static async Task RenderCanvasSnapshotAsync(string html, Action draw) { var context = BrowsingContext.New(Configuration.Default.WithCss().WithRendering()); diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/absolute-positioned-out-of-flow.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/absolute-positioned-out-of-flow.macos.png index 9e1558dc9f1e898c17ef57dc61424f61e9539bc3..a4b6888fcb6e11a19604960356fb8248ca2a706c 100644 GIT binary patch delta 194 zcmey#G=X`7Nxi$Li(^Q|oVT|R@-{gLI0PD)Z1@uYIEranLdPTZk9(~X`Rt2lzOj04 zBbJxk6WISDxW9_w-)sg?&IwaM#Fesd%^Tl`pZR#L_}r8IF{jy7R2_nt(23GtLXVUV zn%`_#!s6l8u;f~*W!_)4C-*LY)MS*N%*ZIpcIDY6hm&j*^He6*iC`dpv9C;mH3EH> SlY5>r0D-5gpUXO@geCyH)=#hi literal 377 zcmeAS@N?(olHy`uVBq!ia0vp^3xGII2`FOphHQ)78&q Iol`;+0OWjZjsO4v diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/compares-list-style-position-inside-and-outside.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/compares-list-style-position-inside-and-outside.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..65acd10339662db81abbc039d72f225d2391001f GIT binary patch literal 9325 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 UWO=c{^FxTDteQ-bl<~9w0G5QVuK)l5 literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/negative-z-index-behind-in-flow.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/negative-z-index-behind-in-flow.macos.png index 9831accf837887a2eb8f654e68b43f6a9a1f27e8..34dfc5d8993f4a6f775e13beb21bb4d6c82814df 100644 GIT binary patch literal 372 zcmeAS@N?(olHy`uVBq!ia0vp^3xGI?qAFJPR${RNktWv z*t*9fU=yP!=Y%N?L=$T-*ZV)ZpMz=;nz@6FtH1m?$FVcFX2t=7k-^i|&t;ucLK6Uf C!dv(N literal 364 zcmeAS@N?(olHy`uVBq!ia0vp^3xGIzopr0LL{}FaQ7m diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/paints-own-background-behind-own-text.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/paints-own-background-behind-own-text.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..a931876d5aaa222987306928fce45ea92f81040d GIT binary patch literal 438 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 G9QhwgYG$zj literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-unordered-list-with-disc-bullets.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-unordered-list-with-disc-bullets.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..63e5e0cfa9306b2e41cec0dcad704d74ae9f9bc7 GIT binary patch literal 2179 zcmbVOc{JPG8a>nyW3RblYHN6?AzD=ECERdPuVz(Mlu}}N^U&R+){VI~$1Flao2-NOR_)3-nyJ46+(PPm(APs?$y?`~MzUZW}} z<6iqbx6BphuTd22Hu7$0;O9kOq9X8WOK+saa3{Hb_$VQBA$aZ`^Bf7gE0$_C6B?hK z>!sn{pD$O;*zNA$+1so~l1IM}+&peUjSqwsZ5GN04;C32sN(VMx#z3Vkyv7YG)hU7 zJAp$g}x7p_m%=3?DO;;?d}$WoY<(eB2p_!{OJ2Z zGCMo_F77Y_mz?Z5SoABRpue>FPjQqG%P~YLo5$PR8fb~3R})uu5q`c8FjgjbsGrHzeZz{cG@eeTb6ZqIXl?#k_@rKK<|!`j$5VK7cw zkh{`Bl^s=HUaqaB#cXP=ueS|ahVDKakNqCZCQvf$y9JX2cB(H7cQ2`{D}q00%dOv3 zG&GbzIBVzvWu+sLJ10Ni(yT1As=8W5MI{Afn3cUgI5@b)kd%@t`~2DR>yK~+3t>|; zGdnjo+1AIW@plt_43Gc8zDG`pQq?08iEhhVJ3F5PDXtn9FILsoDgjYRNejRlulLF+ zEL5ZUV=$rqoj<~}Zd7EG<&dE`Z8+iRWP6!*dUD+(h#lT;U2ND7|Co~_T6}1cg$6x7 zq-f$?C}n=<;P5cTS04*{JnMTN{+w+79VjD_bRr=&l{Z>kMppLamxBC!82AxRHfD1DxaMs&UadJzQoonKqRU?-;8+ zy}cc4lg+1y; ztzlFcCg+{1rAQ*w)c6Gi1bX`WyUVY?T4xQ*|4~ul)t|2emEPX@^eh`72Thu|x=uVv zw|8@U{{6d;wyy3hgB+|EwSGRwQZYbRO4cYoEg^xeC3bhfz0(slQ;a*}E9f^vB9XG> zkgTk%YqhQ^cnk&u1Cwo!r@a`X$E|rQ5e5s3i?PzbmD}0gxnd2w0X2LmGfoN%}@|A>s*ye}y$e zC~IkH#V;xH;jr8`b+kD;>3)#6qobp;hQ`j@=AcRa`Gp0_i@bgxXEpJ+V;BVmg%5$Z znlOiuo~T=k)=s|!gE*4Y8I5Tdo@=h zBO~S2)de0td>FKFf1uFV#Ra}L)w=BD=Io5GsZq1Cvg+|}i$DIfzqy#1kpYrS6883@ z3JoF${HN(gy!Zao+K=Vo;Ry@sr19@OeVySR?D7`1ok= zf~F@8(-?lR<@XiSxG}qvkdVOnyf9j1ND`r?B?L76%`c`a01B>kNI5;_(^S#*so1Dzmww--k%ub$HkqutqEG(c1gd zg4%F_xcv=C=}@ZPh0sjP@{1N(z5xMgc)XdJS<4AGSH*}*uoN8bGQn1h__Ah!cdF(D z@87>KsjE9H{)`iP8Sr>K)~w9<`gOjTm>3T-BQrDef~Jj}oE$fTf{*)Nx;-=F5gl^) zbGq$}Uj9NbofDy;z`>Qs7SSxDqoV_`@84^K_K$Ig9`()-4tD|pDkg^7;_mExPQl_y zCv*LS>eYh&2YS*O3OrJ}0kA%l6W&__1$z4WI1sJoaS$=Un?yZF!%d#F4iA7p#-8{s z9Z$8!N>p@S%43CL_w&CHSVcv$`-SRPuGnjwZeG*8eEF-FQT3Q=u%@bNDFnHVt!<|3 z4zu&AspN=d)4Wf6rKa@W!E8rUAfv>t5?Bo;x6hYRE7K>B4|ii%Bt%5ckm&pbvqUOY zUsP1Iq_$SjAYujEd+*aX;_YLkD3_bW%GmDD>ANKQFJfXz1gWd3YXc2`S*SWC-J_}H h!QV8}|J#I*&z5jyzvQ5+EkeHvU}1U#U2Ed;|{pSDx literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer/HtmlRenderer.cs b/src/AngleSharp.Renderer/HtmlRenderer.cs index bbe8585..c16cfb8 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.cs @@ -549,6 +549,18 @@ private static void LayoutElement( var contentX = borderBoxX + borderLeft + paddingLeft; var contentY = borderBoxY + borderTop + paddingTop; + // The box's own background/border/shadow/outline must paint behind its children, but an + // auto-sized box's height is only known after its children are laid out (and therefore + // appended to the display list) - so their commands are built into a scratch buffer here + // and spliced in before this index once the box's final size is known, rather than simply + // appended (which would paint them on top of - and hide - the children). + var boxPaintInsertIndex = displayList.Commands.Count; + + if (string.Equals(display, "list-item", StringComparison.OrdinalIgnoreCase)) + { + currentTextStyle = PaintListItemMarker(displayList, element, styleMap, currentTextStyle, context, borderBoxX, contentX, contentY); + } + var childCursorY = contentY; var childPreviousBlockMarginBottom = 0f; var childSuppressNextBlockTopMargin = collapseWithFirstChild && !float.Equals(effectiveMarginTop, marginTop); @@ -823,10 +835,12 @@ private static void LayoutElement( paddingTop, paddingBottom); - PaintBackground(displayList, box.BackgroundPaint, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderRadius); - PaintBoxShadows(displayList, box.BoxShadows, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderRadius); - PaintBorder(displayList, box.BorderColor, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderWidth, box.BorderRadius); - PaintOutline(displayList, styleMap, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight); + var boxPaintBuffer = new DisplayList(); + PaintBackground(boxPaintBuffer, box.BackgroundPaint, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderRadius); + PaintBoxShadows(boxPaintBuffer, box.BoxShadows, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderRadius); + PaintBorder(boxPaintBuffer, box.BorderColor, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderWidth, box.BorderRadius); + PaintOutline(boxPaintBuffer, styleMap, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight); + displayList.InsertRange(boxPaintInsertIndex, boxPaintBuffer.Commands); if (TryResolveReplacedElementImage(node, styleMap, flowContainingWidth, borderBoxX + borderLeft + paddingLeft, borderBoxY + borderTop + paddingTop, out var image, out var imageRect)) { @@ -896,6 +910,11 @@ private static void LayoutFlexContainer( float borderBoxX, float borderBoxY) { + // 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 + // (and appended), so their paint commands are spliced in before this index instead. + var boxPaintInsertIndex = displayList.Commands.Count; + var flexDirection = GetFlexDirection(styleMap); var isRowDirection = !string.Equals(flexDirection, "column", StringComparison.OrdinalIgnoreCase) && !string.Equals(flexDirection, "column-reverse", StringComparison.OrdinalIgnoreCase); var isReverseDirection = string.Equals(flexDirection, "row-reverse", StringComparison.OrdinalIgnoreCase) || string.Equals(flexDirection, "column-reverse", StringComparison.OrdinalIgnoreCase); @@ -1163,16 +1182,18 @@ private static void LayoutFlexContainer( effectiveMarginBottom = CollapseMargins(effectiveMarginBottom, childPreviousBlockMarginBottom); } + var boxPaintBuffer = new DisplayList(); + if (box.BackgroundPaint is RenderColorPaint colorPaint && colorPaint.Color.A == 0) { - displayList.FillRect(new RenderRect(borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight), RenderColor.Transparent); + boxPaintBuffer.FillRect(new RenderRect(borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight), RenderColor.Transparent); } else { - PaintBackground(displayList, box.BackgroundPaint, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderRadius); + PaintBackground(boxPaintBuffer, box.BackgroundPaint, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderRadius); } - PaintBoxShadows(displayList, box.BoxShadows, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderRadius); + PaintBoxShadows(boxPaintBuffer, box.BoxShadows, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderRadius); RecordLayoutMetrics( node.Ref, @@ -1189,8 +1210,9 @@ private static void LayoutFlexContainer( paddingTop, paddingBottom); - PaintBorder(displayList, box.BorderColor, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderWidth, box.BorderRadius); - PaintOutline(displayList, styleMap, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight); + PaintBorder(boxPaintBuffer, box.BorderColor, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderWidth, box.BorderRadius); + PaintOutline(boxPaintBuffer, styleMap, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight); + displayList.InsertRange(boxPaintInsertIndex, boxPaintBuffer.Commands); if (TryResolveReplacedElementImage(node, styleMap, containingWidth, borderBoxX + borderLeft + paddingLeft, borderBoxY + borderTop + paddingTop, out var image, out var imageRect)) { @@ -1656,6 +1678,11 @@ private static void LayoutGridContainer( float borderBoxX, float borderBoxY) { + // 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 + // (and appended), so their paint commands are spliced in before this index instead. + var boxPaintInsertIndex = displayList.Commands.Count; + var columns = ParseGridTrackList(styleMap, "grid-template-columns", containingWidth, 1); var columnGap = ParseGridGap(styleMap, "column-gap", containingWidth, 0) ?? ParseGridGap(styleMap, "gap", containingWidth, 0); @@ -1779,16 +1806,18 @@ private static void LayoutGridContainer( effectiveMarginBottom = CollapseMargins(effectiveMarginBottom, previousBlockMarginBottom); } + var boxPaintBuffer = new DisplayList(); + if (box.BackgroundPaint is RenderColorPaint colorPaint && colorPaint.Color.A == 0) { - displayList.FillRect(new RenderRect(borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight), RenderColor.Transparent); + boxPaintBuffer.FillRect(new RenderRect(borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight), RenderColor.Transparent); } else { - PaintBackground(displayList, box.BackgroundPaint, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderRadius); + PaintBackground(boxPaintBuffer, box.BackgroundPaint, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderRadius); } - PaintBoxShadows(displayList, box.BoxShadows, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderRadius); + PaintBoxShadows(boxPaintBuffer, box.BoxShadows, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderRadius); RecordLayoutMetrics( node.Ref, @@ -1805,8 +1834,9 @@ private static void LayoutGridContainer( paddingTop, paddingBottom); - PaintBorder(displayList, box.BorderColor, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderWidth, box.BorderRadius); - PaintOutline(displayList, styleMap, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight); + PaintBorder(boxPaintBuffer, box.BorderColor, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderWidth, box.BorderRadius); + PaintOutline(boxPaintBuffer, styleMap, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight); + displayList.InsertRange(boxPaintInsertIndex, boxPaintBuffer.Commands); cursorY = flowBorderBoxY + borderBoxHeight; previousBlockMarginBottom = effectiveMarginBottom + context.ParagraphSpacing; @@ -2703,6 +2733,9 @@ private static Dictionary CreateStyleMap(ICssStyleDeclaration st AddIfPresent(map, "box-shadow", style.GetBoxShadow()); AddIfPresent(map, "text-shadow", style.GetTextShadow()); + AddIfPresent(map, "list-style-type", style.GetPropertyValue("list-style-type")); + AddIfPresent(map, "list-style-position", style.GetPropertyValue("list-style-position")); + AddIfPresent(map, "outline-width", style.GetPropertyValue("outline-width")); AddIfPresent(map, "outline-style", style.GetPropertyValue("outline-style")); AddIfPresent(map, "outline-color", style.GetPropertyValue("outline-color")); @@ -3514,6 +3547,210 @@ private static bool TryParseDataUri(string? source, out byte[]? bytes, out strin return bytes.Length > 0; } + ///

                      + /// Paints a `display: list-item` element's marker (bullet or number) and, for + /// `list-style-position: inside`, returns a copy of whose + /// is widened to make room for it on the first line. + /// Safe to widen locally: never falls back to an inherited + /// TextIndent (each element re-derives its own from its own style map, defaulting to + /// 0), so this local adjustment cannot leak into nested descendants' own indent. + /// + private static RenderTextStyle PaintListItemMarker( + DisplayList displayList, + IElement element, + Dictionary styleMap, + RenderTextStyle textStyle, + LayoutContext context, + float borderBoxX, + float contentX, + float contentY) + { + var listStyleType = ResolveListStyleType(styleMap); + + if (string.Equals(listStyleType, "none", StringComparison.OrdinalIgnoreCase)) + { + return textStyle; + } + + var isInside = styleMap.TryGetValue("list-style-position", out var positionValue) && + string.Equals(positionValue.Trim(), "inside", StringComparison.OrdinalIgnoreCase); + + // Aligns the marker with the first line of the li's own content, assuming that content + // starts as normal inline text at the default vertical-align - the common case, but an + // approximation when a li's first child is itself a block (its own first line may sit at + // a different offset than this). + var markerBaselineY = contentY + (textStyle.FontSize * textStyle.LineHeightMultiplier) + textStyle.VerticalAlignOffset; + + // Not derived from any spec metric - a small, fixed visual gap between the marker and the + // content that follows it, matching the general proportions browsers use. + const float MarkerGap = 6f; + + if (IsShapeListStyleType(listStyleType)) + { + var markerSize = Math.Max(2f, textStyle.FontSize * 0.35f); + var markerTop = markerBaselineY - (textStyle.FontSize * 0.68f); + var markerLeft = isInside ? contentX : borderBoxX - MarkerGap - markerSize; + var markerRect = new RenderRect(markerLeft, markerTop, markerSize, markerSize); + var circularRadii = new RenderCornerRadii( + markerSize / 2f, markerSize / 2f, markerSize / 2f, markerSize / 2f, + markerSize / 2f, markerSize / 2f, markerSize / 2f, markerSize / 2f); + + switch (listStyleType) + { + case "circle": + displayList.StrokeRoundedRect(markerRect, textStyle.Color, Math.Max(1f, textStyle.FontSize * 0.08f), circularRadii); + break; + case "square": + displayList.FillRect(markerRect, textStyle.Color); + break; + default: // disc + displayList.FillRect(markerRect, textStyle.Color, circularRadii); + break; + } + + return isInside ? textStyle with { TextIndent = textStyle.TextIndent + markerSize + MarkerGap } : textStyle; + } + + var markerText = FormatListMarkerText(ResolveListItemOrdinal(element), listStyleType); + var markerWidth = MeasureTextWidth(context, markerText, textStyle); + var markerX = isInside ? contentX : borderBoxX - MarkerGap - markerWidth; + + displayList.DrawText(markerText, markerX, markerBaselineY, textStyle.Color, textStyle.FontSize, textStyle.FontFamily, textStyle.FontWeight, textStyle.IsItalic, letterSpacing: textStyle.LetterSpacing); + + return isInside ? textStyle with { TextIndent = textStyle.TextIndent + markerWidth + MarkerGap } : textStyle; + } + + private static string ResolveListStyleType(Dictionary styleMap) => + styleMap.TryGetValue("list-style-type", out var value) && !string.IsNullOrWhiteSpace(value) + ? value.Trim().ToLowerInvariant() + : "disc"; + + private static bool IsShapeListStyleType(string listStyleType) => + listStyleType is "disc" or "circle" or "square"; + + /// + /// Formats an ordinal into the marker text for a numbered `list-style-type`. Any keyword this + /// renderer does not specifically implement (and plain `decimal`) falls back to a decimal + /// number, matching how browsers treat an unsupported `list-style-type` value. + /// + private static string FormatListMarkerText(int ordinal, string listStyleType) => listStyleType switch + { + "decimal-leading-zero" => (ordinal is >= 0 and < 10 ? "0" + ordinal.ToString(CultureInfo.InvariantCulture) : ordinal.ToString(CultureInfo.InvariantCulture)) + ".", + "lower-alpha" or "lower-latin" => FormatAlphaListMarker(ordinal, upper: false) + ".", + "upper-alpha" or "upper-latin" => FormatAlphaListMarker(ordinal, upper: true) + ".", + "lower-roman" => FormatRomanListMarker(ordinal, upper: false) + ".", + "upper-roman" => FormatRomanListMarker(ordinal, upper: true) + ".", + _ => ordinal.ToString(CultureInfo.InvariantCulture) + ".", + }; + + /// + /// Formats a 1-based ordinal as a base-26 letter sequence (a, b, ..., z, aa, ab, ...), + /// matching `list-style-type: lower-alpha`/`upper-alpha`. + /// + private static string FormatAlphaListMarker(int ordinal, bool upper) + { + if (ordinal < 1) + { + return ordinal.ToString(CultureInfo.InvariantCulture); + } + + var baseChar = upper ? 'A' : 'a'; + var chars = new Stack(); + var remaining = ordinal; + + while (remaining > 0) + { + remaining--; + chars.Push((char)(baseChar + (remaining % 26))); + remaining /= 26; + } + + return new string(chars.ToArray()); + } + + private static readonly (int Value, string Symbol)[] s_romanNumeralValues = + [ + (1000, "M"), (900, "CM"), (500, "D"), (400, "CD"), + (100, "C"), (90, "XC"), (50, "L"), (40, "XL"), + (10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I"), + ]; + + /// + /// Formats an ordinal as a Roman numeral, matching `list-style-type: lower-roman`/`upper-roman`. + /// Roman numerals have no standard representation outside 1-3999, so values outside that range + /// fall back to a plain decimal number, matching typical browser behavior. + /// + private static string FormatRomanListMarker(int ordinal, bool upper) + { + if (ordinal is < 1 or > 3999) + { + return ordinal.ToString(CultureInfo.InvariantCulture); + } + + var builder = new StringBuilder(); + var remaining = ordinal; + + foreach (var (value, symbol) in s_romanNumeralValues) + { + while (remaining >= value) + { + builder.Append(symbol); + remaining -= value; + } + } + + var result = builder.ToString(); + return upper ? result : result.ToLowerInvariant(); + } + + /// + /// Resolves a `<li>`'s 1-based ordinal from its position among its parent's direct + /// `<li>` children in document order - not from a render-tree traversal order, which + /// can reorder for z-index/absolute positioning, and not from any generic CSS counter, since + /// only the common `<ol>`/`<li>` counting model (`start`, `reversed`, per-item + /// `value`) is implemented. A `<li>` outside any `<ol>`/`<ul>` still resolves + /// to 1, matching how browsers render a bare `<li>`. + /// + private static int ResolveListItemOrdinal(IElement liElement) + { + var parent = liElement.ParentElement; + + if (parent is null) + { + return 1; + } + + var isOrderedList = string.Equals(parent.LocalName, "ol", StringComparison.OrdinalIgnoreCase); + var reversed = isOrderedList && parent.HasAttribute("reversed"); + var counter = isOrderedList && int.TryParse(parent.GetAttribute("start"), NumberStyles.Integer, CultureInfo.InvariantCulture, out var explicitStart) + ? explicitStart + : reversed + ? parent.Children.Count(c => string.Equals(c.LocalName, "li", StringComparison.OrdinalIgnoreCase)) + : 1; + + foreach (var child in parent.Children) + { + if (!string.Equals(child.LocalName, "li", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (int.TryParse(child.GetAttribute("value"), NumberStyles.Integer, CultureInfo.InvariantCulture, out var overrideValue)) + { + counter = overrideValue; + } + + if (ReferenceEquals(child, liElement)) + { + return counter; + } + + counter += reversed ? -1 : 1; + } + + return counter; + } + private static void PaintTextShadows(DisplayList displayList, IReadOnlyList shadows, string text, float x, float y, RenderTextStyle textStyle) { if (shadows.Count == 0 || string.IsNullOrEmpty(text)) diff --git a/src/AngleSharp.Renderer/Rendering/DisplayList.cs b/src/AngleSharp.Renderer/Rendering/DisplayList.cs index 97cb4d4..ef5dbff 100644 --- a/src/AngleSharp.Renderer/Rendering/DisplayList.cs +++ b/src/AngleSharp.Renderer/Rendering/DisplayList.cs @@ -29,6 +29,21 @@ public void Add(RenderCommand command) _commands.Add(command); } + /// + /// Splices commands into the list at a specific index, shifting everything from that index + /// onward later. Used to place a box's own background/border/shadow/outline commands before + /// its children's commands once the box's size is known - an auto-sized box cannot paint its + /// own background until its children have been laid out to measure it, but CSS requires that + /// background to paint *behind* those children, not on top of them. + /// + /// The position to insert at; 0 is the very start of the list. + /// The commands to insert, in order. + public void InsertRange(int index, IEnumerable commands) + { + ArgumentNullException.ThrowIfNull(commands); + _commands.InsertRange(index, commands); + } + /// /// Adds a filled rectangle command. /// From 25ed434b84431fd5df52d6e91c52c7bad22cd2d1 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Mon, 7 Sep 2026 17:20:02 +0200 Subject: [PATCH 05/22] Improved clipping support --- AGENTS.md | 8 +- CHANGELOG.md | 1 + .../HtmlRendererTests.cs | 183 ++++++++++++++++++ .../VisualConformanceTests.cs | 127 ++++++++++++ ...s-overflow-hidden-content-to-box.macos.png | Bin 0 -> 323 bytes ...ersized-normal-flow-child-to-box.macos.png | Bin 0 -> 365 bytes ...rounded-shape-with-border-radius.macos.png | Bin 0 -> 1101 bytes ...ot-clip-overflow-visible-content.macos.png | Bin 0 -> 331 bytes src/AngleSharp.Renderer/HtmlRenderer.cs | 88 +++++++++ .../Rendering/DisplayList.cs | 23 +++ .../Skia/SkiaRenderBackend.cs | 28 +++ 11 files changed, 457 insertions(+), 1 deletion(-) create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/clips-overflow-hidden-content-to-box.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/clips-oversized-normal-flow-child-to-box.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/clips-to-rounded-shape-with-border-radius.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/does-not-clip-overflow-visible-content.macos.png diff --git a/AGENTS.md b/AGENTS.md index ae741ab..31562a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,7 +64,13 @@ An ordered item's ordinal comes from `ResolveListItemOrdinal`, which walks the ` Marker placement approximates rather than exactly replicates browser metrics, since AngleSharp.Css's UA stylesheet gives `
                        `/`
                          ` their default 40px indent via `margin-left` rather than `padding-left` as real browsers do (verified empirically) - there is no distinct padding "gutter" region to place an outside marker inside. For `list-style-position: outside` (the default), the marker's right edge sits a fixed 6px gutter gap before the `
                        1. `'s own border-box edge, vertically aligned to the first line's baseline - this fixed gap is a visual approximation, not derived from any spec metric, and works within AngleSharp.Css's 40px default margin for any font size in normal ranges. For `list-style-position: inside`, the marker is placed at the `
                        2. `'s own content edge and the *first line only* is indented to make room for it (reusing the exact mechanism `text-indent` already uses - `textIndentConsumed`/`RenderTextStyle.TextIndent` - so wrapped continuation lines fall back to the unindented content edge, not a hanging indent under the marker; this matches how `text-indent` itself behaves and is a defensible, common interpretation of the property, verified visually rather than assumed). Boosting `TextIndent` locally for the "inside" case is safe against leaking into nested descendants' own indent because `ResolveTextStyle` never inherits `TextIndent` - every element re-derives its own from its own style map, defaulting to 0 rather than falling back to the parent's value. A `
                        3. ` whose content starts with a nested block rather than inline text is a known, accepted simplification: the marker still paints at an approximated first-line baseline, but the "inside" indent boost has no effect on that nested block's own independent layout. -Current behavior includes block layout, margins, padding, borders, floats, inline-block, relative/fixed/absolute positioning, z-index ordering, outlines, text styling, text alignment, line-height, letter-spacing, text-indent, vertical-align, border-radius, box-shadow, text-shadow, list-item markers, and generic font-family handling. +`overflow`/`overflow-x`/`overflow-y` clip a box's content at the padding edge when the resolved value is `hidden`, `scroll`, or `auto` (`visible`, the default, does not clip). `scroll` and `auto` are treated identically to `hidden` - a rendered PNG has no scrollbars or interactivity, so anything a browser would let the user scroll to reveal is simply clipped away here, same as `hidden`; `overflow: clip` is not recognized by the AngleSharp.Css version this repo builds against (verified empirically - the whole declaration is dropped, leaving the property unset) and so is not distinguished from `visible`, a known upstream limitation rather than a gap in this renderer's own parsing. `ResolveOverflowAxis` also handles a second, verified AngleSharp.Css quirk: the `overflow` shorthand does not decompose into `overflow-x`/`overflow-y` in the computed style the way `border-color` or other shorthands do, so the shorthand and longhands never coexist in the computed declaration - the longhand is preferred when present, falling back to the shorthand, rather than needing to resolve cascade precedence between two values that are never actually both set. Clipping is a single rectangle covering both axes together whenever *either* axis requests it - there is no independent per-axis clip (e.g. `overflow-x: hidden; overflow-y: visible` still clips vertically too), a deliberate simplification. + +Because `DisplayList` is a flat, backend-agnostic command sequence with no grouping/nesting structure, clipping an arbitrary range of already-emitted commands (a box's entire subtree) needed a new primitive rather than reusing anything from `border-radius`/`box-shadow`: `PushClipCommand`/`PopClipCommand` (`DisplayList.PushClip`/`PopClip`) bracket a scope the way `SKCanvas.Save()`/`ClipRect`-or-`ClipRoundRect`/`Restore()` naturally already work in `SkiaRenderBackend` - `PushClip` pushes and clips, `PopClip` simply restores. `LayoutElement`, `LayoutFlexContainer`, and `LayoutGridContainer` each compute whether their own element clips overflow, then reuse the exact same `boxPaintInsertIndex`/scratch-`DisplayList`-buffer splice mechanism `border-radius`/`box-shadow` background-ordering already established: `PushClip` is appended to the scratch buffer *after* that buffer's border/outline (so border and outline themselves are never clipped, matching spec) and the whole buffer is spliced in before the children's already-appended commands; `PopClip` is appended for real (not spliced) once children - and, for a replaced element, its image - have all been emitted, so the clip scope closes after everything that should be inside it. The clip shape reuses the box's own (unmodified) `RenderCornerRadii`, so `overflow: hidden` on a box with `border-radius` clips to that same rounded shape for free - the classic "rounded image container" pattern - via the identical `SKRoundRect` clipping `border-radius`/`box-shadow` already rely on. + +Vertical overflow clipping cannot be demonstrated with ordinary normal-flow content (text or a plain nested block) in this renderer: an auto-sized box's content height is computed as `Math.Max(specifiedHeight, autoContentHeight)` (see `LayoutElement`/`LayoutFlexContainer`/`LayoutGridContainer`), so a normal-flow child taller than a box's specified `height` simply grows that box to fit it rather than overflowing it - `height` behaves like a floor, not a cap, for in-flow content. Genuine overflow that clipping can act on comes from content whose size or position is *not* folded into that auto-height growth: an absolutely/fixed-positioned descendant (the common case - decorative or cropped imagery positioned outside its container's normal flow), or a normal-flow child that is simply *wider* than its container (block width is not grown to fit children the way height is, so horizontal overflow is real and gets clipped). The visual tests deliberately cover both. + +Current behavior includes block layout, margins, padding, borders, floats, inline-block, relative/fixed/absolute positioning, z-index ordering, outlines, text styling, text alignment, line-height, letter-spacing, text-indent, vertical-align, border-radius, box-shadow, text-shadow, list-item markers, overflow clipping, and generic font-family handling. SVG support: `` (including `data:` URIs) and inline `` markup both render, through two loading paths that converge on the same DOM-walking rasterizer (`Skia/Svg/`) - no third-party SVG parser is involved anywhere. An `` SVG source is sniffed and rasterized inside `TryLoadImageResource`, exactly where a PNG/JPEG source is decoded: `SvgRasterizer.TryRasterizeMarkup` parses the bytes with AngleSharp's own HTML/foreign-content parser (wrapped in a throwaway `` shell) and is cached per-document by URL like any other image. Inline `` has no URL and, more importantly, is already sitting in the host document's DOM - `SvgRasterizer.TryRasterizeElement` walks that element directly and is cached per-element in `s_inlineSvgCacheByElement`; it is never serialized back to text and re-parsed. `LayoutElement` empties `orderedChildren` for an `` root so its foreign-namespaced children are never walked as HTML flow content; both `` and inline `` are treated as a single replaced element. Rasterization always happens at the SVG's own natural (`viewBox`/`width`/`height`) size oversampled by a fixed factor (`SvgRasterizer.OversampleFactor`), not at the resolved CSS box size - the loader runs before CSS sizing is known, so this is a deliberate blur-vs-memory tradeoff rather than a per-render-size cache. diff --git a/CHANGELOG.md b/CHANGELOG.md index 68753c1..851da12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ Released on Wednesday, September 9 2026. +- Improved support for clipping and `overflow` declarations - Added support for rounded corners - Added support for `text-shadow` declarations - Added support for `box-shadow` declarations diff --git a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs index 5316a53..267b415 100644 --- a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs +++ b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs @@ -2450,6 +2450,189 @@ public async Task BuildDisplayList_ListStylePositionInsideStartsMarkerAtContentE Assert.True(text.X > marker.Rect.X); } + [Fact] + public async Task BuildDisplayList_DoesNotClipWhenOverflowIsVisible() + { + var document = await ParseAsync(""" + +

                          Hi

                          + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + }); + + Assert.Empty(displayList.Commands.OfType()); + Assert.Empty(displayList.Commands.OfType()); + } + + [Fact] + public async Task BuildDisplayList_PushesClipToPaddingBoxWhenOverflowHidden() + { + var document = await ParseAsync(""" + +

                          Hi

                          + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + }); + + var push = Assert.Single(displayList.Commands.OfType()); + Assert.Single(displayList.Commands.OfType()); + + // The border box is 100 + 2*5 (border) + 2*3 (padding) = 116 wide; the clip region is the + // padding box, which excludes the border but includes the padding: border box minus the + // 5px border on each side. + Assert.Equal(5f, push.Rect.X); + Assert.Equal(5f, push.Rect.Y); + Assert.Equal(106f, push.Rect.Width); + Assert.Equal(56f, push.Rect.Height); + } + + [Theory] + [InlineData("scroll")] + [InlineData("auto")] + [InlineData("hidden")] + public async Task BuildDisplayList_ClipsForScrollAutoAndHiddenOverflow(string overflowValue) + { + var document = await ParseAsync($$""" + +

                          Hi

                          + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + }); + + Assert.Single(displayList.Commands.OfType()); + Assert.Single(displayList.Commands.OfType()); + } + + [Fact] + public async Task BuildDisplayList_ClipsWhenEitherOverflowAxisIsHidden() + { + var document = await ParseAsync(""" + +

                          Hi

                          + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + }); + + // This renderer clips with a single rectangle covering both axes together whenever either + // axis requests clipping - it does not clip one axis while leaving the other open. + Assert.Single(displayList.Commands.OfType()); + } + + [Fact] + public async Task BuildDisplayList_ClipWrapsChildContentBetweenPushAndPop() + { + var document = await ParseAsync(""" + +

                          Hi

                          + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + }); + + var commands = displayList.Commands.ToArray(); + var pushIndex = Array.FindIndex(commands, c => c is PushClipCommand); + var popIndex = Array.FindIndex(commands, c => c is PopClipCommand); + var textIndex = Array.FindIndex(commands, c => c is DrawTextCommand t && t.Text == "Hi"); + + Assert.True(pushIndex >= 0); + Assert.True(popIndex > pushIndex); + Assert.True(textIndex > pushIndex && textIndex < popIndex); + } + + [Fact] + public async Task BuildDisplayList_ClipShapeFollowsBorderRadius() + { + var document = await ParseAsync(""" + +

                          Hi

                          + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + }); + + var push = Assert.Single(displayList.Commands.OfType()); + Assert.False(push.Radii.IsZero); + Assert.Equal(12f, push.Radii.TopLeftX); + } + + [Fact] + public async Task BuildDisplayList_ClipsFlexContainerToOwnBounds() + { + var document = await ParseAsync(""" + +
                          +
                          Hi
                          +
                          + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + }); + + Assert.Single(displayList.Commands.OfType()); + Assert.Single(displayList.Commands.OfType()); + } + + [Fact] + public async Task BuildDisplayList_ClipsGridContainerToOwnBounds() + { + var document = await ParseAsync(""" + +
                          +
                          Hi
                          +
                          + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + }); + + Assert.Single(displayList.Commands.OfType()); + Assert.Single(displayList.Commands.OfType()); + } + private static async Task ParseAsync(string html, IConfiguration? configuration = null, string? address = null) { var context = BrowsingContext.New(configuration ?? Configuration.Default.WithCss()); diff --git a/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs b/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs index 51aca5f..b54670f 100644 --- a/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs +++ b/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs @@ -1931,6 +1931,133 @@ public async Task RenderToPng_ComparesListStylePositionInsideAndOutside() maxDifferentPixels: TextRenderingToleranceMaxPixels); } + [Fact] + public async Task RenderToPng_ClipsOverflowHiddenContentToBox() + { + var document = await ParseAsync(""" + + + + + +
                          +
                          +
                          + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 80, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "clips-overflow-hidden-content-to-box.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_DoesNotClipOverflowVisibleContent() + { + var document = await ParseAsync(""" + + + + + +
                          +
                          +
                          + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 80, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "does-not-clip-overflow-visible-content.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_ClipsToRoundedShapeWithBorderRadius() + { + var document = await ParseAsync(""" + + + + + +
                          +
                          +
                          + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "clips-to-rounded-shape-with-border-radius.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_ClipsOversizedNormalFlowChildToBox() + { + // A child's own explicit width is not constrained by its parent's - a plain, normal-flow + // (non-absolute) child can be wider than its container, which is a common real-world + // overflow trigger (an oversized image, a wide table, ...), distinct from the + // position:absolute escape the other tests here use. The child is deliberately no taller + // than the parent's own height, since this renderer's auto content height always grows to + // fit a normal-flow child vertically (height behaves like a floor, not a cap) - so a + // taller child would just grow the parent instead of overflowing it. + var document = await ParseAsync(""" + + + + + +
                          +
                          +
                          + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "clips-oversized-normal-flow-child-to-box.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + private static async Task RenderCanvasSnapshotAsync(string html, Action draw) { var context = BrowsingContext.New(Configuration.Default.WithCss().WithRendering()); diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/clips-overflow-hidden-content-to-box.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/clips-overflow-hidden-content-to-box.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..2048dbb59e2f0f7a3eaf683a3a05ff5deaf8615a GIT binary patch literal 323 zcmeAS@N?(olHy`uVBq!ia0vp^DL@>+!3HGXb6$84q*#ibJVQ8upoSx*1IYj7>Eakt zG3V`VL$1RH0t^Q&o(O;X|9@HfiPBjQlscE)SjlS@u}u%6 zn9-B~XT+7;&nf;_)VE(U`HkGe#bzwQ0zQrgtOrF9jN;$R{^dTl`7YCAubAPCDu<~W pE>iqu{;s9G$OgfsC}x~tz1_|GESqJ^E?_7yc)I$ztaD0e0ssh4V<`Xt literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/clips-oversized-normal-flow-child-to-box.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/clips-oversized-normal-flow-child-to-box.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..3a49f087fa451ae3c26091ad9ae751cf96a58d68 GIT binary patch literal 365 zcmeAS@N?(olHy`uVBq!ia0vp^DImEakt zG3V`VL#`GB0f#{4xTN>>=HDeY-t&t%+P^1!xx^2j$rB8p&*7GQeVO@#_XG*YB$iG= z6*m|o&APUzNBa1V1KxTvIUYR&~=F|oMfA@QC@t?P!zc7ezRK%j~u>Dst hX(Egw#yX1Bu%BJV+h2LZCKDKb44$rjF6*2UngEi^bmjm6 literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/clips-to-rounded-shape-with-border-radius.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/clips-to-rounded-shape-with-border-radius.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..74c09db9da18235535d000e72e3bd9e094cf09c7 GIT binary patch literal 1101 zcmeAS@N?(olHy`uVBq!ia0vp^DIm|BZ5X$ca#l4%q@Uvn4Gu|G#l;FKA}u{l69NT{xSZ7T9<~3g*x&R1 z{kezVUs~PiV&DE~;kGL`#hG5N3T%ldA=QTcHD;`9lpkL2_V z{mFj6h~39WG|ZQWq2h&E!G{>e?s;!LZ*deo%i=Wuw{GT)qpPy(uNFMZI{8iFV3UZp zoZs`e{JE#Z!@Xwc-QMfN#A;c$v*YKn&mXUDlP&(L=JzLPi&gV`h0WZ)bN2qd@U~f|AO+avzysCl2Qr-^6lR~=bRFYs`p=&jcT0dYW`ZT_d zH#Ny(tA6|U__yv?{>5g^ydb7|{`%}6Ht~nT&HpWpTT)Z+!3HGXb6$84q*#ibJVQ8upoSx*gMopO$@(QGf9{6C@mySULq& y+z2uxZ~qIHPd>fvc17@ie>98XE})3 CreateStyleMap(ICssStyleDeclaration st AddIfPresent(map, "list-style-type", style.GetPropertyValue("list-style-type")); AddIfPresent(map, "list-style-position", style.GetPropertyValue("list-style-position")); + AddIfPresent(map, "overflow", style.GetPropertyValue("overflow")); + AddIfPresent(map, "overflow-x", style.GetPropertyValue("overflow-x")); + AddIfPresent(map, "overflow-y", style.GetPropertyValue("overflow-y")); + AddIfPresent(map, "outline-width", style.GetPropertyValue("outline-width")); AddIfPresent(map, "outline-style", style.GetPropertyValue("outline-style")); AddIfPresent(map, "outline-color", style.GetPropertyValue("outline-color")); @@ -3620,6 +3682,32 @@ private static RenderTextStyle PaintListItemMarker( return isInside ? textStyle with { TextIndent = textStyle.TextIndent + markerWidth + MarkerGap } : textStyle; } + /// + /// Whether an element's `overflow` clips its content. `scroll` and `auto` are treated the + /// same as `hidden`: a rendered PNG has no scrollbars or interactivity, so anything a browser + /// would let the user scroll to reveal is, here, simply clipped away like `hidden`. + /// + private static bool ShouldClipOverflow(Dictionary styleMap) => + IsClippingOverflowValue(ResolveOverflowAxis(styleMap, "overflow-x")) || + IsClippingOverflowValue(ResolveOverflowAxis(styleMap, "overflow-y")); + + /// + /// Resolves one overflow axis. AngleSharp.Css's `overflow` shorthand does not decompose into + /// `overflow-x`/`overflow-y` in its computed style (verified empirically) - unlike a real + /// cascade, the longhand and shorthand never coexist in the computed declaration here, so the + /// longhand is preferred when both happen to be present and the shorthand is used as a + /// fallback, rather than needing to resolve cascade precedence between them. + /// + private static string ResolveOverflowAxis(Dictionary styleMap, string longhandProperty) => + styleMap.TryGetValue(longhandProperty, out var axisValue) && !string.IsNullOrWhiteSpace(axisValue) + ? axisValue.Trim().ToLowerInvariant() + : styleMap.TryGetValue("overflow", out var shorthandValue) && !string.IsNullOrWhiteSpace(shorthandValue) + ? shorthandValue.Trim().ToLowerInvariant() + : "visible"; + + private static bool IsClippingOverflowValue(string overflowValue) => + overflowValue is "hidden" or "scroll" or "auto"; + private static string ResolveListStyleType(Dictionary styleMap) => styleMap.TryGetValue("list-style-type", out var value) && !string.IsNullOrWhiteSpace(value) ? value.Trim().ToLowerInvariant() diff --git a/src/AngleSharp.Renderer/Rendering/DisplayList.cs b/src/AngleSharp.Renderer/Rendering/DisplayList.cs index ef5dbff..bc60ccc 100644 --- a/src/AngleSharp.Renderer/Rendering/DisplayList.cs +++ b/src/AngleSharp.Renderer/Rendering/DisplayList.cs @@ -44,6 +44,18 @@ public void InsertRange(int index, IEnumerable commands) _commands.InsertRange(index, commands); } + /// + /// Begins clipping every command up to the matching to a rectangle + /// (`overflow: hidden`/`scroll`/`auto`). Pairs must nest like a stack, matching how a backend + /// implements this as a save/clip/restore scope. + /// + public void PushClip(RenderRect rect, RenderCornerRadii radii) => Add(new PushClipCommand(rect, radii)); + + /// + /// Ends the clip scope started by the matching . + /// + public void PopClip() => Add(new PopClipCommand()); + /// /// Adds a filled rectangle command. /// @@ -169,6 +181,17 @@ public sealed record StrokeRoundedRectCommand(RenderRect Rect, RenderColor Color ///
public sealed record DrawBoxShadowCommand(RenderRect BorderBoxRect, RenderCornerRadii BorderBoxRadii, RenderBoxShadow Shadow) : RenderCommand; +/// +/// Begins clipping every following command, up to the matching , to +/// (`overflow: hidden`/`scroll`/`auto`). +/// +public sealed record PushClipCommand(RenderRect Rect, RenderCornerRadii Radii) : RenderCommand; + +/// +/// Ends the clip scope started by the matching . +/// +public sealed record PopClipCommand : RenderCommand; + /// /// Draws a single line of text at a baseline position. /// diff --git a/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs b/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs index 9079076..47cffb3 100644 --- a/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs +++ b/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs @@ -70,6 +70,34 @@ private static void DrawCommand(SKCanvas canvas, RenderCommand command, FontFace case DrawTextShadowCommand textShadow: DrawTextShadow(canvas, textShadow, fonts); break; + case PushClipCommand pushClip: + PushClip(canvas, pushClip); + break; + case PopClipCommand: + canvas.Restore(); + break; + } + } + + private static void PushClip(SKCanvas canvas, PushClipCommand command) + { + canvas.Save(); + + var rect = new SKRect( + command.Rect.X, + command.Rect.Y, + command.Rect.X + command.Rect.Width, + command.Rect.Y + command.Rect.Height); + + if (command.Radii.IsZero) + { + canvas.ClipRect(rect, SKClipOperation.Intersect, antialias: true); + } + else + { + using var roundRect = new SKRoundRect(); + roundRect.SetRectRadii(rect, ToSkPoints(command.Radii)); + canvas.ClipRoundRect(roundRect, SKClipOperation.Intersect, antialias: true); } } From 88a5378c984f835e009390b849074e157fdc7f41 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Mon, 7 Sep 2026 17:36:30 +0200 Subject: [PATCH 06/22] Fixed scrolling of content --- AGENTS.md | 6 +- CHANGELOG.md | 1 + .../HtmlRendererTests.cs | 93 ++++++++++++++++++ .../VisualConformanceTests.cs | 85 +++++++++++++++- ...arbitrary-settable-scroll-offset.macos.png | Bin 0 -> 279 bytes ...en-scroll-offset-is-large-enough.macos.png | Bin 0 -> 274 bytes ...top-of-tall-page-when-unscrolled.macos.png | Bin 0 -> 273 bytes src/AngleSharp.Renderer/HtmlRenderer.cs | 80 +++++++++++++-- .../InteractiveHtmlRendererStateExtensions.cs | 15 +++ 9 files changed, 270 insertions(+), 10 deletions(-) create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/paints-middle-of-tall-page-at-an-arbitrary-settable-scroll-offset.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/paints-only-bottom-of-tall-page-when-scroll-offset-is-large-enough.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/paints-top-of-tall-page-when-unscrolled.macos.png diff --git a/AGENTS.md b/AGENTS.md index 31562a4..05ba6d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,7 +70,11 @@ Because `DisplayList` is a flat, backend-agnostic command sequence with no group Vertical overflow clipping cannot be demonstrated with ordinary normal-flow content (text or a plain nested block) in this renderer: an auto-sized box's content height is computed as `Math.Max(specifiedHeight, autoContentHeight)` (see `LayoutElement`/`LayoutFlexContainer`/`LayoutGridContainer`), so a normal-flow child taller than a box's specified `height` simply grows that box to fit it rather than overflowing it - `height` behaves like a floor, not a cap, for in-flow content. Genuine overflow that clipping can act on comes from content whose size or position is *not* folded into that auto-height growth: an absolutely/fixed-positioned descendant (the common case - decorative or cropped imagery positioned outside its container's normal flow), or a normal-flow child that is simply *wider* than its container (block width is not grown to fit children the way height is, so horizontal overflow is real and gets clipped). The visual tests deliberately cover both. -Current behavior includes block layout, margins, padding, borders, floats, inline-block, relative/fixed/absolute positioning, z-index ordering, outlines, text styling, text alignment, line-height, letter-spacing, text-indent, vertical-align, border-radius, box-shadow, text-shadow, list-item markers, overflow clipping, and generic font-family handling. +Page-level scrolling: `HtmlRenderer.RenderToPng`/`BuildDisplayList` (the public overloads) honor `document.documentElement`'s scroll position when an interactive `IDomHarness` already exists for the document's browsing context (created via `IBrowsingContext.GetDomHarness()` - typically through `IDomHarness.PaintToPng()` or by calling any of the CSSOM-view scroll APIs `ElementCssomViewExtensions.cs` already exposed for other purposes: `scrollTop`, `scrollTo`, `scrollIntoView`, ...). This was previously a real, confirmed gap, not a hypothetical one: `scrollTop`/`scrollTo` were fully implemented and tested for CSSOM-view purposes (`getBoundingClientRect`, `scrollHeight`, ...), but nothing in the rendering path ever consulted them, so scrolling an element had zero effect on `RenderToPng` output. `ResolveRootScrollOffsetY` (`HtmlRenderer.cs`) looks up a harness *without* creating one (`IBrowsingContext.TryGetDomHarness`, added alongside the existing `GetDomHarness`) - `GetDomHarness` auto-creates on first access but throws when no `IRenderDevice` service is registered, which the vast majority of `RenderToPng` callers (including nearly this entire test suite) never do, so unconditionally calling it would break plain, non-interactive rendering entirely. Finding no harness (the common case) resolves to a scroll offset of 0, leaving rendering exactly as before. The offset is applied as a single float shift to the top-level layout's starting Y position in `BuildDisplayList` - a scrolled page is laid out exactly like an unscrolled one, just starting from a (possibly negative) Y - and needs no explicit clip, since Skia's surface is a fixed `viewport.Width x viewport.Height` raster that simply never has pixels to write for content positioned outside those bounds. + +Critically, `HtmlRenderer.CaptureLayoutMetrics` (backing `getBoundingClientRect`/`GetScrollExtents`/hover-hit-testing) deliberately never applies this offset (it does not call `ResolveRootScrollOffsetY` at all) - `scrollHeight` and max-scroll clamping are scroll-*independent* intrinsic measurements, and letting them see a scrolled layout would create a self-referential bug where setting a scroll position depends on measurements that were themselves computed at the very scroll position being set. This is also why `document.documentElement`/`document.body` needed a second, narrower change: neither is ever laid out as a box of its own in this renderer (the top-level loop in `BuildDisplayList` only ever lays out *their children* directly onto the page canvas), so neither previously appeared in the layout-metrics map `GetScrollExtents` reads at all, making `document.documentElement.scrollHeight` (and therefore max-scroll) permanently 0 regardless of content height - confirmed by a failing test before the fix, not assumed. `BuildDisplayList` now synthesizes one `RecordLayoutMetrics` call for `document.Body`/`document.documentElement` sized to the *viewport's client area* (not the total content height - the existing `GetScrollExtents` descendant-walk already computes true scrollable height correctly from each individual child's own, normally-recorded metrics, exactly as it already does for any other scrollable element; recording the full content height here directly instead would make the synthesized element's own client and scroll heights identical, so nothing would ever look scrollable). This recording is a no-op outside of `CaptureLayoutMetrics`, since `RecordLayoutMetrics` itself only does anything while that capture's `AsyncLocal` is active. A related, pre-existing performance optimization (layout stops early once content runs past the visible viewport bottom) had to become conditional too: normal painting still wants that early exit, but metrics capture needs the *full* page laid out regardless of viewport height to measure a true scrollable extent, so `BuildDisplayList` takes a `measureFullExtent` parameter that widens its cutoff to `float.MaxValue` only for that call path. + +Current behavior includes block layout, margins, padding, borders, floats, inline-block, relative/fixed/absolute positioning, z-index ordering, outlines, text styling, text alignment, line-height, letter-spacing, text-indent, vertical-align, border-radius, box-shadow, text-shadow, list-item markers, overflow clipping, page-level scroll offsets, and generic font-family handling. SVG support: `` (including `data:` URIs) and inline `` markup both render, through two loading paths that converge on the same DOM-walking rasterizer (`Skia/Svg/`) - no third-party SVG parser is involved anywhere. An `` SVG source is sniffed and rasterized inside `TryLoadImageResource`, exactly where a PNG/JPEG source is decoded: `SvgRasterizer.TryRasterizeMarkup` parses the bytes with AngleSharp's own HTML/foreign-content parser (wrapped in a throwaway `` shell) and is cached per-document by URL like any other image. Inline `` has no URL and, more importantly, is already sitting in the host document's DOM - `SvgRasterizer.TryRasterizeElement` walks that element directly and is cached per-element in `s_inlineSvgCacheByElement`; it is never serialized back to text and re-parsed. `LayoutElement` empties `orderedChildren` for an `` root so its foreign-namespaced children are never walked as HTML flow content; both `` and inline `` are treated as a single replaced element. Rasterization always happens at the SVG's own natural (`viewBox`/`width`/`height`) size oversampled by a fixed factor (`SvgRasterizer.OversampleFactor`), not at the resolved CSS box size - the loader runs before CSS sizing is known, so this is a deliberate blur-vs-memory tradeoff rather than a per-render-size cache. diff --git a/CHANGELOG.md b/CHANGELOG.md index 851da12..83ea8ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ Released on Wednesday, September 9 2026. - Improved support for clipping and `overflow` declarations +- Fixed painting of scrolled content - Added support for rounded corners - Added support for `text-shadow` declarations - Added support for `box-shadow` declarations diff --git a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs index 267b415..64d4c98 100644 --- a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs +++ b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs @@ -2633,6 +2633,99 @@ public async Task BuildDisplayList_ClipsGridContainerToOwnBounds() Assert.Single(displayList.Commands.OfType()); } + [Fact] + public async Task BuildDisplayList_AppliesRootScrollOffsetToPaintedContent() + { + var renderDevice = new DefaultRenderDevice + { + ViewPortWidth = 200, + ViewPortHeight = 80, + DeviceWidth = 200, + DeviceHeight = 80, + FontSize = 16, + }; + var configuration = Configuration.Default.WithCss().WithRenderDevice(renderDevice); + var document = await ParseAsync(""" + + + +
+ + + """, configuration); + + var renderer = new HtmlRenderer(); + var unscrolled = renderer.BuildDisplayList(document, renderDevice); + var unscrolledFill = Assert.Single(unscrolled.Commands.OfType().Where(f => f.Color.Equals(new RenderColor(255, 0, 0)))); + + document.Context.GetDomHarness(); + document.DocumentElement.SetScrollTop(40); + + var scrolled = renderer.BuildDisplayList(document, renderDevice); + var scrolledFill = Assert.Single(scrolled.Commands.OfType().Where(f => f.Color.Equals(new RenderColor(255, 0, 0)))); + + Assert.Equal(unscrolledFill.Rect.Y - 40f, scrolledFill.Rect.Y); + } + + [Fact] + public async Task BuildDisplayList_ClampsRootScrollOffsetToScrollableExtent() + { + var renderDevice = new DefaultRenderDevice + { + ViewPortWidth = 200, + ViewPortHeight = 80, + DeviceWidth = 200, + DeviceHeight = 80, + FontSize = 16, + }; + var configuration = Configuration.Default.WithCss().WithRenderDevice(renderDevice); + var document = await ParseAsync(""" + + + +
+ + + """, configuration); + + document.Context.GetDomHarness(); + document.DocumentElement.SetScrollTop(100_000); + + var maxScrollTop = document.DocumentElement.GetScrollTop(); + Assert.True(maxScrollTop > 0); + Assert.True(maxScrollTop < 100_000); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, renderDevice); + var fill = Assert.Single(displayList.Commands.OfType().Where(f => f.Color.Equals(new RenderColor(255, 0, 0)))); + + // Clamped to the true scrollable extent, not the (much larger) value that was set. + Assert.Equal(-(float)maxScrollTop, fill.Rect.Y); + } + + [Fact] + public async Task BuildDisplayList_WithoutHarness_IgnoresScrollAndRendersUnaffected() + { + // No IRenderDevice service is registered here, so no DOM harness can even exist for this + // context - confirms rendering is completely unaffected (and does not throw) for the + // overwhelmingly common case of a document that was never wired up for interactive use. + var document = await ParseAsync(""" + +
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 200, + }); + + var fill = Assert.Single(displayList.Commands.OfType().Where(f => f.Color.Equals(new RenderColor(0, 255, 0)))); + Assert.Equal(0f, fill.Rect.Y); + } + private static async Task ParseAsync(string html, IConfiguration? configuration = null, string? address = null) { var context = BrowsingContext.New(configuration ?? Configuration.Default.WithCss()); diff --git a/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs b/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs index b54670f..d7cb9fe 100644 --- a/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs +++ b/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs @@ -2,6 +2,7 @@ namespace AngleSharp.Renderer.Tests; using AngleSharp; using AngleSharp.Css; +using AngleSharp.Dom; using AngleSharp.Html.Dom; [Trait("Category", "Visual")] @@ -2073,9 +2074,89 @@ private static async Task RenderCanvasSnapshotAsync(string html, Action< return canvasContext.ToImage("image/png"); } - private static async Task ParseAsync(string html) + [Fact] + public async Task RenderToPng_PaintsTopOfTallPageWhenUnscrolled() + { + var image = await RenderScrolledTallPageAsync(scrollTop: null, viewportHeight: 80); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "paints-top-of-tall-page-when-unscrolled.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_PaintsOnlyBottomOfTallPageWhenScrollOffsetIsLargeEnough() + { + // The page is 5 stacked 40px bands (200px total) in an 80px-tall viewport - scrolling by + // 120px moves past the first three bands entirely, so only the last two (green, blue) + // should paint, shifted up to fill the viewport exactly as the unscrolled page's first two + // bands (red, orange) do in the sibling test above. + var image = await RenderScrolledTallPageAsync(scrollTop: 120, viewportHeight: 80); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "paints-only-bottom-of-tall-page-when-scroll-offset-is-large-enough.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_PaintsMiddleOfTallPageAtAnArbitrarySettableScrollOffset() + { + // A scroll offset that isn't aligned to a band boundary (100px lands 20px into the third + // band, yellow) proves the offset is a continuously "settable" pixel value, not just a + // switch between a fixed top/bottom view - the viewport should show the bottom 20px of + // yellow, all 40px of green, then the top 20px of blue. 100px is also comfortably within + // the page's actual 120px max scroll (200px content - 80px viewport), unlike a larger + // value that would just clamp to the same view as the "scrolled past the top" test above. + var image = await RenderScrolledTallPageAsync(scrollTop: 100, viewportHeight: 80); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "paints-middle-of-tall-page-at-an-arbitrary-settable-scroll-offset.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + private static async Task RenderScrolledTallPageAsync(double? scrollTop, int viewportHeight) + { + var renderDevice = new DefaultRenderDevice + { + ViewPortWidth = 80, + ViewPortHeight = viewportHeight, + DeviceWidth = 80, + DeviceHeight = viewportHeight, + FontSize = 16, + }; + var configuration = Configuration.Default.WithCss().WithRenderDevice(renderDevice); + var document = await ParseAsync(""" + + + +
+
+
+
+
+ + + """, configuration); + + if (scrollTop.HasValue) + { + document.Context.GetDomHarness(); + document.DocumentElement.SetScrollTop(scrollTop.Value); + } + + var renderer = new HtmlRenderer(); + return renderer.RenderToPng(document, renderDevice); + } + + private static async Task ParseAsync(string html, IConfiguration? configuration = null) { - var context = BrowsingContext.New(Configuration.Default.WithCss()); + var context = BrowsingContext.New(configuration ?? Configuration.Default.WithCss()); return await context.OpenAsync(request => request.Content(html)); } } \ No newline at end of file diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/paints-middle-of-tall-page-at-an-arbitrary-settable-scroll-offset.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/paints-middle-of-tall-page-at-an-arbitrary-settable-scroll-offset.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..ae09a7e7eb9d944282273d7ff316a3d063eba55c GIT binary patch literal 279 zcmeAS@N?(olHy`uVBq!ia0vp^0U*r51|<6gKdl8)EX7WqAsj$Z!;#Vf&>sES0%^`Uh-kR_Uhg}<7~0{vCH{?STiaUgo~@~m;3)P=k8&l nBK#KhxI8SOabg2uc#*p*g)PW%=J%~YpD}p4`njxgN@xNA-GWv> literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/paints-only-bottom-of-tall-page-when-scroll-offset-is-large-enough.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/paints-only-bottom-of-tall-page-when-scroll-offset-is-large-enough.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..b71e7bfe598cd6c4bd1cd7b7ff396065de147c85 GIT binary patch literal 274 zcmeAS@N?(olHy`uVBq!ia0vp^0U*r51|<6gKdl8)EX7WqAsj$Z!;#Vf#V|tu6V`>Sj?;6X+B ag7pI*OTd$o1TCPa7(8A5T-G@yGywn%l~#}d literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/paints-top-of-tall-page-when-unscrolled.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/paints-top-of-tall-page-when-unscrolled.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..9633231684b803129356734b06ad02393e1fab10 GIT binary patch literal 273 zcmeAS@N?(olHy`uVBq!ia0vp^0U*r51|<6gKdl8)EX7WqAsj$Z!;#Vf CaptureLayou try { - _ = BuildDisplayList(document, viewport, context, renderDevice); + _ = BuildDisplayList(document, viewport, context, renderDevice, measureFullExtent: true); return capture.Snapshot(); } finally @@ -234,7 +235,7 @@ public RenderedImage RenderToPng(IDocument document, IRenderDevice renderDevice) var context = CreateLayoutContext(document, renderDevice, _textMeasurer); var viewport = new RenderViewport(context.Width, context.Height); - var displayList = BuildDisplayList(document, viewport, context, renderDevice); + var displayList = BuildDisplayList(document, viewport, context, renderDevice, ResolveRootScrollOffsetY(document)); return _backend.RenderToPng(displayList, viewport); } @@ -264,10 +265,38 @@ public DisplayList BuildDisplayList(IDocument document, IRenderDevice renderDevi var context = CreateLayoutContext(document, renderDevice, _textMeasurer); var viewport = new RenderViewport(context.Width, context.Height); - return BuildDisplayList(document, viewport, context, renderDevice); + return BuildDisplayList(document, viewport, context, renderDevice, ResolveRootScrollOffsetY(document)); } - private static DisplayList BuildDisplayList(IDocument document, RenderViewport viewport, LayoutContext context, IRenderDevice renderDevice) + /// + /// Resolves the page's vertical scroll offset from the interactive DOM harness, if one has + /// been created for the document's browsing context (via IBrowsingContext.GetDomHarness() + /// - typically through or direct use of the CSSOM-view + /// scroll APIs). Returns 0 for the overwhelmingly common case of a document that was never + /// wired up for interactive use, so rendering stays unaffected unless a caller has actually + /// opted into scroll state existing at all. deliberately + /// does not call this - getBoundingClientRect/scrollHeight/max-scroll + /// calculations need the document's true, unscrolled layout to stay correct (and clamping a + /// newly-set scroll position depends on exactly that), so metrics capture always lays out at + /// scroll offset 0 regardless of whatever is currently scrolled into view for painting. + /// + private static float ResolveRootScrollOffsetY(IDocument document) + { + var scrollingElement = document.DocumentElement; + + if (scrollingElement is null || !document.Context.TryGetDomHarness(out var harness) || harness is null) + { + return 0f; + } + + // double.MaxValue as the clamp ceiling means this only floors at 0, never re-clamps to an + // upper bound - the stored value was already correctly clamped against the document's + // true scrollable extent when it was set via the public scrollTop/scrollTo DOM APIs + // (which measure with CaptureLayoutMetrics, unaffected by this method). + return (float)harness.GetScrollTop(scrollingElement, double.MaxValue); + } + + private static DisplayList BuildDisplayList(IDocument document, RenderViewport viewport, LayoutContext context, IRenderDevice renderDevice, float scrollOffsetY = 0f, bool measureFullExtent = false) { var displayList = new DisplayList { Fonts = context.Fonts }; displayList.FillRect(new RenderRect(0f, 0f, viewport.Width, viewport.Height), context.BackgroundColor); @@ -285,7 +314,12 @@ private static DisplayList BuildDisplayList(IDocument document, RenderViewport v var root = body is null ? renderTree : renderTree.Find(body) ?? renderTree; var contentX = context.Padding; - var contentY = context.Padding; + // A positive scroll offset moves the page's content up relative to the fixed viewport + // surface - painted the same way as an unscrolled page, just starting from a Y position + // that can be negative. Content scrolled above or below the surface's fixed pixel bounds + // simply falls outside what a raster surface of that size can hold; no explicit clip is + // needed to hide it; Skia only ever writes pixels that exist within the surface itself. + var contentY = context.Padding - scrollOffsetY; var contentWidth = viewport.Width - (2f * context.Padding); if (contentWidth <= 0f) @@ -301,6 +335,12 @@ private static DisplayList BuildDisplayList(IDocument document, RenderViewport v var activeFloatBottom = 0f; var textIndentConsumed = false; + // Normal painting stops laying out content once it has gone past the visible viewport - + // there is no need to spend time measuring what will never be rasterized. Measuring the + // page's true scrollable extent (for scrollTop/scrollHeight/max-scroll purposes) needs the + // opposite: the full page, regardless of how tall it is relative to the viewport. + var maxY = measureFullExtent ? float.MaxValue : viewport.Height - context.Padding; + foreach (var child in OrderChildrenForPainting(root.Children)) { LayoutNode( @@ -317,14 +357,40 @@ private static DisplayList BuildDisplayList(IDocument document, RenderViewport v textStyle: textStyle, context: context, displayList: displayList, - maxY: viewport.Height - context.Padding); + maxY: maxY); - if (cursorY > viewport.Height - context.Padding) + if (cursorY > maxY) { break; } } + // The page's own scrolling elements (/) are never laid out as boxes of their + // own - the loop above only ever lays out *their children* directly onto the page canvas - + // so neither ever gets an ordinary RecordLayoutMetrics call the way a normal descendant + // does. Synthesizing one here, sized to the full stacked content height, is what lets + // GetScrollHeight()/GetMaxScrollTop() (via ElementCssomViewExtensions.GetScrollExtents, + // which reads exactly this metrics map) treat the document as scrollable at all. This is a + // no-op outside of CaptureLayoutMetrics, since RecordLayoutMetrics itself only does + // anything while that capture is active. + if (body is not null) + { + // The synthesized box represents the *client* (viewport) area, not the total content + // height - GetScrollExtents (ElementCssomViewExtensions) separately walks this map for + // every actual descendant (each already carrying its own, normally-recorded metrics) + // to work out how far content actually extends past this box, exactly the way it + // already does for any other scrollable element. Recording the full content height + // here instead would make the client and scroll heights identical, so nothing would + // ever look scrollable. + var clientHeight = Math.Max(0f, viewport.Height - (2f * context.Padding)); + RecordLayoutMetrics(body, contentX, contentY, contentWidth, clientHeight, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f); + + if (document.DocumentElement is not null) + { + RecordLayoutMetrics(document.DocumentElement, contentX, contentY, contentWidth, clientHeight, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f); + } + } + return displayList; } diff --git a/src/AngleSharp.Renderer/InteractiveHtmlRendererStateExtensions.cs b/src/AngleSharp.Renderer/InteractiveHtmlRendererStateExtensions.cs index 89e2b8c..0211b82 100644 --- a/src/AngleSharp.Renderer/InteractiveHtmlRendererStateExtensions.cs +++ b/src/AngleSharp.Renderer/InteractiveHtmlRendererStateExtensions.cs @@ -31,4 +31,19 @@ public static IDomHarness GetDomHarness(this IBrowsingContext context) return new InteractiveHtmlRendererState(browsingContext, renderDevice); }); } + + /// + /// Gets the interactive DOM harness for the browsing context if one has already been created + /// (via a prior call), without creating one and without throwing + /// when no service is registered. Used by rendering code paths + /// that want to honor interaction state (e.g. scroll position) when present, but must remain + /// a no-op for the overwhelmingly common case of a document that was never wired up for + /// interactive use. + /// + public static bool TryGetDomHarness(this IBrowsingContext context, out IDomHarness? harness) + { + ArgumentNullException.ThrowIfNull(context); + + return s_harnesses.TryGetValue(context, out harness); + } } From 2ed9d1ed58d76fefb45fb141eff8d5cf28043d40 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Mon, 7 Sep 2026 22:12:51 +0200 Subject: [PATCH 07/22] Improved background image handling --- AGENTS.md | 8 +- CHANGELOG.md | 1 + .../HtmlRendererTests.cs | 125 +++++++ .../VisualConformanceTests.cs | 105 ++++++ ...sizes-no-repeat-background-image.macos.png | Bin 0 -> 210 bytes ...es-background-image-to-cover-box.macos.png | Bin 0 -> 214 bytes ...ound-image-across-box-by-default.macos.png | Bin 0 -> 210 bytes src/AngleSharp.Renderer/HtmlRenderer.cs | 349 ++++++++++++++++-- .../Rendering/RenderPaint.cs | 87 +++++ .../Skia/SkiaRenderBackend.cs | 96 +++++ 10 files changed, 739 insertions(+), 32 deletions(-) create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/positions-and-sizes-no-repeat-background-image.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/scales-background-image-to-cover-box.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/tiles-background-image-across-box-by-default.macos.png diff --git a/AGENTS.md b/AGENTS.md index 05ba6d8..feb1777 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,7 +74,7 @@ Page-level scrolling: `HtmlRenderer.RenderToPng`/`BuildDisplayList` (the public Critically, `HtmlRenderer.CaptureLayoutMetrics` (backing `getBoundingClientRect`/`GetScrollExtents`/hover-hit-testing) deliberately never applies this offset (it does not call `ResolveRootScrollOffsetY` at all) - `scrollHeight` and max-scroll clamping are scroll-*independent* intrinsic measurements, and letting them see a scrolled layout would create a self-referential bug where setting a scroll position depends on measurements that were themselves computed at the very scroll position being set. This is also why `document.documentElement`/`document.body` needed a second, narrower change: neither is ever laid out as a box of its own in this renderer (the top-level loop in `BuildDisplayList` only ever lays out *their children* directly onto the page canvas), so neither previously appeared in the layout-metrics map `GetScrollExtents` reads at all, making `document.documentElement.scrollHeight` (and therefore max-scroll) permanently 0 regardless of content height - confirmed by a failing test before the fix, not assumed. `BuildDisplayList` now synthesizes one `RecordLayoutMetrics` call for `document.Body`/`document.documentElement` sized to the *viewport's client area* (not the total content height - the existing `GetScrollExtents` descendant-walk already computes true scrollable height correctly from each individual child's own, normally-recorded metrics, exactly as it already does for any other scrollable element; recording the full content height here directly instead would make the synthesized element's own client and scroll heights identical, so nothing would ever look scrollable). This recording is a no-op outside of `CaptureLayoutMetrics`, since `RecordLayoutMetrics` itself only does anything while that capture's `AsyncLocal` is active. A related, pre-existing performance optimization (layout stops early once content runs past the visible viewport bottom) had to become conditional too: normal painting still wants that early exit, but metrics capture needs the *full* page laid out regardless of viewport height to measure a true scrollable extent, so `BuildDisplayList` takes a `measureFullExtent` parameter that widens its cutoff to `float.MaxValue` only for that call path. -Current behavior includes block layout, margins, padding, borders, floats, inline-block, relative/fixed/absolute positioning, z-index ordering, outlines, text styling, text alignment, line-height, letter-spacing, text-indent, vertical-align, border-radius, box-shadow, text-shadow, list-item markers, overflow clipping, page-level scroll offsets, and generic font-family handling. +Current behavior includes block layout, margins, padding, borders, floats, inline-block, relative/fixed/absolute positioning, z-index ordering, outlines, text styling, text alignment, line-height, letter-spacing, text-indent, vertical-align, border-radius, box-shadow, text-shadow, list-item markers, overflow clipping, page-level scroll offsets, image and gradient backgrounds, and generic font-family handling. SVG support: `` (including `data:` URIs) and inline `` markup both render, through two loading paths that converge on the same DOM-walking rasterizer (`Skia/Svg/`) - no third-party SVG parser is involved anywhere. An `` SVG source is sniffed and rasterized inside `TryLoadImageResource`, exactly where a PNG/JPEG source is decoded: `SvgRasterizer.TryRasterizeMarkup` parses the bytes with AngleSharp's own HTML/foreign-content parser (wrapped in a throwaway `` shell) and is cached per-document by URL like any other image. Inline `` has no URL and, more importantly, is already sitting in the host document's DOM - `SvgRasterizer.TryRasterizeElement` walks that element directly and is cached per-element in `s_inlineSvgCacheByElement`; it is never serialized back to text and re-parsed. `LayoutElement` empties `orderedChildren` for an `` root so its foreign-namespaced children are never walked as HTML flow content; both `` and inline `` are treated as a single replaced element. Rasterization always happens at the SVG's own natural (`viewBox`/`width`/`height`) size oversampled by a fixed factor (`SvgRasterizer.OversampleFactor`), not at the resolved CSS box size - the loader runs before CSS sizing is known, so this is a deliberate blur-vs-memory tradeoff rather than a per-render-size cache. @@ -92,6 +92,12 @@ A gradient stop's position may be a percentage, a bare fraction, or (very common SVG gradients (`linearGradient`/`radialGradient` inside an SVG document) are a separate, independent implementation (`SvgGradientBuilder`, see the SVG section above) and do not share code with the CSS gradient path described here, aside from both ultimately building an `SKShader`. +`background-image: url(...)` (image backgrounds, as opposed to a gradient function): this was previously a real, confirmed gap, not a hypothetical one - `ParseBackgroundPaint` recognized only the six gradient function names and fell straight through to the plain background-color for anything else, so a `url(...)` background silently rendered as if it were not specified at all. Resolution now goes through `HtmlRenderer.TryLoadBackgroundImageResource`, which decodes a `data:` URI inline and otherwise fetches over the network - and only over the network - when the document's browsing context has an `IDocumentLoader` configured, exactly mirroring how `` and `@font-face url()` sources are already handled (`FontFaceLoader.TryLoadFontData`); the fetch is synchronous (blocking on the download's `Task`), so the image is already loaded and decoded within the very same `BuildDisplayList` call that requested it - this renderer has no separate "paint now, repaint later once loaded" pass to defer to, unlike a live browser. The resolved `CachedImageResource` is cached per-document (`DocumentImageCache`, the same cache `` already uses, keyed by the same trimmed URL string) so a background image referenced from CSS is fetched once regardless of how many times the display list is rebuilt, and shares a cache entry with an `` pointed at the identical URL. A background image that fails to load for any reason (malformed data URI, HTTP error, unsupported format, or simply no `IDocumentLoader` configured) falls back to the element's `background-color`, matching browser behavior - the box is never left completely unpainted because of it. + +The loaded image becomes a `RenderImagePaint` (`Rendering/RenderPaint.cs`), parsed alongside it from `background-repeat` (`repeat` default, `no-repeat`, `repeat-x`, `repeat-y`; `space`/`round` are accepted but treated as plain `repeat`, since neither's actual spacing/rounding behavior is implemented), `background-position` (keywords, percentages, and pixel lengths, in the common one- and two-value forms; the four-value ` ` edge-relative syntax is not recognized), and `background-size` (`auto` default, `cover`, `contain`, or one/two explicit length-or-percentage values). Like a gradient's `CenterX`/`CenterY` fractions, none of this is resolved into concrete pixel geometry until paint time in `SkiaRenderBackend.CreateImagePaint` - a box's own final size (particularly an auto-sized box's height) is not known when its `background-image` is parsed out of the style map, only once its layout is complete. `CreateImagePaint` decodes the image, resolves `background-size`'s `cover`/`contain`/explicit sizing against the box's rendered rect, resolves `background-position` as `percentage * (boxDimension - imageDimension) + offsetPixels` per axis, and builds an `SKShader.CreateImage` with a constructor-time `SKMatrix` that scales and positions the image's own natural pixel space directly into world space - the same technique, and the same "local space maps directly into world space, not the inverse" Skia behavior, `SvgPatternBuilder` already established for `` fills. A non-repeating axis uses `SKShaderTileMode.Decal` rather than `Clamp` - `Clamp` would smear the image's own edge pixels across the rest of the box, which is not what CSS `no-repeat` specifies; `Decal` leaves that region transparent instead, exactly matching the spec. + +`PaintBackground` (`HtmlRenderer.cs`) - the function that turns a resolved `BoxStyle.BackgroundPaint` into an actual `FillRectCommand` - had its own, narrower version of the same gap: it explicitly matched only `RenderColorPaint` and `RenderGradientPaint`, so even after `RenderImagePaint` values were being constructed correctly by the parsing side, nothing ever painted them - a `background-image: url(...)` box rendered as fully unpainted rather than falling back to its background-color, silently dropping content rather than erroring. This was caught by a failing visual test before being traced to a one-line fix (`RenderGradientPaint or RenderImagePaint` in the type check), not assumed from reading the code - a reminder that adding a new `RenderPaint` subtype means checking every place that pattern-matches on the existing ones, not just the ones a change happens to touch directly. + ## Code Conventions Follow the repository's existing C# style, which is defined by `.editorconfig` and the existing source files: diff --git a/CHANGELOG.md b/CHANGELOG.md index 83ea8ae..773b1f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Released on Wednesday, September 9 2026. - Improved support for clipping and `overflow` declarations - Fixed painting of scrolled content +- Fixed handling of background images - Added support for rounded corners - Added support for `text-shadow` declarations - Added support for `box-shadow` declarations diff --git a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs index 64d4c98..b1d1834 100644 --- a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs +++ b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs @@ -91,6 +91,131 @@ public async Task BuildDisplayList_CachesHttpImagePayloadPerDocument() Assert.Equal(1, requester.ContentReadSessionCount); } + [Fact] + public async Task BuildDisplayList_PaintsBackgroundImageFromDataUri() + { + var document = await ParseAsync(""" + +
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 240, + ViewPortHeight = 160, + FontSize = 16f, + }); + + var fill = Assert.Single(displayList.Commands.OfType().Where(f => f.Paint is RenderImagePaint)); + var imagePaint = Assert.IsType(fill.Paint); + + Assert.NotEmpty(imagePaint.Image.Data); + Assert.Equal(1, imagePaint.Image.Width); + Assert.Equal(1, imagePaint.Image.Height); + // CSS initial values: `repeat repeat`, `0% 0%`, `auto`. + Assert.True(imagePaint.RepeatX); + Assert.True(imagePaint.RepeatY); + Assert.Equal(RenderBackgroundPositionComponent.Zero, imagePaint.PositionX); + Assert.Equal(RenderBackgroundPositionComponent.Zero, imagePaint.PositionY); + Assert.Equal(RenderBackgroundSizeKind.Auto, imagePaint.Size.Kind); + } + + [Fact] + public async Task BuildDisplayList_ParsesBackgroundRepeatPositionAndSize() + { + var document = await ParseAsync(""" + +
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 240, + ViewPortHeight = 160, + FontSize = 16f, + }); + + var fill = Assert.Single(displayList.Commands.OfType().Where(f => f.Paint is RenderImagePaint)); + var imagePaint = Assert.IsType(fill.Paint); + + Assert.False(imagePaint.RepeatX); + Assert.False(imagePaint.RepeatY); + Assert.Equal(new RenderBackgroundPositionComponent(1f, 0f), imagePaint.PositionX); + Assert.Equal(new RenderBackgroundPositionComponent(1f, 0f), imagePaint.PositionY); + Assert.Equal(RenderBackgroundSizeKind.Cover, imagePaint.Size.Kind); + } + + [Fact] + public async Task BuildDisplayList_FetchesAndCachesHttpBackgroundImagePerDocument() + { + var requester = new SingleResponseImageRequester(Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQABAA4A4cQTmwAAAABJRU5ErkJggg==")); + var configuration = Configuration.Default + .WithCss() + .With(requester) + .WithDefaultLoader(new LoaderOptions + { + IsResourceLoadingEnabled = true, + }); + + var document = await ParseAsync(""" + +
+ + """, configuration, "http://example.test/"); + + var renderer = new HtmlRenderer(); + + var first = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 240, + ViewPortHeight = 160, + FontSize = 16f, + }); + + var second = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 240, + ViewPortHeight = 160, + FontSize = 16f, + }); + + Assert.IsType(Assert.Single(first.Commands.OfType().Where(f => f.Paint is RenderImagePaint)).Paint); + Assert.IsType(Assert.Single(second.Commands.OfType().Where(f => f.Paint is RenderImagePaint)).Paint); + // Requested once and reused on the second render - not re-fetched every time the display + // list is rebuilt, the same guarantee already established for . + Assert.Equal(1, requester.ContentReadSessionCount); + } + + [Fact] + public async Task BuildDisplayList_WithoutDocumentLoader_FallsBackToBackgroundColorForNetworkUrl() + { + // No IDocumentLoader is registered here, so a network background-image URL must never be + // fetched - matching how and @font-face url() sources already behave. The box still + // paints (its background-color), it just does not get the image. + var document = await ParseAsync(""" + +
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 240, + ViewPortHeight = 160, + FontSize = 16f, + }); + + Assert.DoesNotContain(displayList.Commands, command => command is FillRectCommand fill && fill.Paint is RenderImagePaint); + + var colorFill = Assert.Single(displayList.Commands.OfType().Where(f => f.Color.Equals(new RenderColor(0, 0, 255)))); + Assert.IsType(colorFill.Paint); + } + [Fact] public async Task BuildDisplayList_PaintsSvgImageElementFromDataUri() { diff --git a/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs b/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs index d7cb9fe..ff52ff3 100644 --- a/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs +++ b/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs @@ -2154,6 +2154,111 @@ public async Task RenderToPng_PaintsMiddleOfTallPageAtAnArbitrarySettableScrollO return renderer.RenderToPng(document, renderDevice); } + // A hand-built 2x2 RGB PNG (no palette, filter 0), one red pixel diagonal from one blue pixel - + // a tiny, unambiguous checkerboard tile that makes tiling/scaling/positioning visually obvious + // even at very small sizes, unlike a solid-color 1x1 pixel (which would look identical whether + // or not tiling/positioning code actually ran). + private const string CheckerboardTileDataUri = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAE0lEQVR42mO4IycnZ3OHAYiBLAAgUgSdATw7NgAAAABJRU5ErkJggg=="; + + // A larger (20x20, four 10x10 quadrants) checkerboard PNG built the same way as + // CheckerboardTileDataUri, used specifically for the default-repeat tiling test below - tiling + // a 2x2 tile at 1:1 scale produces a genuinely correct but single-pixel-period checkerboard, + // which is indistinguishable from a rendering bug under any kind of downsampled visual + // inspection (verified: a raw per-pixel dump of that render confirmed it actually alternates + // correctly, but it is not a useful baseline for a human - or a diff image - to eyeball). + private const string CoarseCheckerboardTileDataUri = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAIAAAAC64paAAAAJUlEQVR42mO4IyeHB8nZ3MGDGEY1j2omqBm/NH6jRzWPaiaoGQCY+s0A5fuwxQAAAABJRU5ErkJggg=="; + + [Fact] + public async Task RenderToPng_TilesBackgroundImageAcrossBoxByDefault() + { + // No background-repeat/position/size given at all - the CSS initial values (`repeat repeat`, + // `0% 0%`, `auto`) tile the 20x20 tile at its own natural size across the whole box, so a + // 60x60 box shows a clean 3x3 grid of the tile's four quadrants. + var document = await ParseAsync($$""" + + + +
+ + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 60, + ViewPortHeight = 60, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "tiles-background-image-across-box-by-default.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_PositionsAndSizesNoRepeatBackgroundImage() + { + // An explicit background-size, background-repeat: no-repeat and background-position: right + // bottom together place a single, scaled-up copy of the tile in the box's bottom-right + // corner - the rest of the box shows the plain background-color underneath, since a + // non-repeating axis paints transparent (Decal), not a smeared/clamped edge color. + var document = await ParseAsync($$""" + + + +
+ + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 60, + ViewPortHeight = 60, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "positions-and-sizes-no-repeat-background-image.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_ScalesBackgroundImageToCoverBox() + { + // background-size: cover scales the (square) tile up until it fills a non-square box + // entirely, cropping whichever axis overflows - here the box is wider than it is tall, so + // the tile is scaled to the box's width and its vertical overflow is cropped symmetrically + // (background-position: center, the CSS default for a single "center" keyword). + var document = await ParseAsync($$""" + + + +
+ + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 80, + ViewPortHeight = 40, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "scales-background-image-to-cover-box.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + private static async Task ParseAsync(string html, IConfiguration? configuration = null) { var context = BrowsingContext.New(configuration ?? Configuration.Default.WithCss()); diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/positions-and-sizes-no-repeat-background-image.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/positions-and-sizes-no-repeat-background-image.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..af7bf92ade345fdf8014a040e87f32f4ae8d2c7d GIT binary patch literal 210 zcmeAS@N?(olHy`uVBq!ia0vp^HXzKw1|+Ti+$;i8EX7WqAsj$Z!;#Vf630S zmms73Ox=Omf!SHt&&|7~Tb|hbl4qImjB^RQK7E(}_*H6U?EdJi?RQ#v!Gc!JFXa+u W@*WlJJG=qtCyrPwzIWgTcYn@WxnTk wz=I&nICKBwi}@$&@>Z+gDQcY9Kp0+>FQ3jnTRZx^BG6$Bp00i_>zopr0F$Cs%>V!Z literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/tiles-background-image-across-box-by-default.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/tiles-background-image-across-box-by-default.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..570a9c3a4633e66caf2e25084153a1d221b2a21a GIT binary patch literal 210 zcmeAS@N?(olHy`uVBq!ia0vp^HXzKw1|+Ti+$;i8EX7WqAsj$Z!;#VfTX7c@7wYv#x zlEK`AC2W@^eZp(sWnBLCi*4$@@4MyAW}Hi~YJSPHOglfS03@3ZQg(043!Y`h5UD+x m7onnTmn$zXf{OAit7h1(Wfi`5A*(ykQ4F50elF{r5}E)JzEEfY literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer/HtmlRenderer.cs b/src/AngleSharp.Renderer/HtmlRenderer.cs index a264d7c..9244f8c 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.cs @@ -519,7 +519,7 @@ private static void LayoutElement( return; } - var box = ResolveBoxStyle(styleMap); + var box = ResolveBoxStyle(styleMap, element); var marginTop = ParseLength(styleMap, "margin-top", flowContainingWidth, box.Margin.Top, allowAuto: false); var marginBottom = ParseLength(styleMap, "margin-bottom", flowContainingWidth, box.Margin.Bottom, allowAuto: false); var marginLeft = ParseLength(styleMap, "margin-left", flowContainingWidth, box.Margin.Left, allowAuto: true); @@ -2897,8 +2897,20 @@ private static Dictionary CreateStyleMap(ICssStyleDeclaration st } else { - AddIfPresent(map, "background-image", style.GetPropertyValue("background-image")); + // AngleSharp.Css does compute a `url(...)` background-image (unlike the `overflow` + // shorthand quirk documented elsewhere), but as a normalized, quoted `url("...")` - the + // raw inline `style=""` fallback below matches the pattern the grid/flex properties + // above already use for their own AngleSharp.Css computation gaps, kept here as the + // same defensive fallback for the rare case computation reports nothing at all. + var computedBackgroundImage = style.GetPropertyValue("background-image"); + AddIfPresent(map, "background-image", string.IsNullOrWhiteSpace(computedBackgroundImage) + ? ParseStyleAttributeValue(inlineStyle, "background-image") + : computedBackgroundImage); } + + AddIfPresent(map, "background-repeat", string.IsNullOrWhiteSpace(style.GetPropertyValue("background-repeat")) ? ParseStyleAttributeValue(inlineStyle, "background-repeat") : style.GetPropertyValue("background-repeat")); + AddIfPresent(map, "background-position", string.IsNullOrWhiteSpace(style.GetPropertyValue("background-position")) ? ParseStyleAttributeValue(inlineStyle, "background-position") : style.GetPropertyValue("background-position")); + AddIfPresent(map, "background-size", string.IsNullOrWhiteSpace(style.GetPropertyValue("background-size")) ? ParseStyleAttributeValue(inlineStyle, "background-size") : style.GetPropertyValue("background-size")); AddIfPresent(map, "font-size", style.GetFontSize()); AddIfPresent(map, "font-family", style.GetFontFamily()); AddIfPresent(map, "font-weight", style.GetPropertyValue("font-weight")); @@ -3076,7 +3088,7 @@ private static int ParseZIndex(Dictionary styleMap) : 0; } - private static BoxStyle ResolveBoxStyle(Dictionary styleMap) + private static BoxStyle ResolveBoxStyle(Dictionary styleMap, IElement element) { var margin = new EdgeSizes( Top: ParseLength(styleMap, "margin-top", 0f, 0f, allowAuto: false), @@ -3101,7 +3113,7 @@ private static BoxStyle ResolveBoxStyle(Dictionary styleMap) borderWidth = ApplyBorderStyleToWidths(borderWidth, borderStyle); var backgroundColor = ParseColor(styleMap.TryGetValue("background-color", out var background) ? background : null, RenderColor.Transparent); - var backgroundPaint = ParseBackgroundPaint(styleMap, backgroundColor); + var backgroundPaint = ParseBackgroundPaint(styleMap, backgroundColor, element); var borderColor = ParseColor( styleMap.TryGetValue("border-top-color", out var topColor) ? topColor : styleMap.TryGetValue("border-right-color", out var rightColor) ? rightColor : @@ -3510,12 +3522,30 @@ private static bool TryRasterizeInlineSvgElement(IElement element, out CachedIma private static bool TryGetOrLoadImageResource(IElement element, string? source, out CachedImageResource? imageResource) { imageResource = null; - if (string.IsNullOrWhiteSpace(source)) - { - return false; - } - var cacheKey = source.Trim(); + return !string.IsNullOrWhiteSpace(source) && + TryGetOrLoadCachedResource(element, source.Trim(), TryLoadImageResource, out imageResource); + } + + /// + /// Resolves a `background-image: url(...)` reference to a decoded, cached image, sharing the + /// same per-document (keyed by the same trimmed URL string) as + /// an `<img src>` reference to the exact same URL - one fetch serves both. Unlike + /// , there is no download + /// already in flight to consult first: a CSS property value has no DOM-level load of its own, + /// so always resolves the URL itself (data URI, or + /// the network - and only the network - when the browsing context has an + /// configured), mirroring how @font-face url() sources are + /// handled in FontFaceLoader. + /// + private static bool TryGetOrLoadBackgroundImageResource(IElement element, string url, out CachedImageResource? imageResource) => + TryGetOrLoadCachedResource(element, url.Trim(), TryLoadBackgroundImageResource, out imageResource); + + private delegate bool ImageResourceLoader(IElement element, string source, out CachedImageResource? imageResource); + + private static bool TryGetOrLoadCachedResource(IElement element, string cacheKey, ImageResourceLoader loader, out CachedImageResource? imageResource) + { + imageResource = null; var cache = GetImageCache(element); if (cache is not null) @@ -3529,28 +3559,17 @@ private static bool TryGetOrLoadImageResource(IElement element, string? source, } } - if (!TryLoadImageResource(element, source, out imageResource)) - { - if (cache is not null) - { - lock (cache.Resources) - { - cache.Resources[cacheKey] = null; - } - } - - return false; - } + var loaded = loader(element, cacheKey, out imageResource); if (cache is not null) { lock (cache.Resources) { - cache.Resources[cacheKey] = imageResource; + cache.Resources[cacheKey] = loaded ? imageResource : null; } } - return true; + return loaded; } private static DocumentImageCache? GetImageCache(IElement element) @@ -3597,7 +3616,74 @@ private static bool TryLoadImageResource(IElement element, string? source, out C mimeType = dataUriMimeType; } - if (bytes is null || bytes.Length == 0) + return bytes is not null && TryDecodeImageBytes(bytes, mimeType, out imageResource); + } + + /// + /// Resolves a `background-image: url(...)` value: a data URI decodes inline, exactly like an + /// `<img src>` data URI; anything else is only ever fetched when the browsing context was + /// configured with an - matching how images and `@font-face` + /// sources are already handled, a renderer should not silently reach out to the network. The + /// fetch is synchronous (blocking on the download's Task) so the resource is + /// available - loaded and decoded - within the very same BuildDisplayList call that + /// requested it, the same way an `<img>`'s already-in-flight CurrentDownload is + /// awaited: this renderer has no separate "repaint later once loaded" pass to defer to. + /// + private static bool TryLoadBackgroundImageResource(IElement element, string url, out CachedImageResource? imageResource) + { + imageResource = null; + + if (TryParseDataUri(url, out var dataUriBytes, out var dataUriMimeType)) + { + return dataUriBytes is not null && TryDecodeImageBytes(dataUriBytes, dataUriMimeType, out imageResource); + } + + var document = element.Owner; + var loader = document?.Context.GetService(); + + if (document is null || loader is null) + { + return false; + } + + try + { + var target = new Url(document.BaseUrl, url); + var download = loader.FetchAsync(DocumentRequest.Get(target, source: document, referer: document.BaseUri)); + var response = download.Task.GetAwaiter().GetResult(); + + if (response?.Content is null) + { + return false; + } + + using var content = response.Content; + using var buffer = new MemoryStream(); + content.CopyTo(buffer); + var bytes = buffer.ToArray(); + + if (bytes.Length == 0) + { + return false; + } + + var mimeType = response.Headers?.TryGetValue("Content-Type", out var contentType) == true && !string.IsNullOrWhiteSpace(contentType) + ? contentType + : "image/unknown"; + + return TryDecodeImageBytes(bytes, mimeType, out imageResource); + } + catch + { + return false; + } + } + + private static bool TryDecodeImageBytes(byte[] bytes, string? mimeType, out CachedImageResource? imageResource) + { + imageResource = null; + + if (bytes.Length == 0) { return false; } @@ -3619,10 +3705,7 @@ private static bool TryLoadImageResource(IElement element, string? source, out C return false; } - var naturalWidth = skImage.Width; - var naturalHeight = skImage.Height; - - imageResource = new CachedImageResource(bytes, mimeType ?? "image/unknown", naturalWidth, naturalHeight); + imageResource = new CachedImageResource(bytes, mimeType ?? "image/unknown", skImage.Width, skImage.Height); return true; } @@ -3959,7 +4042,7 @@ private static void PaintBackground(DisplayList displayList, RenderPaint paint, return; } - if (paint is RenderGradientPaint) + if (paint is RenderGradientPaint or RenderImagePaint) { displayList.FillRect(new RenderRect(x, y, width, height), paint, clampedRadii); } @@ -4149,14 +4232,218 @@ private static bool TryParsePixelValue(string value, out float pixels) return false; } - private static RenderPaint ParseBackgroundPaint(Dictionary styleMap, RenderColor fallbackColor) + private static RenderPaint ParseBackgroundPaint(Dictionary styleMap, RenderColor fallbackColor, IElement element) { if (!styleMap.TryGetValue("background-image", out var backgroundImage) || string.IsNullOrWhiteSpace(backgroundImage)) { return new RenderColorPaint(fallbackColor); } - return ParseGradientPaint(backgroundImage, fallbackColor); + var trimmed = backgroundImage.Trim(); + + if (string.Equals(trimmed, "none", StringComparison.OrdinalIgnoreCase)) + { + return new RenderColorPaint(fallbackColor); + } + + if (TryExtractCssUrl(trimmed, out var url)) + { + // A background image that fails to load (bad data URI, 404, no IDocumentLoader + // configured, unsupported format) falls back to the background-color exactly like a + // browser does - the box is never left entirely unpainted because of it. + return TryGetOrLoadBackgroundImageResource(element, url, out var imageResource) && imageResource is not null + ? BuildImagePaint(imageResource, styleMap) + : new RenderColorPaint(fallbackColor); + } + + return ParseGradientPaint(trimmed, fallbackColor); + } + + private static bool TryExtractCssUrl(string value, out string url) + { + url = string.Empty; + + if (!value.StartsWith("url(", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var close = value.LastIndexOf(')'); + + if (close < 4) + { + return false; + } + + url = value[4..close].Trim().Trim('\'', '"').Trim(); + return url.Length > 0; + } + + private static RenderPaint BuildImagePaint(CachedImageResource imageResource, Dictionary styleMap) + { + var image = new RenderedImage(imageResource.Bytes, imageResource.NaturalWidth, imageResource.NaturalHeight, imageResource.MimeType); + var (repeatX, repeatY) = ParseBackgroundRepeat(styleMap); + var (positionX, positionY) = ParseBackgroundPosition(styleMap); + var size = ParseBackgroundSize(styleMap); + + return new RenderImagePaint(image, repeatX, repeatY, positionX, positionY, size); + } + + /// + /// Parses `background-repeat`. Only the single-keyword and two-keyword-per-axis forms are + /// recognized; `space`/`round` fall back to tiling like plain `repeat` rather than the spacing + /// they actually specify, since neither is otherwise implemented. The CSS initial value is + /// `repeat` on both axes. + /// + private static (bool RepeatX, bool RepeatY) ParseBackgroundRepeat(Dictionary styleMap) + { + if (!styleMap.TryGetValue("background-repeat", out var value) || string.IsNullOrWhiteSpace(value)) + { + return (true, true); + } + + var tokens = value.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); + + if (tokens.Length == 0) + { + return (true, true); + } + + bool IsRepeating(string token) => token.ToLowerInvariant() is "repeat" or "space" or "round"; + + return tokens[0].ToLowerInvariant() switch + { + "repeat-x" => (true, false), + "repeat-y" => (false, true), + "no-repeat" when tokens.Length == 1 => (false, false), + _ when tokens.Length >= 2 => (IsRepeating(tokens[0]), IsRepeating(tokens[1])), + _ => (IsRepeating(tokens[0]), IsRepeating(tokens[0])), + }; + } + + /// + /// Parses `background-position`. Only the common one- and two-value forms are recognized + /// (keywords, percentages, lengths); the four-value `<side> <offset>` edge-relative + /// syntax is not. The CSS initial value is `0% 0%` (top-left). + /// + private static (RenderBackgroundPositionComponent X, RenderBackgroundPositionComponent Y) ParseBackgroundPosition(Dictionary styleMap) + { + if (!styleMap.TryGetValue("background-position", out var value) || string.IsNullOrWhiteSpace(value)) + { + return (RenderBackgroundPositionComponent.Zero, RenderBackgroundPositionComponent.Zero); + } + + var tokens = value.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); + + if (tokens.Length == 0) + { + return (RenderBackgroundPositionComponent.Zero, RenderBackgroundPositionComponent.Zero); + } + + if (tokens.Length == 1) + { + // A single token positions the X axis (or both, for `center`); the Y axis defaults to + // centered, matching the CSS single-value `background-position` rule. + var only = tokens[0].ToLowerInvariant(); + + if (only is "top" or "bottom") + { + return (new RenderBackgroundPositionComponent(0.5f, 0f), ParsePositionComponent(only)); + } + + return (ParsePositionComponent(only), new RenderBackgroundPositionComponent(0.5f, 0f)); + } + + return (ParsePositionComponent(tokens[0]), ParsePositionComponent(tokens[1])); + } + + private static RenderBackgroundPositionComponent ParsePositionComponent(string token) + { + return token.ToLowerInvariant() switch + { + "left" or "top" => RenderBackgroundPositionComponent.Zero, + "right" or "bottom" => new RenderBackgroundPositionComponent(1f, 0f), + "center" => new RenderBackgroundPositionComponent(0.5f, 0f), + _ => ParsePositionLength(token), + }; + } + + private static RenderBackgroundPositionComponent ParsePositionLength(string token) + { + var trimmed = token.Trim(); + + if (trimmed.EndsWith("%", StringComparison.Ordinal) && + float.TryParse(trimmed[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out var percent)) + { + return new RenderBackgroundPositionComponent(percent / 100f, 0f); + } + + if (TryParsePixelValue(trimmed, out var pixels)) + { + return new RenderBackgroundPositionComponent(0f, pixels); + } + + return RenderBackgroundPositionComponent.Zero; + } + + /// + /// Parses `background-size`. The CSS initial value is `auto` (the image's own natural size). + /// + private static RenderBackgroundSize ParseBackgroundSize(Dictionary styleMap) + { + if (!styleMap.TryGetValue("background-size", out var value) || string.IsNullOrWhiteSpace(value)) + { + return RenderBackgroundSize.Auto; + } + + var trimmed = value.Trim(); + + if (string.Equals(trimmed, "cover", StringComparison.OrdinalIgnoreCase)) + { + return new RenderBackgroundSize(RenderBackgroundSizeKind.Cover); + } + + if (string.Equals(trimmed, "contain", StringComparison.OrdinalIgnoreCase)) + { + return new RenderBackgroundSize(RenderBackgroundSizeKind.Contain); + } + + var tokens = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries); + + if (tokens.Length == 0) + { + return RenderBackgroundSize.Auto; + } + + var width = ParseSizeAxisToken(tokens[0]); + // A single-value `background-size` sizes only the width explicitly; the height is always + // `auto` (proportional to the image's aspect ratio), never a copy of the width token. + var height = tokens.Length > 1 ? ParseSizeAxisToken(tokens[1]) : new RenderBackgroundSizeAxis(true, false, 0f); + + return new RenderBackgroundSize(RenderBackgroundSizeKind.Explicit, width, height); + } + + private static RenderBackgroundSizeAxis ParseSizeAxisToken(string token) + { + var trimmed = token.Trim(); + + if (string.Equals(trimmed, "auto", StringComparison.OrdinalIgnoreCase)) + { + return new RenderBackgroundSizeAxis(true, false, 0f); + } + + if (trimmed.EndsWith("%", StringComparison.Ordinal) && + float.TryParse(trimmed[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out var percent)) + { + return new RenderBackgroundSizeAxis(false, true, percent / 100f); + } + + if (TryParsePixelValue(trimmed, out var pixels)) + { + return new RenderBackgroundSizeAxis(false, false, pixels); + } + + return new RenderBackgroundSizeAxis(true, false, 0f); } private static RenderPaint ParseGradientPaint(string rawValue, RenderColor fallbackColor) diff --git a/src/AngleSharp.Renderer/Rendering/RenderPaint.cs b/src/AngleSharp.Renderer/Rendering/RenderPaint.cs index 5989cd6..863fd29 100644 --- a/src/AngleSharp.Renderer/Rendering/RenderPaint.cs +++ b/src/AngleSharp.Renderer/Rendering/RenderPaint.cs @@ -15,6 +15,93 @@ public sealed record RenderColorPaint(RenderColor Color) : RenderPaint; ///
public sealed record RenderGradientPaint(RenderGradient Gradient) : RenderPaint; +/// +/// Represents a `background-image: url(...)` paint. The image itself is already fully loaded and +/// decoded by the time this is constructed (see HtmlRenderer.TryLoadBackgroundImageResource) +/// - what remains unresolved here is exactly what stays unresolved for : +/// geometry that depends on the final painted box, which is not known until the box's own layout is +/// complete (in particular, an auto-sized box's own height). / +/// and are therefore resolved against the box's rect only at paint time, in the +/// backend, mirroring how a gradient's CenterX/CenterY fractions and absolute-length +/// stop positions are resolved against the gradient's own rendered geometry rather than at parse time. +/// +/// The decoded, natural-size image payload. +/// Whether the image tiles along the horizontal axis (`background-repeat`). +/// Whether the image tiles along the vertical axis (`background-repeat`). +/// The horizontal `background-position` component. +/// The vertical `background-position` component. +/// The `background-size` specification. +public sealed record RenderImagePaint( + RenderedImage Image, + bool RepeatX, + bool RepeatY, + RenderBackgroundPositionComponent PositionX, + RenderBackgroundPositionComponent PositionY, + RenderBackgroundSize Size) : RenderPaint; + +/// +/// One axis of a `background-position` value, resolved as +/// Percentage * (boxDimension - imageDimension) + OffsetPixels - the CSS formula for a +/// percentage combined with a length along the same axis. A pure keyword/percentage carries +/// = 0; a pure length carries = 0. +/// +public readonly record struct RenderBackgroundPositionComponent(float Percentage, float OffsetPixels) +{ + /// + /// The CSS initial value for a `background-position` component (`0%`). + /// + public static readonly RenderBackgroundPositionComponent Zero = new(0f, 0f); +} + +/// +/// Describes a `background-size` value. +/// +/// Which sizing algorithm applies. +/// The explicit width axis, when is . +/// The explicit height axis, when is . +public sealed record RenderBackgroundSize(RenderBackgroundSizeKind Kind, RenderBackgroundSizeAxis Width = default, RenderBackgroundSizeAxis Height = default) +{ + /// + /// The CSS initial value (`auto auto`) - the image paints at its own natural size. + /// + public static readonly RenderBackgroundSize Auto = new(RenderBackgroundSizeKind.Auto); +} + +/// +/// One axis of an explicit `background-size` value. +/// +/// Whether this axis is `auto` (sized proportionally from the other axis). +/// Whether is a 0-1 fraction of the box rather than pixels. +/// The axis value: pixels, or a 0-1 fraction of the box when . +public readonly record struct RenderBackgroundSizeAxis(bool IsAuto, bool IsPercentage, float Value); + +/// +/// Describes how a `background-size` value sizes the image, matching the CSS keyword/explicit-value +/// forms. +/// +public enum RenderBackgroundSizeKind +{ + /// + /// The image paints at its own natural (intrinsic) size. The CSS default. + /// + Auto, + + /// + /// The image is scaled up, preserving aspect ratio, until it covers the box entirely. + /// + Cover, + + /// + /// The image is scaled, preserving aspect ratio, until it fits entirely within the box. + /// + Contain, + + /// + /// The image is sized from explicit / axes. + /// + Explicit, +} + /// /// Describes a gradient definition. /// diff --git a/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs b/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs index 47cffb3..417d798 100644 --- a/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs +++ b/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs @@ -266,10 +266,106 @@ private static SKPaint CreateFillPaint(RenderPaint paint, RenderRect rect) Style = SKPaintStyle.Fill, }, RenderGradientPaint gradientPaint => CreateGradientPaint(gradientPaint.Gradient, rect), + RenderImagePaint imagePaint => CreateImagePaint(imagePaint, rect), _ => throw new NotSupportedException($"Unsupported paint type: {paint.GetType().Name}"), }; } + /// + /// Builds a `background-image: url(...)` paint as a tiled/positioned image shader, resolving + /// and the position components against + /// - the box's own final geometry, not known when the paint was constructed (see + /// 's own remarks). This mirrors SvgPatternBuilder's + /// tile-to-user-space matrix technique: build the shader in the image's own natural pixel space, + /// then let a constructor-time position and scale it - not the inverse + /// (the same "local space maps directly into world space" behavior already verified for the + /// gradient shaders above). + /// + private static SKPaint CreateImagePaint(RenderImagePaint imagePaint, RenderRect rect) + { + var paint = new SKPaint + { + IsAntialias = true, + Style = SKPaintStyle.Fill, + }; + + if (rect.Width <= 0f || rect.Height <= 0f) + { + return paint; + } + + using var data = SKData.CreateCopy(imagePaint.Image.Data); + using var image = SKImage.FromEncodedData(data); + + if (image is null || image.Width <= 0 || image.Height <= 0) + { + return paint; + } + + var (drawWidth, drawHeight) = ResolveBackgroundImageSize(imagePaint.Size, image.Width, image.Height, rect.Width, rect.Height); + + if (drawWidth <= 0f || drawHeight <= 0f) + { + return paint; + } + + var originX = rect.X + (imagePaint.PositionX.Percentage * (rect.Width - drawWidth)) + imagePaint.PositionX.OffsetPixels; + var originY = rect.Y + (imagePaint.PositionY.Percentage * (rect.Height - drawHeight)) + imagePaint.PositionY.OffsetPixels; + + var localMatrix = new SKMatrix( + drawWidth / image.Width, 0f, originX, + 0f, drawHeight / image.Height, originY, + 0f, 0f, 1f); + + // `no-repeat` uses Decal (transparent outside the single placed tile), not Clamp - Clamp + // would smear the image's edge pixels across the rest of the box instead of leaving it + // uncovered, which is what CSS actually specifies for a non-repeating axis. + var tileX = imagePaint.RepeatX ? SKShaderTileMode.Repeat : SKShaderTileMode.Decal; + var tileY = imagePaint.RepeatY ? SKShaderTileMode.Repeat : SKShaderTileMode.Decal; + + paint.Shader = SKShader.CreateImage(image, tileX, tileY, localMatrix); + return paint; + } + + /// + /// Resolves a `background-size` value against the image's natural pixel size and the box's + /// rendered rect, matching the CSS `cover`/`contain`/explicit-axis sizing algorithms. + /// + private static (float Width, float Height) ResolveBackgroundImageSize(RenderBackgroundSize size, int naturalWidth, int naturalHeight, float boxWidth, float boxHeight) + { + switch (size.Kind) + { + case RenderBackgroundSizeKind.Cover: + { + var scale = Math.Max(boxWidth / naturalWidth, boxHeight / naturalHeight); + return (naturalWidth * scale, naturalHeight * scale); + } + case RenderBackgroundSizeKind.Contain: + { + var scale = Math.Min(boxWidth / naturalWidth, boxHeight / naturalHeight); + return (naturalWidth * scale, naturalHeight * scale); + } + case RenderBackgroundSizeKind.Explicit: + { + var aspect = (float)naturalWidth / naturalHeight; + float? width = size.Width.IsAuto ? null : size.Width.IsPercentage ? size.Width.Value * boxWidth : size.Width.Value; + float? height = size.Height.IsAuto ? null : size.Height.IsPercentage ? size.Height.Value * boxHeight : size.Height.Value; + + if (width is null && height is null) + { + return (naturalWidth, naturalHeight); + } + + width ??= height!.Value * aspect; + height ??= width.Value / aspect; + + return (width.Value, height.Value); + } + default: + return (naturalWidth, naturalHeight); + } + } + private static SKPaint CreateGradientPaint(RenderGradient gradient, RenderRect rect) { var paint = new SKPaint From 1a7efaf5f0e95f0e9ab60082642b0d699a6282c3 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Tue, 8 Sep 2026 09:54:13 +0200 Subject: [PATCH 08/22] Added and improved form drawing --- AGENTS.md | 14 +- CHANGELOG.md | 1 + .../HtmlRendererTests.cs | 555 +++++++++++++ .../VisualConformanceTests.cs | 75 ++ ...lts-are-overridden-by-author-css.macos.png | Bin 0 -> 3605 bytes ...rol-gallery-with-default-styling.macos.png | Bin 0 -> 11125 bytes src/AngleSharp.Renderer/HtmlRenderer.cs | 733 +++++++++++++++++- 7 files changed, 1372 insertions(+), 6 deletions(-) create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/form-control-defaults-are-overridden-by-author-css.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-form-control-gallery-with-default-styling.macos.png diff --git a/AGENTS.md b/AGENTS.md index feb1777..852222d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,7 +74,19 @@ Page-level scrolling: `HtmlRenderer.RenderToPng`/`BuildDisplayList` (the public Critically, `HtmlRenderer.CaptureLayoutMetrics` (backing `getBoundingClientRect`/`GetScrollExtents`/hover-hit-testing) deliberately never applies this offset (it does not call `ResolveRootScrollOffsetY` at all) - `scrollHeight` and max-scroll clamping are scroll-*independent* intrinsic measurements, and letting them see a scrolled layout would create a self-referential bug where setting a scroll position depends on measurements that were themselves computed at the very scroll position being set. This is also why `document.documentElement`/`document.body` needed a second, narrower change: neither is ever laid out as a box of its own in this renderer (the top-level loop in `BuildDisplayList` only ever lays out *their children* directly onto the page canvas), so neither previously appeared in the layout-metrics map `GetScrollExtents` reads at all, making `document.documentElement.scrollHeight` (and therefore max-scroll) permanently 0 regardless of content height - confirmed by a failing test before the fix, not assumed. `BuildDisplayList` now synthesizes one `RecordLayoutMetrics` call for `document.Body`/`document.documentElement` sized to the *viewport's client area* (not the total content height - the existing `GetScrollExtents` descendant-walk already computes true scrollable height correctly from each individual child's own, normally-recorded metrics, exactly as it already does for any other scrollable element; recording the full content height here directly instead would make the synthesized element's own client and scroll heights identical, so nothing would ever look scrollable). This recording is a no-op outside of `CaptureLayoutMetrics`, since `RecordLayoutMetrics` itself only does anything while that capture's `AsyncLocal` is active. A related, pre-existing performance optimization (layout stops early once content runs past the visible viewport bottom) had to become conditional too: normal painting still wants that early exit, but metrics capture needs the *full* page laid out regardless of viewport height to measure a true scrollable extent, so `BuildDisplayList` takes a `measureFullExtent` parameter that widens its cutoff to `float.MaxValue` only for that call path. -Current behavior includes block layout, margins, padding, borders, floats, inline-block, relative/fixed/absolute positioning, z-index ordering, outlines, text styling, text alignment, line-height, letter-spacing, text-indent, vertical-align, border-radius, box-shadow, text-shadow, list-item markers, overflow clipping, page-level scroll offsets, image and gradient backgrounds, and generic font-family handling. +Form controls (``, `"""); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 100, + FontSize = 16f, + }); + + Assert.Single(displayList.Commands.OfType().Where(f => f.Color.Equals(new RenderColor(255, 255, 255)) && f.Rect.Width == 160f)); + // A textarea's own child text node still flows through the ordinary block child-layout + // path (unlike +
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 300, + ViewPortHeight = 100, + FontSize = 16f, + }); + + Assert.DoesNotContain(displayList.Commands, c => c is DrawTextCommand t && t.Text.Contains("secret-token")); + // Filtered to width 150 (the form-control default) rather than a bare "no white fill at + // all", since the page's own default background is also a full-viewport white fill. + Assert.DoesNotContain(displayList.Commands, c => c is FillRectCommand f && f.Color.Equals(new RenderColor(255, 255, 255)) && f.Rect.Width == 160f); + // The visible sibling still renders normally - the hidden input contributes no box at all + // to disrupt the layout around it, not just an invisible one. + Assert.Single(displayList.Commands.OfType().Where(f => f.Color.Equals(new RenderColor(0, 255, 0)))); + } + + // Mirrors the private HtmlRenderer.FormControlAccentColor constant (26, 115, 232) - kept as an + // independent literal here rather than reflecting into the private field, so a test failure + // reads as "the painted color changed" rather than needing reflection to even compile. + private static readonly RenderColor FormControlAccentColorForTests = new(26, 115, 232); + private static async Task ParseAsync(string html, IConfiguration? configuration = null, string? address = null) { var context = BrowsingContext.New(configuration ?? Configuration.Default.WithCss()); diff --git a/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs b/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs index ff52ff3..b9ca194 100644 --- a/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs +++ b/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs @@ -2259,6 +2259,81 @@ public async Task RenderToPng_ScalesBackgroundImageToCoverBox() maxDifferentPixels: 0); } + [Fact] + public async Task RenderToPng_RendersFormControlGalleryWithDefaultStyling() + { + var document = await ParseAsync(""" + + + +
+
+
+
+
+
+
+ +
+
+
+ + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 220, + ViewPortHeight = 340, + FontSize = 16, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-form-control-gallery-with-default-styling.png", + actualPng: image.Data, + perChannelTolerance: TextRenderingToleranceChannel, + maxDifferentPixels: TextRenderingToleranceMaxPixels); + } + + [Fact] + public async Task RenderToPng_FormControlDefaultsAreOverriddenByAuthorCss() + { + // Two otherwise-identical text inputs side by side - the left keeps every synthesized + // default, the right overrides border color/width, border-radius, background-color and + // width via ordinary CSS, exactly the way a real browser lets you restyle a form control's + // "somewhat overridable" native chrome without needing a `appearance: none` escape hatch. + var document = await ParseAsync(""" + + + + + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 180, + ViewPortHeight = 80, + FontSize = 16, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "form-control-defaults-are-overridden-by-author-css.png", + actualPng: image.Data, + perChannelTolerance: TextRenderingToleranceChannel, + maxDifferentPixels: TextRenderingToleranceMaxPixels); + } + private static async Task ParseAsync(string html, IConfiguration? configuration = null) { var context = BrowsingContext.New(configuration ?? Configuration.Default.WithCss()); diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/form-control-defaults-are-overridden-by-author-css.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/form-control-defaults-are-overridden-by-author-css.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..6e3f74267b5380c1860cec3d0f38c0035d0d22a5 GIT binary patch literal 3605 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 literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer/HtmlRenderer.cs b/src/AngleSharp.Renderer/HtmlRenderer.cs index 9244f8c..c51fc3d 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.cs @@ -470,6 +470,16 @@ private static void LayoutElement( return; } + var formControlKind = ResolveFormControlKind(element); + + // input[type=hidden] never paints at all, matching the UA `display: none` browsers give + // it - AngleSharp.Css's own UA stylesheet does not special-case it (every type + // computes to plain inline-block), so this renderer has to. + if (formControlKind == FormControlKind.Hidden) + { + return; + } + var display = GetDisplay(styleMap); if (string.Equals(display, "table", StringComparison.OrdinalIgnoreCase)) @@ -482,6 +492,11 @@ private static void LayoutElement( var isInlineBlock = IsInlineBlock(computedStyle); var currentTextStyle = ResolveTextStyle(styleMap, inheritedTextStyle); + if (formControlKind != FormControlKind.None) + { + ApplyFormControlDefaults(formControlKind, element, styleMap, currentTextStyle, context); + } + if (cursorY >= activeFloatBottom) { activeFloatLeftOffset = 0f; @@ -627,6 +642,29 @@ private static void LayoutElement( currentTextStyle = PaintListItemMarker(displayList, element, styleMap, currentTextStyle, context, borderBoxX, contentX, contentY); } + if (formControlKind != FormControlKind.None) + { + // Children are suppressed or absent for every kind PaintFormControl actually paints + // content for (TextLike/Select/Button/Checkbox/Radio/Color), so their box never grows + // past its own specified height the way an ordinary auto-sized box can - the specified + // height (falling back to a single line, for an author-supplied "auto") is therefore + // also this box's final content height, safe to resolve here rather than waiting for + // the auto-height computation later in this method. + var formControlSpecifiedHeight = ResolveFlexibleContentDimension( + styleMap, + flowContainingWidth, + float.NaN, + isFlexItem, + isRowDirection, + flexMainSize, + flexCrossSize, + propertyName: "height"); + var formControlContentHeight = float.IsNaN(formControlSpecifiedHeight) + ? currentTextStyle.FontSize * currentTextStyle.LineHeightMultiplier + : formControlSpecifiedHeight; + PaintFormControl(displayList, element, formControlKind, currentTextStyle, context, contentX, contentY, contentWidth, formControlContentHeight); + } + var childCursorY = contentY; var childPreviousBlockMarginBottom = 0f; var childSuppressNextBlockTopMargin = collapseWithFirstChild && !float.Equals(effectiveMarginTop, marginTop); @@ -640,14 +678,29 @@ private static void LayoutElement( // An root's children are foreign-namespaced SVG elements (circle, text, title, ...), // not HTML flow content; it is rasterized as a single replaced element below, so its - // subtree must never be walked as if it were normal inline/block content. - var orderedChildren = string.Equals(tagName, "svg", StringComparison.OrdinalIgnoreCase) + // subtree must never be walked as if it were normal inline/block content. A ` siblings. var hasInlineRun = orderedChildren.Any(child => (child is ElementRenderNode childElement && - !ShouldRenderAsBlock(childElement.ComputedStyle) && - !IsInlineBlock(childElement.ComputedStyle)) || + (!ShouldRenderAsBlock(childElement.ComputedStyle) || IsInlineBlock(childElement.ComputedStyle))) || (child is ElementRenderNode childElementWithBr && string.Equals(childElementWithBr.Ref.LocalName, "br", StringComparison.OrdinalIgnoreCase))); if (IsFlexContainer(styleMap)) @@ -768,7 +821,24 @@ private static void LayoutElement( { foreach (var child in orderedChildren) { - var childIsBlock = child is ElementRenderNode childElement && (ShouldRenderAsBlock(childElement.ComputedStyle) || IsInlineBlock(childElement.ComputedStyle)); + // An inline-block child flows next to its siblings on the shared line (like a real + // browser) only when its own box size can be predicted up front without laying it + // out first - i.e. both width and height resolve to an explicit, non-auto value, + // which is guaranteed for every form control (ApplyFormControlDefaults always + // injects concrete pixel defaults) and for any inline-block given explicit + // width/height in CSS. An inline-block whose size genuinely depends on its own + // auto-flowing content (shrink-to-fit width, or height driven by wrapped children) + // cannot be predicted this cheaply without a full trial layout, so it deliberately + // falls back to the older, still-correct-if-visually-imperfect behavior below: its + // own line, exactly like display:block - a documented, deliberate scope cut rather + // than building genuine two-pass (shrink-to-fit) inline-block layout. + float ibWidth = 0f, ibHeight = 0f, ibMarginLeft = 0f, ibMarginRight = 0f, ibMarginTop = 0f, ibMarginBottom = 0f; + var canFlowAsInlineBlock = child is ElementRenderNode ibCandidate && + IsInlineBlock(ibCandidate.ComputedStyle) && + TryMeasureInlineBlockBoxSize(ibCandidate, contentWidth, currentTextStyle, context, out ibWidth, out ibHeight, out ibMarginLeft, out ibMarginRight, out ibMarginTop, out ibMarginBottom); + var childIsBlock = child is ElementRenderNode childElement && + ShouldRenderAsBlock(childElement.ComputedStyle) && + !canFlowAsInlineBlock; if (childIsBlock) { @@ -819,6 +889,63 @@ private static void LayoutElement( ref textIndentConsumed); } } + else if (canFlowAsInlineBlock && child is ElementRenderNode inlineBlockElement) + { + // Wraps to a new line first if this item does not fit in what is left of + // the current one - unless it is the very first thing being placed on this + // line at all, so a single inline-block item wider than its container still + // gets placed (on its own line) rather than looping forever. + var totalAdvance = ibMarginLeft + ibWidth + ibMarginRight; + + // contentX/contentWidth (this element's own resolved content box), not + // flowContainingX/flowContainingWidth (the wider box this *element itself* + // was given to size *itself* within its own parent) - using the latter here + // was a real bug caught by BuildDisplayList_InlineBlockSiblingsWrapToANewLineWhenTheyDoNotFit: + // a narrower-than-parent container (e.g. width:50px) never wrapped its + // inline-block children at all, since the wrap boundary was being measured + // against the parent's own, much wider available width instead of this + // element's own. + if (inlineCursorX > contentX && inlineCursorX + totalAdvance > contentX + contentWidth) + { + childCursorY = Math.Max(childCursorY, inlineLineTop + inlineLineHeight); + inlineLineTop = childCursorY; + inlineCursorX = contentX; + inlineLineHeight = 0f; + } + + // containingX is the margin box's own left edge, not the border box's - + // LayoutNode/LayoutElement apply this element's own margin-left internally + // (flowBorderBoxX = flowContainingX + marginLeft) exactly as they would for + // a block-level child, so the border box lands ibMarginLeft to the right of + // what is passed here, matching totalAdvance's own accounting below. + var itemStartX = inlineCursorX; + var inlineBlockCursorY = inlineLineTop; + var inlineBlockPreviousBlockMarginBottom = 0f; + var inlineBlockSuppressNextBlockTopMargin = false; + var inlineBlockActiveFloatLeftOffset = 0f; + var inlineBlockActiveFloatBottom = 0f; + var inlineBlockTextIndentConsumed = true; + + LayoutNode( + node: inlineBlockElement, + containingX: itemStartX, + containingY: inlineLineTop, + containingWidth: contentWidth, + cursorY: ref inlineBlockCursorY, + previousBlockMarginBottom: ref inlineBlockPreviousBlockMarginBottom, + suppressNextBlockTopMargin: ref inlineBlockSuppressNextBlockTopMargin, + activeFloatLeftOffset: ref inlineBlockActiveFloatLeftOffset, + activeFloatBottom: ref inlineBlockActiveFloatBottom, + textIndentConsumed: ref inlineBlockTextIndentConsumed, + textStyle: currentTextStyle, + context: context, + displayList: displayList, + maxY: maxY); + + inlineCursorX = itemStartX + totalAdvance; + inlineLineHeight = Math.Max(inlineLineHeight, ibMarginTop + ibHeight + ibMarginBottom); + textIndentConsumed = true; + } else if (child is ElementRenderNode inlineElement) { var childTagName = inlineElement.Ref.LocalName; @@ -1357,6 +1484,85 @@ private static float ResolveFlexibleContentDimension( : (flexMainSize.HasValue ? flexMainSize.Value : ParseLength(styleMap, propertyName, relativeTo, defaultValue, allowAuto: true)); } + /// + /// Predicts an inline-block element's own border-box width/height and all four margins without + /// actually laying it out, so its parent's inline-merging child loop can decide up front + /// whether it fits on the current line and exactly where to place it. LayoutNode/ + /// LayoutElement have no "measure only" mode of their own - they only ever discover a + /// box's final size as a side effect of actually laying out (and painting) it - so this exists + /// purely to avoid needing one for the one case that can be predicted cheaply. It succeeds only + /// when both width and height resolve to an explicit, non-auto value: guaranteed + /// for every form control ( always injects concrete pixel + /// defaults for both) and for any inline-block given explicit width/height in + /// CSS. It deliberately returns for anything else - shrink-to-fit width + /// or auto/content-driven height would need a genuine trial layout to measure, which this does + /// not attempt (a documented scope cut, not an oversight: see the call site for what happens + /// instead, which is not a regression - it is exactly this renderer's pre-existing behavior for + /// every inline-block element, before shared-line flow existed for any of them). + /// + private static bool TryMeasureInlineBlockBoxSize( + ElementRenderNode elementNode, + float containingWidth, + RenderTextStyle inheritedTextStyle, + LayoutContext context, + out float width, + out float height, + out float marginLeft, + out float marginRight, + out float marginTop, + out float marginBottom) + { + width = 0f; + height = 0f; + marginLeft = 0f; + marginRight = 0f; + marginTop = 0f; + marginBottom = 0f; + + var element = elementNode.Ref; + var styleMap = CreateStyleMap(elementNode.ComputedStyle, element); + var textStyle = ResolveTextStyle(styleMap, inheritedTextStyle); + var formControlKind = ResolveFormControlKind(element); + + // A hidden input never lays out or paints at all (see LayoutElement's own early return for + // it) - it has no box to flow inline, so it is neither flowable nor block-stackable here. + if (formControlKind == FormControlKind.Hidden) + { + return false; + } + + if (formControlKind != FormControlKind.None) + { + ApplyFormControlDefaults(formControlKind, element, styleMap, textStyle, context); + } + + var specifiedContentWidth = ParseLength(styleMap, "width", containingWidth, float.NaN, allowAuto: true); + var specifiedContentHeight = ParseLength(styleMap, "height", containingWidth, float.NaN, allowAuto: true); + + if (float.IsNaN(specifiedContentWidth) || float.IsNaN(specifiedContentHeight)) + { + return false; + } + + // Reuses the exact same box-model resolution LayoutElement itself calls for every other + // element, rather than re-deriving border/padding/margin independently, so this prediction + // can never quietly drift out of sync with what actually gets laid out. + var box = ResolveBoxStyle(styleMap, element); + width = box.BorderWidth.Left + box.Padding.Left + specifiedContentWidth + box.Padding.Right + box.BorderWidth.Right; + height = box.BorderWidth.Top + box.Padding.Top + specifiedContentHeight + box.Padding.Bottom + box.BorderWidth.Bottom; + + // An auto horizontal margin resolves to NaN from ParseLength (mirroring width/height's own + // auto signal) - correct for a block-level box's own centering math, but a non-block box's + // auto margin simply computes to 0 per spec, since there is no "available space" to center + // within on an inline-formatting-context line the way there is for text-align:center. + marginLeft = float.IsNaN(box.Margin.Left) ? 0f : box.Margin.Left; + marginRight = float.IsNaN(box.Margin.Right) ? 0f : box.Margin.Right; + marginTop = box.Margin.Top; + marginBottom = box.Margin.Bottom; + + return true; + } + private static IEnumerable CollectTableRows(ElementRenderNode tableNode) { foreach (var child in tableNode.Children) @@ -3758,6 +3964,523 @@ private static bool TryParseDataUri(string? source, out byte[]? bytes, out strin return bytes.Length > 0; } + /// + /// Identifies which kind of default styling/content-painting a form control needs. `None` + /// covers every non-form element, so callers can skip all of the form-control machinery with a + /// single check. `Hidden` (`input[type=hidden]`) never lays out or paints at all - handled by + /// an early return in LayoutElement rather than here, since AngleSharp.Css's UA + /// stylesheet does not give it `display: none` the way a real browser's does (every `<input>` + /// type computes to plain `inline-block` in this AngleSharp.Css version, verified empirically). + /// + private enum FormControlKind + { + /// Not a form control. + None, + + /// + /// A single-line text value box: text, and every other textual `<input>` type this + /// renderer treats identically (`search`, `url`, `tel`, `email`, `number`, the date/time + /// family, and any unrecognized/absent type - matching the HTML spec's "unknown type falls + /// back to text" rule), plus `password` (masked with bullet characters). + /// + TextLike, + + /// `input[type=checkbox]`. + Checkbox, + + /// `input[type=radio]`. + Radio, + + /// `input[type=color]`. + Color, + + /// + /// A clickable, label-centered button box: `<button>` and + /// `input[type=button|submit|reset]`. + /// + Button, + + /// `<select>` - shows only its selected `<option>`'s text, never every option. + Select, + + /// `<textarea>` - gets the same box chrome as a text input, but keeps its real + /// child text node flowing through the ordinary block child-layout path for its content. + TextArea, + + /// `input[type=hidden]` - never laid out or painted; see the type's own remarks. + Hidden, + } + + /// + /// A common browser checkbox/radio accent color (Chrome/Edge's default `accent-color`), + /// approximated as a fixed constant - this renderer does not parse the CSS `accent-color` + /// property itself, a deliberate scope cut for a rarely-overridden value. + /// + private static readonly RenderColor FormControlAccentColor = new(26, 115, 232, 255); + + /// + /// The bundled sans-serif font's own ascent, as a fraction of font-size, measured empirically + /// via SKPaint.FontMetrics (14.8515625px ascent at font-size 16 -> 0.928) - used to + /// center a form control's single-line text on its real visual ink rather than on the taller + /// CSS line-height box. See for why: the "baseline at the bottom + /// of the line-height box" convention the rest of this renderer uses for stacked body text + /// leaves no room for descenders/leading below the baseline, which is invisible across many + /// stacked lines but visibly pushed single-line form-control text toward the bottom of its box. + /// + private const float FormControlTextAscentRatio = 0.928f; + + /// Companion to - measured descent 3.7734375px at font-size 16 -> 0.236. + private const float FormControlTextDescentRatio = 0.236f; + + /// + /// "⌄" (DOWNWARDS ARROWHEAD, a thin chevron) rather than a custom-drawn triangle - + /// DisplayList has no generic polygon-fill primitive, so any dropdown indicator has to come + /// from a real font glyph. A plain ASCII "v" (this constant's original value) is guaranteed to + /// exist in every bundled font but reads as a literal letter, not an icon; U+2304 was verified + /// - by rendering several candidate glyphs and inspecting the actual pixels, not assumed from a + /// coverage table - to exist in the bundled DejaVu Sans as a proper thin chevron shape close to + /// a real browser's own native indicator, unlike, for example, U+23D7 which the bundled font + /// has no glyph for at all (renders as a hollow "tofu" box). Shared between the default-width + /// measurement in and the actual paint in + /// so the two can never disagree about how much room it needs. + /// + private const string FormControlSelectArrowGlyph = "⌄"; + + /// + /// A downward correction applied only to 's own + /// baseline, on top of the ordinary text baseline every other form-control label already + /// centers on via /. + /// Those two ratios approximate *ordinary latin text's* ink extents (built from measuring a + /// word like "Second"), but "⌄" is a short symbol glyph whose own ink sits much closer to the + /// baseline - measured via SKPaint.MeasureText's tight bounding box at font-size 16: + /// "⌄" spans roughly 6px above the baseline to 1px below it (a 7px-tall glyph, vertical ink + /// center ~2.5px above baseline), while "Second" spans roughly 14px above to 2px below (a + /// 16px-tall run, vertical ink center ~6px above baseline). Painting both at the *same* + /// baseline - which is what correctly centers the text label - therefore left the arrow's own, + /// much shorter ink sitting visibly low in the box: a real, confirmed bug, not a hypothetical + /// one (caught from a direct visual report, not assumed). This ratio is the measured difference + /// between those two ink-centers as a fraction of font-size (~-3px at font-size 16, i.e. + /// -3/16), shifting the arrow's baseline up just enough that its own ink centers where the + /// text's ink already does. + /// + private const float FormControlSelectArrowVerticalOffsetRatio = -0.19f; + + private static FormControlKind ResolveFormControlKind(IElement element) + { + var tagName = element.LocalName; + + if (string.Equals(tagName, "textarea", StringComparison.OrdinalIgnoreCase)) + { + return FormControlKind.TextArea; + } + + if (string.Equals(tagName, "select", StringComparison.OrdinalIgnoreCase)) + { + return FormControlKind.Select; + } + + if (string.Equals(tagName, "button", StringComparison.OrdinalIgnoreCase)) + { + return FormControlKind.Button; + } + + if (!string.Equals(tagName, "input", StringComparison.OrdinalIgnoreCase)) + { + return FormControlKind.None; + } + + var type = element.GetAttribute("type")?.Trim().ToLowerInvariant(); + + return type switch + { + "checkbox" => FormControlKind.Checkbox, + "radio" => FormControlKind.Radio, + "color" => FormControlKind.Color, + "button" or "submit" or "reset" => FormControlKind.Button, + "hidden" => FormControlKind.Hidden, + _ => FormControlKind.TextLike, + }; + } + + /// + /// Fills in the browser-like default declarations a form control needs (border, padding, + /// background, and a natural size) directly into its style map, but only for whichever + /// individual properties the author has not already set - exactly the same "synthesize the UA + /// default only where the cascade left a gap" approach already used for + /// `list-style-type`/`list-style-position`, just for a much larger set of properties at once. + /// This is what makes the defaults "somewhat overridable, like in real browsers": setting + /// `border`, `background-color`, `padding`, or an explicit `width`/`height` in CSS pre-empts + /// the corresponding default below exactly as it would in a real browser's form-control + /// rendering, while every property the author leaves alone still gets a sensible UA look + /// instead of rendering as an invisible, zero-size box (this renderer's actual previous + /// behavior for every form control, since neither this renderer nor the AngleSharp.Css UA + /// stylesheet gave them any border/padding/background/size at all). + /// + private static void ApplyFormControlDefaults( + FormControlKind kind, + IElement element, + Dictionary styleMap, + RenderTextStyle textStyle, + LayoutContext context) + { + void SetDefault(string property, string value) + { + if (!styleMap.TryGetValue(property, out var existing) || string.IsNullOrWhiteSpace(existing)) + { + styleMap[property] = value; + } + } + + // Unlike border-width/style/color (which GetPropertyValue reports as a genuinely empty + // string when nothing in the cascade set them, verified empirically), AngleSharp.Css always + // resolves a concrete border-*-radius computed value - "0px" - even for a plain, completely + // unstyled element. A bare SetDefault would see that "0px" as "the author already set this" + // and never apply the radio's circular default at all, so radius defaults specifically also + // treat the computed zero-length initial value as still-unset. + void SetDefaultRadius(string property, string value) + { + if (!styleMap.TryGetValue(property, out var existing) || + string.IsNullOrWhiteSpace(existing) || + existing.Trim() is "0px" or "0" or "0%") + { + styleMap[property] = value; + } + } + + void SetDefaultBorder() + { + foreach (var side in new[] { "top", "right", "bottom", "left" }) + { + SetDefault($"border-{side}-width", "1px"); + SetDefault($"border-{side}-style", "solid"); + SetDefault($"border-{side}-color", "#767676"); + } + } + + void SetDefaultTextPadding() + { + SetDefault("padding-top", "2px"); + SetDefault("padding-bottom", "2px"); + SetDefault("padding-left", "4px"); + SetDefault("padding-right", "4px"); + } + + // A single line's worth of content height - the box's own vertical size, not to be + // confused with the ascent/descent-based metric PaintFormControl separately uses to place + // where the *baseline* sits within that box (see FormControlTextAscentRatio/DescentRatio). + // Sizing the box itself off the full CSS line-height (rather than an independent constant + // like the previous 1.2f) keeps a text-like control's default box roomy enough for + // normal text leading, matching a real browser's own default input height reasonably + // closely. + var lineHeight = textStyle.FontSize * textStyle.LineHeightMultiplier; + + switch (kind) + { + case FormControlKind.TextLike: + SetDefaultBorder(); + SetDefaultTextPadding(); + SetDefault("background-color", "#ffffff"); + SetDefault("width", "150px"); + SetDefault("height", FormatPixelValue(lineHeight)); + break; + + case FormControlKind.Select: + { + SetDefaultBorder(); + SetDefaultTextPadding(); + // A light gray, button-like background (not the white a text-like input gets) - + // matching how a real browser's own native is never that wide + // unless its own content actually demands it. + var selectLabel = ResolveFormControlLabel(FormControlKind.Select, element); + var selectLabelWidth = MeasureTextWidth(context, selectLabel, textStyle); + var arrowWidth = MeasureTextWidth(context, FormControlSelectArrowGlyph, textStyle); + var arrowGap = textStyle.FontSize * 0.5f; + SetDefault("width", FormatPixelValue(Math.Max(30f, selectLabelWidth + arrowGap + arrowWidth))); + break; + } + + case FormControlKind.TextArea: + SetDefaultBorder(); + SetDefaultTextPadding(); + SetDefault("background-color", "#ffffff"); + SetDefault("width", "150px"); + // Only a floor, not a cap: an auto-sized box's height is already + // Math.Max(specifiedHeight, autoContentHeight) throughout this renderer, so a + //