From 4d91c3851b3b2afee365b7ce019226b78df5d34c Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sat, 1 Aug 2026 21:42:43 +0200 Subject: [PATCH 01/24] Added gradient support --- CHANGELOG.md | 6 + .../HtmlRendererTests.cs | 88 ++++ src/AngleSharp.Renderer/HtmlRenderer.cs | 381 +++++++++++++++++- .../Rendering/DisplayList.cs | 19 +- .../Rendering/RenderPaint.cs | 54 +++ .../Skia/SkiaRenderBackend.cs | 79 +++- 6 files changed, 609 insertions(+), 18 deletions(-) create mode 100644 src/AngleSharp.Renderer/Rendering/RenderPaint.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ab77c0..0a26722 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# 0.3.0 + +Released on Monday, August 3 2026. + +- Added support for gradients. + # 0.2.0 Released on Friday, July 31 2026. diff --git a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs index 1f2c771..85c6f07 100644 --- a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs +++ b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs @@ -24,6 +24,94 @@ public async Task BuildDisplayList_IncludesBackgroundAndTextCommands() Assert.Contains(displayList.Commands, command => command is DrawTextCommand); } + [Fact] + public async Task BuildDisplayList_ParsesLinearGradientBackgrounds() + { + var document = await ParseAsync(""" + +
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + { + Width = 240, + Height = 160, + FontSize = 16f, + }); + + var gradientBackground = displayList.Commands + .OfType() + .Single(command => command.Paint is RenderGradientPaint); + + var gradient = Assert.IsType(gradientBackground.Paint).Gradient; + + Assert.Equal(RenderGradientKind.Linear, gradient.Kind); + Assert.Equal(2, gradient.Stops.Count); + Assert.Equal(new RenderColor(255, 0, 0), gradient.Stops[0].Color); + Assert.Equal(new RenderColor(0, 0, 255), gradient.Stops[1].Color); + } + + [Fact] + public async Task BuildDisplayList_ParsesRadialGradientBackgrounds() + { + var document = await ParseAsync(""" + +
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + { + Width = 240, + Height = 160, + FontSize = 16f, + }); + + var gradientBackground = displayList.Commands + .OfType() + .Single(command => command.Paint is RenderGradientPaint); + + var gradient = Assert.IsType(gradientBackground.Paint).Gradient; + + Assert.Equal(RenderGradientKind.Radial, gradient.Kind); + Assert.Equal(2, gradient.Stops.Count); + Assert.Equal(new RenderColor(255, 0, 0), gradient.Stops[0].Color); + Assert.Equal(new RenderColor(0, 0, 255), gradient.Stops[1].Color); + } + + [Fact] + public async Task BuildDisplayList_ParsesConicGradientBackgrounds() + { + var document = await ParseAsync(""" + +
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + { + Width = 240, + Height = 160, + FontSize = 16f, + }); + + var gradientBackground = displayList.Commands + .OfType() + .Single(command => command.Paint is RenderGradientPaint); + + var gradient = Assert.IsType(gradientBackground.Paint).Gradient; + + Assert.Equal(RenderGradientKind.Conic, gradient.Kind); + Assert.Equal(3, gradient.Stops.Count); + Assert.Equal(new RenderColor(255, 0, 0), gradient.Stops[0].Color); + Assert.Equal(new RenderColor(0, 255, 0), gradient.Stops[1].Color); + Assert.Equal(new RenderColor(0, 0, 255), gradient.Stops[2].Color); + } + [Fact] public async Task BuildDisplayList_PropagatesFontSizeStyleAndDecorationToTextCommands() { diff --git a/src/AngleSharp.Renderer/HtmlRenderer.cs b/src/AngleSharp.Renderer/HtmlRenderer.cs index 5c037f6..62afd03 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.cs @@ -79,6 +79,8 @@ private static DisplayList BuildDisplayList(IDocument document, RenderViewport v return displayList; } + PrepareDocumentForRendering(document); + var renderDevice = new DefaultRenderDevice { ViewPortWidth = viewport.Width, @@ -183,7 +185,7 @@ private static void LayoutElement( { var element = node.Ref; var computedStyle = node.ComputedStyle; - var styleMap = CreateStyleMap(node.ComputedStyle); + var styleMap = CreateStyleMap(node.ComputedStyle, node.Ref); if (!node.IsVisible()) { @@ -519,7 +521,7 @@ private static void LayoutElement( effectiveMarginBottom = CollapseMargins(marginBottom, childPreviousBlockMarginBottom); } - PaintBackground(displayList, box.BackgroundColor, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight); + PaintBackground(displayList, box.BackgroundPaint, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight); PaintBorder(displayList, box.BorderColor, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderWidth); PaintOutline(displayList, styleMap, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight); @@ -1231,7 +1233,75 @@ private static float ParseLineHeight(Dictionary styleMap, float return defaultValue; } - private static Dictionary CreateStyleMap(ICssStyleDeclaration style) + private static void PrepareDocumentForRendering(IDocument document) + { + ArgumentNullException.ThrowIfNull(document); + + foreach (var element in document.All.OfType()) + { + var styleAttribute = element.GetAttribute("style"); + + if (string.IsNullOrWhiteSpace(styleAttribute)) + { + continue; + } + + if (!TryExtractGradientBackground(styleAttribute, out var gradientValue, out var updatedStyle)) + { + continue; + } + + element.SetAttribute("style", updatedStyle); + element.SetAttribute("data-render-gradient", gradientValue); + } + } + + private static bool TryExtractGradientBackground(string styleAttribute, out string gradientValue, out string updatedStyle) + { + gradientValue = string.Empty; + updatedStyle = styleAttribute; + + if (!styleAttribute.Contains("background-image", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var declarations = styleAttribute.Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + var remaining = new List(); + + foreach (var declaration in declarations) + { + var separator = declaration.IndexOf(':'); + if (separator <= 0) + { + continue; + } + + var property = declaration[..separator].Trim(); + var value = declaration[(separator + 1)..].Trim(); + + if (string.Equals(property, "background-image", StringComparison.OrdinalIgnoreCase) && + (value.StartsWith("linear-gradient", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("radial-gradient", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("conic-gradient", StringComparison.OrdinalIgnoreCase))) + { + gradientValue = value; + continue; + } + + remaining.Add(declaration); + } + + if (string.IsNullOrWhiteSpace(gradientValue)) + { + return false; + } + + updatedStyle = string.Join(";", remaining); + return true; + } + + private static Dictionary CreateStyleMap(ICssStyleDeclaration style, IElement? element = null) { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -1276,6 +1346,19 @@ private static Dictionary CreateStyleMap(ICssStyleDeclaration st AddIfPresent(map, "outline-color", style.GetPropertyValue("outline-color")); AddIfPresent(map, "background-color", style.GetBackgroundColor()); + + var backgroundImageValue = element is not null + ? element.GetAttribute("data-render-gradient") + : null; + + if (!string.IsNullOrWhiteSpace(backgroundImageValue)) + { + AddIfPresent(map, "background-image", backgroundImageValue); + } + else + { + AddIfPresent(map, "background-image", style.GetPropertyValue("background-image")); + } AddIfPresent(map, "font-size", style.GetFontSize()); AddIfPresent(map, "font-family", style.GetFontFamily()); AddIfPresent(map, "font-weight", style.GetPropertyValue("font-weight")); @@ -1341,7 +1424,7 @@ private static bool TryGetFirstCollapsibleChildTopMargin(ElementRenderNode node, return false; } - var childStyle = CreateStyleMap(childElement.ComputedStyle); + var childStyle = CreateStyleMap(childElement.ComputedStyle, childElement.Ref); marginTop = ParseLength(childStyle, "margin-top", containingWidth, 0f, allowAuto: false); return true; } @@ -1362,7 +1445,7 @@ private static IEnumerable OrderChildrenForPainting(IEnumerable 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 borderColor = ParseColor( styleMap.TryGetValue("border-top-color", out var topColor) ? topColor : styleMap.TryGetValue("border-right-color", out var rightColor) ? rightColor : @@ -1461,7 +1545,7 @@ private static BoxStyle ResolveBoxStyle(Dictionary styleMap) null, RenderColor.Black); - return new BoxStyle(margin, padding, borderWidth, backgroundColor, borderColor); + return new BoxStyle(margin, padding, borderWidth, backgroundPaint, borderColor); } private static EdgeBorderStyle ResolveBorderStyles(Dictionary styleMap) @@ -1564,14 +1648,28 @@ private static void PaintOutline(DisplayList displayList, Dictionary styleMap, RenderColor fallbackColor) + { + if (!styleMap.TryGetValue("background-image", out var backgroundImage) || string.IsNullOrWhiteSpace(backgroundImage)) + { + return new RenderColorPaint(fallbackColor); + } + + return ParseGradientPaint(backgroundImage, fallbackColor); + } + + private static RenderPaint ParseGradientPaint(string rawValue, RenderColor fallbackColor) + { + var value = rawValue.Trim(); + + if (value.StartsWith("linear-gradient", StringComparison.OrdinalIgnoreCase)) + { + return new RenderGradientPaint(ParseLinearGradient(value, fallbackColor)); + } + + if (value.StartsWith("radial-gradient", StringComparison.OrdinalIgnoreCase)) + { + return new RenderGradientPaint(ParseRadialGradient(value, fallbackColor)); + } + + if (value.StartsWith("conic-gradient", StringComparison.OrdinalIgnoreCase)) + { + return new RenderGradientPaint(ParseConicGradient(value, fallbackColor)); + } + + return new RenderColorPaint(fallbackColor); + } + + private static RenderGradient ParseLinearGradient(string rawValue, RenderColor fallbackColor) + { + var inner = ExtractGradientInnerExpression(rawValue, "linear-gradient"); + var parts = SplitGradientArguments(inner); + var startIndex = 0; + var angleDegrees = 90f; + + if (parts.Length > 0) + { + var first = parts[0].Trim(); + + if (TryParseDirection(first, out var parsedAngle)) + { + angleDegrees = parsedAngle; + startIndex = 1; + } + } + + var stops = ParseGradientStops(parts.Skip(startIndex).ToArray(), fallbackColor); + return new RenderGradient(RenderGradientKind.Linear, stops, AngleDegrees: angleDegrees); + } + + private static RenderGradient ParseRadialGradient(string rawValue, RenderColor fallbackColor) + { + var inner = ExtractGradientInnerExpression(rawValue, "radial-gradient"); + var parts = SplitGradientArguments(inner); + var startIndex = 0; + + if (parts.Length > 0) + { + var first = parts[0].Trim(); + if (first.StartsWith("circle", StringComparison.OrdinalIgnoreCase) || first.StartsWith("ellipse", StringComparison.OrdinalIgnoreCase)) + { + startIndex = 1; + } + } + + var stops = ParseGradientStops(parts.Skip(startIndex).ToArray(), fallbackColor); + return new RenderGradient(RenderGradientKind.Radial, stops); + } + + private static RenderGradient ParseConicGradient(string rawValue, RenderColor fallbackColor) + { + var inner = ExtractGradientInnerExpression(rawValue, "conic-gradient"); + var parts = SplitGradientArguments(inner); + var startIndex = 0; + var angleDegrees = 0f; + + if (parts.Length > 0) + { + var first = parts[0].Trim(); + if (first.StartsWith("from", StringComparison.OrdinalIgnoreCase)) + { + angleDegrees = ParseAngle(first[4..].Trim()); + startIndex = 1; + } + } + + var stops = ParseGradientStops(parts.Skip(startIndex).ToArray(), fallbackColor); + return new RenderGradient(RenderGradientKind.Conic, stops, AngleDegrees: angleDegrees); + } + + private static string ExtractGradientInnerExpression(string rawValue, string functionName) + { + if (!rawValue.StartsWith(functionName, StringComparison.OrdinalIgnoreCase)) + { + return string.Empty; + } + + var opening = rawValue.IndexOf('('); + var closing = rawValue.LastIndexOf(')'); + + if (opening < 0 || closing <= opening) + { + return string.Empty; + } + + return rawValue[(opening + 1)..closing].Trim(); + } + + private static string[] SplitGradientArguments(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return []; + } + + var parts = new List(); + var current = new StringBuilder(); + var depth = 0; + + foreach (var character in value) + { + if (character == '(') + { + depth++; + } + else if (character == ')') + { + depth = Math.Max(0, depth - 1); + } + else if (character == ',' && depth == 0) + { + var part = current.ToString().Trim(); + if (part.Length > 0) + { + parts.Add(part); + } + + current.Clear(); + continue; + } + + current.Append(character); + } + + var last = current.ToString().Trim(); + if (last.Length > 0) + { + parts.Add(last); + } + + return parts.ToArray(); + } + + private static IReadOnlyList ParseGradientStops(string[] parts, RenderColor fallbackColor) + { + if (parts.Length == 0) + { + return [new RenderGradientStop(0f, fallbackColor)]; + } + + var stops = new List(parts.Length); + + for (var index = 0; index < parts.Length; index++) + { + var part = parts[index].Trim(); + if (part.Length == 0) + { + continue; + } + + var separatorIndex = part.IndexOfAny([ ' ', '\t', '\n', '\r' ]); + var colorToken = separatorIndex >= 0 ? part[..separatorIndex].Trim() : part; + var positionToken = separatorIndex >= 0 ? part[(separatorIndex + 1)..].Trim() : string.Empty; + + var color = ParseColor(colorToken, fallbackColor); + var position = string.IsNullOrWhiteSpace(positionToken) + ? (parts.Length == 1 ? 0f : (index / (float)Math.Max(1, parts.Length - 1))) + : ParseStopPosition(positionToken); + + stops.Add(new RenderGradientStop(position, color)); + } + + return stops; + } + + private static float ParseStopPosition(string rawPosition) + { + var value = rawPosition.Trim(); + if (string.IsNullOrWhiteSpace(value)) + { + return 0f; + } + + if (value.EndsWith("%", StringComparison.Ordinal) && + float.TryParse(value[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out var percent)) + { + return Math.Clamp(percent / 100f, 0f, 1f); + } + + if (float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var numeric)) + { + return Math.Clamp(numeric, 0f, 1f); + } + + return 0f; + } + + private static bool TryParseDirection(string value, out float angleDegrees) + { + angleDegrees = 90f; + var normalized = value.Trim().ToLowerInvariant(); + + if (normalized.StartsWith("to ", StringComparison.Ordinal)) + { + var direction = normalized[3..].Trim(); + angleDegrees = direction switch + { + "top" => 270f, + "right" => 0f, + "bottom" => 90f, + "left" => 180f, + "top right" or "right top" => 315f, + "top left" or "left top" => 225f, + "bottom right" or "right bottom" => 45f, + "bottom left" or "left bottom" => 135f, + _ => 90f, + }; + return true; + } + + return TryParseAngle(normalized, out angleDegrees); + } + + private static bool TryParseAngle(string value, out float angleDegrees) + { + angleDegrees = 90f; + + if (value.EndsWith("deg", StringComparison.Ordinal) && + float.TryParse(value[..^3].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var degrees)) + { + angleDegrees = degrees; + return true; + } + + if (value.EndsWith("rad", StringComparison.Ordinal) && + float.TryParse(value[..^3].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var radians)) + { + angleDegrees = radians * 180f / (float)Math.PI; + return true; + } + + return false; + } + + private static float ParseAngle(string value) + { + return TryParseAngle(value, out var angle) ? angle : 0f; + } + private static RenderColor ParseColor(string? rawColor, RenderColor fallback) { if (string.IsNullOrWhiteSpace(rawColor)) @@ -2035,6 +2396,6 @@ private readonly record struct BoxStyle( EdgeSizes Margin, EdgeSizes Padding, EdgeSizes BorderWidth, - RenderColor BackgroundColor, + RenderPaint BackgroundPaint, RenderColor BorderColor); } diff --git a/src/AngleSharp.Renderer/Rendering/DisplayList.cs b/src/AngleSharp.Renderer/Rendering/DisplayList.cs index 789d008..7a7d0bc 100644 --- a/src/AngleSharp.Renderer/Rendering/DisplayList.cs +++ b/src/AngleSharp.Renderer/Rendering/DisplayList.cs @@ -28,7 +28,16 @@ public void Add(RenderCommand command) /// /// Adds a filled rectangle command. /// - public void FillRect(RenderRect rect, RenderColor color) => Add(new FillRectCommand(rect, color)); + public void FillRect(RenderRect rect, RenderColor color) => Add(new FillRectCommand(rect, new RenderColorPaint(color))); + + /// + /// Adds a filled rectangle command using a custom paint. + /// + public void FillRect(RenderRect rect, RenderPaint paint) + { + ArgumentNullException.ThrowIfNull(paint); + Add(new FillRectCommand(rect, paint)); + } /// /// Adds a text draw command. @@ -63,7 +72,13 @@ public abstract record RenderCommand; /// /// Draws a filled rectangle. /// -public sealed record FillRectCommand(RenderRect Rect, RenderColor Color) : RenderCommand; +public sealed record FillRectCommand(RenderRect Rect, RenderPaint Paint) : RenderCommand +{ + /// + /// Gets the solid color for this command when it uses a simple color paint. + /// + public RenderColor Color => Paint is RenderColorPaint colorPaint ? colorPaint.Color : RenderColor.Transparent; +} /// /// Draws a single line of text at a baseline position. diff --git a/src/AngleSharp.Renderer/Rendering/RenderPaint.cs b/src/AngleSharp.Renderer/Rendering/RenderPaint.cs new file mode 100644 index 0000000..afd109d --- /dev/null +++ b/src/AngleSharp.Renderer/Rendering/RenderPaint.cs @@ -0,0 +1,54 @@ +namespace AngleSharp.Renderer.Rendering; + +/// +/// Represents a paint that can fill a rectangle. +/// +public abstract record RenderPaint; + +/// +/// Represents a solid-color paint. +/// +public sealed record RenderColorPaint(RenderColor Color) : RenderPaint; + +/// +/// Represents a gradient paint. +/// +public sealed record RenderGradientPaint(RenderGradient Gradient) : RenderPaint; + +/// +/// Describes a gradient definition. +/// +public sealed record RenderGradient( + RenderGradientKind Kind, + IReadOnlyList Stops, + float AngleDegrees = 90f, + float CenterX = 0.5f, + float CenterY = 0.5f, + float Radius = 0.5f, + bool IsCircle = false); + +/// +/// Describes a single gradient stop. +/// +public sealed record RenderGradientStop(float Position, RenderColor Color); + +/// +/// Describes the available gradient kinds. +/// +public enum RenderGradientKind +{ + /// + /// Draws a linear gradient. + /// + Linear, + + /// + /// Draws a radial gradient. + /// + Radial, + + /// + /// Draws a conic gradient. + /// + Conic, +} diff --git a/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs b/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs index 3f0bcc0..1cddb81 100644 --- a/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs +++ b/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs @@ -1,4 +1,5 @@ using AngleSharp.Renderer.Rendering; +using System.Linq; using System.Reflection; using System.Threading; @@ -78,12 +79,7 @@ private static void DrawFillRect(SKCanvas canvas, FillRectCommand command) return; } - using var paint = new SKPaint - { - Color = ToSkColor(command.Color), - IsAntialias = true, - Style = SKPaintStyle.Fill, - }; + using var paint = CreateFillPaint(command.Paint, command.Rect); var rect = new SKRect( command.Rect.X, @@ -94,6 +90,77 @@ private static void DrawFillRect(SKCanvas canvas, FillRectCommand command) canvas.DrawRect(rect, paint); } + private static SKPaint CreateFillPaint(RenderPaint paint, RenderRect rect) + { + return paint switch + { + RenderColorPaint colorPaint => new SKPaint + { + Color = ToSkColor(colorPaint.Color), + IsAntialias = true, + Style = SKPaintStyle.Fill, + }, + RenderGradientPaint gradientPaint => CreateGradientPaint(gradientPaint.Gradient, rect), + _ => throw new NotSupportedException($"Unsupported paint type: {paint.GetType().Name}"), + }; + } + + private static SKPaint CreateGradientPaint(RenderGradient gradient, RenderRect rect) + { + var paint = new SKPaint + { + IsAntialias = true, + Style = SKPaintStyle.Fill, + }; + + var colors = gradient.Stops.Select(stop => new SKColor(stop.Color.R, stop.Color.G, stop.Color.B, stop.Color.A)).ToArray(); + var positions = gradient.Stops.Select(stop => stop.Position).ToArray(); + + var centerX = rect.X + (rect.Width / 2f); + var centerY = rect.Y + (rect.Height / 2f); + var radius = (float)Math.Max(rect.Width, rect.Height) / 2f; + + paint.Shader = gradient.Kind switch + { + RenderGradientKind.Linear => CreateLinearGradientShader(gradient, rect), + RenderGradientKind.Radial => SKShader.CreateRadialGradient( + new SKPoint(centerX, centerY), + radius, + colors, + positions, + SKShaderTileMode.Clamp), + RenderGradientKind.Conic => SKShader.CreateSweepGradient( + new SKPoint(centerX, centerY), + colors, + positions), + _ => throw new NotSupportedException($"Unsupported gradient kind: {gradient.Kind}"), + }; + + return paint; + } + + private static SKShader CreateLinearGradientShader(RenderGradient gradient, RenderRect rect) + { + var centerX = rect.X + (rect.Width / 2f); + var centerY = rect.Y + (rect.Height / 2f); + var diagonal = Math.Sqrt((rect.Width * rect.Width) + (rect.Height * rect.Height)); + var halfDiagonal = (float)diagonal / 2f; + var radians = (gradient.AngleDegrees % 360f + 360f) % 360f; + var angle = radians * (Math.PI / 180d); + var dx = (float)Math.Cos(angle); + var dy = (float)Math.Sin(angle); + + var start = new SKPoint(centerX - (dx * halfDiagonal), centerY - (dy * halfDiagonal)); + var end = new SKPoint(centerX + (dx * halfDiagonal), centerY + (dy * halfDiagonal)); + + return SKShader.CreateLinearGradient( + start, + end, + gradient.Stops.Select(stop => new SKColor(stop.Color.R, stop.Color.G, stop.Color.B, stop.Color.A)).ToArray(), + gradient.Stops.Select(stop => stop.Position).ToArray(), + SKShaderTileMode.Clamp); + } + private static void DrawText(SKCanvas canvas, DrawTextCommand command) { var fontStyle = new SKFontStyle( From a24d1fafb95f10d6e98416dd15b1a22cf1eb7db8 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sat, 1 Aug 2026 22:05:54 +0200 Subject: [PATCH 02/24] Added image support --- CHANGELOG.md | 3 +- .../HtmlRendererTests.cs | 23 +++ src/AngleSharp.Renderer/HtmlRenderer.cs | 148 +++++++++++++++++- .../Rendering/DisplayList.cs | 9 ++ .../Rendering/DrawImageCommand.cs | 6 + .../Skia/SkiaRenderBackend.cs | 27 ++++ 6 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 src/AngleSharp.Renderer/Rendering/DrawImageCommand.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a26722..b0e9643 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ Released on Monday, August 3 2026. -- Added support for gradients. +- Added support for gradients +- Added support for images # 0.2.0 diff --git a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs index 85c6f07..367897e 100644 --- a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs +++ b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs @@ -24,6 +24,29 @@ public async Task BuildDisplayList_IncludesBackgroundAndTextCommands() Assert.Contains(displayList.Commands, command => command is DrawTextCommand); } + [Fact] + public async Task BuildDisplayList_PaintsImageElementsFromCurrentDownload() + { + var document = await ParseAsync(""" + + + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + { + Width = 240, + Height = 160, + FontSize = 16f, + }); + + var imageCommand = Assert.Single(displayList.Commands.OfType()); + Assert.Equal(40f, imageCommand.Rect.Width); + Assert.Equal(20f, imageCommand.Rect.Height); + Assert.NotEmpty(imageCommand.Image.Data); + } + [Fact] public async Task BuildDisplayList_ParsesLinearGradientBackgrounds() { diff --git a/src/AngleSharp.Renderer/HtmlRenderer.cs b/src/AngleSharp.Renderer/HtmlRenderer.cs index 62afd03..1803a56 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.IO; using System.Linq; using System.Text; @@ -6,9 +7,12 @@ using AngleSharp.Css.Dom; using AngleSharp.Css.RenderTree; using AngleSharp.Dom; +using AngleSharp.Io; using AngleSharp.Renderer.Rendering; using AngleSharp.Renderer.Skia; +using SkiaSharp; + namespace AngleSharp.Renderer; /// @@ -214,7 +218,7 @@ private static void LayoutElement( return; } - var renderAsBlock = ShouldRenderAsBlock(computedStyle); + var renderAsBlock = ShouldRenderAsBlock(computedStyle) || string.Equals(tagName, "img", StringComparison.OrdinalIgnoreCase); var isInlineBlock = IsInlineBlock(computedStyle); var currentTextStyle = ResolveTextStyle(styleMap, inheritedTextStyle); @@ -525,6 +529,12 @@ private static void LayoutElement( PaintBorder(displayList, box.BorderColor, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderWidth); PaintOutline(displayList, styleMap, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight); + if (string.Equals(tagName, "img", StringComparison.OrdinalIgnoreCase) && + TryResolveImage(node, styleMap, flowContainingWidth, borderBoxX + borderLeft + paddingLeft, borderBoxY + borderTop + paddingTop, out var image, out var imageRect)) + { + displayList.DrawImage(imageRect, image!); + } + if (isFloatLeft) { var floatRightEdge = (flowBorderBoxX + borderBoxWidth) - containingX; @@ -1648,6 +1658,142 @@ private static void PaintOutline(DisplayList displayList, Dictionary styleMap, float containingWidth, float x, float y, out RenderedImage? image, out RenderRect rect) + { + image = null; + rect = default; + + if (!string.Equals(node.Ref.LocalName, "img", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var source = node.Ref.GetAttribute("src"); + byte[]? bytes = null; + string? mimeType = null; + + if (node.Ref is ILoadableElement loadableElement && loadableElement.CurrentDownload is { Task: not null } download) + { + IResponse? response; + + try + { + response = download.Task.GetAwaiter().GetResult(); + } + catch + { + response = null; + } + + if (response?.Content is not null) + { + using var sourceStream = response.Content; + using var memoryStream = new MemoryStream(); + sourceStream.CopyTo(memoryStream); + bytes = memoryStream.ToArray(); + mimeType = response.Headers?.TryGetValue("Content-Type", out var contentType) == true && !string.IsNullOrWhiteSpace(contentType) + ? contentType + : "image/unknown"; + } + } + + if (bytes is null && TryParseDataUri(source, out var dataUriBytes, out var dataUriMimeType)) + { + bytes = dataUriBytes; + mimeType = dataUriMimeType; + } + + if (bytes is null || bytes.Length == 0) + { + return false; + } + + using var skImage = SKImage.FromEncodedData(bytes); + if (skImage is null) + { + return false; + } + + var width = ParseLength(styleMap, "width", containingWidth, float.NaN, allowAuto: true); + var height = ParseLength(styleMap, "height", containingWidth, float.NaN, allowAuto: true); + + var naturalWidth = skImage.Width; + var naturalHeight = skImage.Height; + + if (float.IsNaN(width) && float.IsNaN(height)) + { + width = naturalWidth; + height = naturalHeight; + } + else if (float.IsNaN(width) && !float.IsNaN(height) && naturalWidth > 0f) + { + width = (height / naturalHeight) * naturalWidth; + } + else if (!float.IsNaN(width) && float.IsNaN(height) && naturalHeight > 0f) + { + height = (width / naturalWidth) * naturalHeight; + } + + if (float.IsNaN(width) || float.IsNaN(height) || width <= 0f || height <= 0f) + { + width = naturalWidth; + height = naturalHeight; + } + + image = new RenderedImage(bytes, (int)Math.Max(1, Math.Round(width)), (int)Math.Max(1, Math.Round(height)), mimeType ?? "image/unknown"); + rect = new RenderRect(x, y, width, height); + return true; + } + + private static bool TryParseDataUri(string? source, out byte[]? bytes, out string? mimeType) + { + bytes = null; + mimeType = null; + + if (string.IsNullOrWhiteSpace(source) || !source.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var commaIndex = source.IndexOf(','); + if (commaIndex <= 5) + { + return false; + } + + var header = source[5..commaIndex]; + var isBase64 = header.EndsWith(";base64", StringComparison.OrdinalIgnoreCase); + var mediaType = isBase64 ? header[..^7] : header; + var payload = source[(commaIndex + 1)..]; + + if (string.IsNullOrEmpty(mediaType)) + { + mimeType = "image/unknown"; + } + else if (mediaType.StartsWith(";", StringComparison.Ordinal)) + { + mimeType = "image/unknown"; + } + else + { + mimeType = mediaType; + } + + try + { + bytes = isBase64 + ? Convert.FromBase64String(payload) + : Encoding.UTF8.GetBytes(Uri.UnescapeDataString(payload)); + } + catch + { + bytes = null; + return false; + } + + return bytes.Length > 0; + } + private static void PaintBackground(DisplayList displayList, RenderPaint paint, float x, float y, float width, float height) { if (width <= 0f || height <= 0f) diff --git a/src/AngleSharp.Renderer/Rendering/DisplayList.cs b/src/AngleSharp.Renderer/Rendering/DisplayList.cs index 7a7d0bc..a550870 100644 --- a/src/AngleSharp.Renderer/Rendering/DisplayList.cs +++ b/src/AngleSharp.Renderer/Rendering/DisplayList.cs @@ -39,6 +39,15 @@ public void FillRect(RenderRect rect, RenderPaint paint) Add(new FillRectCommand(rect, paint)); } + /// + /// Adds an image draw command. + /// + public void DrawImage(RenderRect rect, RenderedImage image) + { + ArgumentNullException.ThrowIfNull(image); + Add(new DrawImageCommand(rect, image)); + } + /// /// Adds a text draw command. /// diff --git a/src/AngleSharp.Renderer/Rendering/DrawImageCommand.cs b/src/AngleSharp.Renderer/Rendering/DrawImageCommand.cs new file mode 100644 index 0000000..ea6fa42 --- /dev/null +++ b/src/AngleSharp.Renderer/Rendering/DrawImageCommand.cs @@ -0,0 +1,6 @@ +namespace AngleSharp.Renderer.Rendering; + +/// +/// Draws an image at a given rectangle. +/// +public sealed record DrawImageCommand(RenderRect Rect, RenderedImage Image) : RenderCommand; diff --git a/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs b/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs index 1cddb81..b666df1 100644 --- a/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs +++ b/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs @@ -66,6 +66,9 @@ private static void DrawCommand(SKCanvas canvas, RenderCommand command) case FillRectCommand fill: DrawFillRect(canvas, fill); break; + case DrawImageCommand image: + DrawImage(canvas, image); + break; case DrawTextCommand text: DrawText(canvas, text); break; @@ -161,6 +164,30 @@ private static SKShader CreateLinearGradientShader(RenderGradient gradient, Rend SKShaderTileMode.Clamp); } + private static void DrawImage(SKCanvas canvas, DrawImageCommand command) + { + if (command.Image.Data.Length == 0 || command.Rect.IsEmpty) + { + return; + } + + using var data = SKData.CreateCopy(command.Image.Data); + using var image = SKImage.FromEncodedData(data); + if (image is null) + { + 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 paint = new SKPaint { IsAntialias = true, FilterQuality = SKFilterQuality.High }; + canvas.DrawImage(image, rect, paint); + } + private static void DrawText(SKCanvas canvas, DrawTextCommand command) { var fontStyle = new SKFontStyle( From b136016a58c3d6682842965357e0bd7167b0d401 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sat, 1 Aug 2026 23:00:18 +0200 Subject: [PATCH 03/24] Added flexbox --- CHANGELOG.md | 3 +- .../HtmlRendererTests.cs | 327 ++++++++++ src/AngleSharp.Renderer/HtmlRenderer.cs | 587 +++++++++++++++++- 3 files changed, 910 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0e9643..d9c498d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,8 @@ Released on Monday, August 3 2026. - Added support for gradients -- Added support for images +- Added rendering images +- Added flexbox layout mode # 0.2.0 diff --git a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs index 367897e..d787581 100644 --- a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs +++ b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs @@ -290,6 +290,333 @@ public async Task BuildDisplayList_UsesColumnWidthsFromColgroup() Assert.True(cellBackground!.Rect.Width >= 100f, $"Expected the colgroup width to expand the cell geometry, but got {cellBackground.Rect.Width}."); } + [Fact] + public async Task BuildDisplayList_LaysOutFlexItemsInCenteredRow() + { + var document = await ParseAsync(""" + +
+
+
+
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + { + Width = 200, + Height = 120, + FontSize = 16f, + }); + + var containerBackground = displayList.Commands + .OfType() + .First(command => command.Rect.Width == 100f && command.Rect.Height == 40f); + + var childBackgrounds = displayList.Commands + .OfType() + .Where(command => command.Rect.Width == 20f && command.Rect.Height == 10f) + .OrderBy(command => command.Rect.X) + .ToArray(); + + Assert.Equal(2, childBackgrounds.Length); + Assert.Equal(containerBackground.Rect.X + 30f, childBackgrounds[0].Rect.X); + Assert.Equal(containerBackground.Rect.Y + 15f, childBackgrounds[0].Rect.Y); + Assert.Equal(containerBackground.Rect.X + 50f, childBackgrounds[1].Rect.X); + Assert.Equal(containerBackground.Rect.Y + 15f, childBackgrounds[1].Rect.Y); + } + + [Fact] + public async Task BuildDisplayList_LaysOutFlexItemsInColumnDirection() + { + var document = await ParseAsync(""" + +
+
+
+
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + { + Width = 200, + Height = 140, + FontSize = 16f, + }); + + var containerBackground = displayList.Commands + .OfType() + .First(command => command.Rect.Width == 100f && command.Rect.Height == 60f); + + var childBackgrounds = displayList.Commands + .OfType() + .Where(command => command.Rect.Width == 20f && command.Rect.Height == 10f) + .OrderBy(command => command.Rect.Y) + .ToArray(); + + Assert.Equal(2, childBackgrounds.Length); + Assert.Equal(containerBackground.Rect.X + 40f, childBackgrounds[0].Rect.X); + Assert.Equal(containerBackground.Rect.Y, childBackgrounds[0].Rect.Y); + Assert.Equal(containerBackground.Rect.X + 40f, childBackgrounds[1].Rect.X); + Assert.Equal(containerBackground.Rect.Y + 10f, childBackgrounds[1].Rect.Y); + } + + [Fact] + public async Task BuildDisplayList_AppliesFlexGrowToItems() + { + var document = await ParseAsync(""" + +
+
+
+
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + { + Width = 200, + Height = 120, + FontSize = 16f, + }); + + var childBackgrounds = displayList.Commands + .OfType() + .Where(command => command.Rect.Width == 50f && command.Rect.Height == 10f) + .OrderBy(command => command.Rect.X) + .ToArray(); + + Assert.Equal(2, childBackgrounds.Length); + Assert.Equal(childBackgrounds[0].Rect.X, childBackgrounds[0].Rect.X); + Assert.Equal(childBackgrounds[0].Rect.X + 50f, childBackgrounds[1].Rect.X); + } + + [Fact] + public async Task BuildDisplayList_WrapsItemsToNewLinesWhenNeeded() + { + var document = await ParseAsync(""" + +
+
+
+
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + { + Width = 200, + Height = 120, + FontSize = 16f, + }); + + var childBackgrounds = displayList.Commands + .OfType() + .Where(command => command.Rect.Width == 40f && command.Rect.Height == 10f) + .OrderBy(command => command.Rect.Y) + .ThenBy(command => command.Rect.X) + .ToArray(); + + Assert.Equal(2, childBackgrounds.Length); + Assert.True(childBackgrounds[1].Rect.Y > childBackgrounds[0].Rect.Y); + } + + [Fact] + public async Task BuildDisplayList_UsesAlignSelfForIndividualItems() + { + var document = await ParseAsync(""" + +
+
+
+
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + { + Width = 200, + Height = 120, + FontSize = 16f, + }); + + var childBackgrounds = displayList.Commands + .OfType() + .Where(command => command.Rect.Width == 20f && command.Rect.Height == 10f) + .OrderBy(command => command.Rect.X) + .ToArray(); + + Assert.Equal(2, childBackgrounds.Length); + Assert.True(childBackgrounds[0].Rect.Y < childBackgrounds[1].Rect.Y); + } + + [Fact] + public async Task BuildDisplayList_UsesFlexEndJustification() + { + var document = await ParseAsync(""" + +
+
+
+
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + { + Width = 200, + Height = 120, + FontSize = 16f, + }); + + var childBackgrounds = displayList.Commands + .OfType() + .Where(command => command.Rect.Width == 20f && command.Rect.Height == 10f) + .OrderBy(command => command.Rect.X) + .ToArray(); + + Assert.Equal(2, childBackgrounds.Length); + Assert.Equal(76f, childBackgrounds[0].Rect.X); + Assert.Equal(96f, childBackgrounds[1].Rect.X); + } + + [Fact] + public async Task BuildDisplayList_UsesSpaceBetweenJustification() + { + var document = await ParseAsync(""" + +
+
+
+
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + { + Width = 200, + Height = 120, + FontSize = 16f, + }); + + var childBackgrounds = displayList.Commands + .OfType() + .Where(command => command.Rect.Width == 20f && command.Rect.Height == 10f) + .OrderBy(command => command.Rect.X) + .ToArray(); + + Assert.Equal(2, childBackgrounds.Length); + Assert.Equal(31f, childBackgrounds[0].Rect.X); + Assert.Equal(81f, childBackgrounds[1].Rect.X); + } + + [Fact] + public async Task BuildDisplayList_UsesRowReverseDirection() + { + var document = await ParseAsync(""" + +
+
+
+
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + { + Width = 200, + Height = 120, + FontSize = 16f, + }); + + var containerBackground = displayList.Commands + .OfType() + .First(command => command.Rect.Width == 100f && command.Rect.Height == 40f); + + var childBackgrounds = displayList.Commands + .OfType() + .Where(command => command.Rect.Width == 20f && command.Rect.Height == 10f) + .OrderBy(command => command.Rect.X) + .ToArray(); + + Assert.Equal(2, childBackgrounds.Length); + Assert.Equal(containerBackground.Rect.X + 60f, childBackgrounds[0].Rect.X); + Assert.Equal(containerBackground.Rect.X + 80f, childBackgrounds[1].Rect.X); + } + + [Fact] + public async Task BuildDisplayList_UsesColumnReverseDirection() + { + var document = await ParseAsync(""" + +
+
+
+
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + { + Width = 200, + Height = 140, + FontSize = 16f, + }); + + var containerBackground = displayList.Commands + .OfType() + .First(command => command.Rect.Width == 100f && command.Rect.Height == 60f); + + var childBackgrounds = displayList.Commands + .OfType() + .Where(command => command.Rect.Width == 20f && command.Rect.Height == 10f) + .OrderBy(command => command.Rect.Y) + .ToArray(); + + Assert.Equal(2, childBackgrounds.Length); + Assert.Equal(containerBackground.Rect.Y + 40f, childBackgrounds[0].Rect.Y); + Assert.Equal(containerBackground.Rect.Y + 50f, childBackgrounds[1].Rect.Y); + } + + [Fact] + public async Task BuildDisplayList_UsesFlexBasisForMainSize() + { + var document = await ParseAsync(""" + +
+
+
+
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + { + Width = 200, + Height = 120, + FontSize = 16f, + }); + + var childBackgrounds = displayList.Commands + .OfType() + .Where(command => command.Rect.Width == 40f && command.Rect.Height == 10f) + .OrderBy(command => command.Rect.X) + .ToArray(); + + Assert.Single(childBackgrounds); + } + [Fact] public async Task BuildDisplayList_AppliesLetterSpacingToTextCommands() { diff --git a/src/AngleSharp.Renderer/HtmlRenderer.cs b/src/AngleSharp.Renderer/HtmlRenderer.cs index 1803a56..754372e 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.cs @@ -156,7 +156,11 @@ private static void LayoutNode( RenderTextStyle textStyle, HtmlRenderOptions options, DisplayList displayList, - float maxY) + float maxY, + bool isFlexItem = false, + bool isRowDirection = true, + float? flexMainSize = null, + float? flexCrossSize = null) { switch (node) { @@ -164,7 +168,7 @@ private static void LayoutNode( LayoutTextNode(textNode.Ref, containingX, containingWidth, ref cursorY, ref previousBlockMarginBottom, ref suppressNextBlockTopMargin, ref activeFloatLeftOffset, ref activeFloatBottom, ref textIndentConsumed, textStyle, options, displayList, maxY); return; case ElementRenderNode element: - LayoutElement(element, containingX, containingY, containingWidth, ref cursorY, ref previousBlockMarginBottom, ref suppressNextBlockTopMargin, ref activeFloatLeftOffset, ref activeFloatBottom, ref textIndentConsumed, textStyle, options, displayList, maxY); + LayoutElement(element, containingX, containingY, containingWidth, ref cursorY, ref previousBlockMarginBottom, ref suppressNextBlockTopMargin, ref activeFloatLeftOffset, ref activeFloatBottom, ref textIndentConsumed, textStyle, options, displayList, maxY, isFlexItem, isRowDirection, flexMainSize, flexCrossSize); return; default: return; @@ -185,7 +189,11 @@ private static void LayoutElement( RenderTextStyle inheritedTextStyle, HtmlRenderOptions options, DisplayList displayList, - float maxY) + float maxY, + bool isFlexItem = false, + bool isRowDirection = true, + float? flexMainSize = null, + float? flexCrossSize = null) { var element = node.Ref; var computedStyle = node.ComputedStyle; @@ -304,7 +312,15 @@ private static void LayoutElement( effectiveMarginTop = CollapseMargins(marginTop, firstChildTopMargin); } - var specifiedContentWidth = ParseLength(styleMap, "width", flowContainingWidth, float.NaN, allowAuto: true); + var specifiedContentWidth = ResolveFlexibleContentDimension( + styleMap, + flowContainingWidth, + float.NaN, + isFlexItem, + isRowDirection, + flexMainSize, + flexCrossSize, + propertyName: "width"); ResolveHorizontalMetrics( flowContainingWidth, specifiedContentWidth, @@ -365,6 +381,40 @@ private static void LayoutElement( !IsInlineBlock(childElement.ComputedStyle)) || (child is ElementRenderNode childElementWithBr && string.Equals(childElementWithBr.Ref.LocalName, "br", StringComparison.OrdinalIgnoreCase))); + if (IsFlexContainer(styleMap)) + { + LayoutFlexContainer( + node, + contentX, + contentY, + contentWidth, + ref cursorY, + ref previousBlockMarginBottom, + ref suppressNextBlockTopMargin, + ref activeFloatLeftOffset, + ref activeFloatBottom, + ref textIndentConsumed, + currentTextStyle, + options, + displayList, + maxY, + styleMap, + borderLeft, + borderTop, + borderRight, + borderBottom, + paddingLeft, + paddingRight, + paddingTop, + paddingBottom, + box, + flowBorderBoxX, + flowBorderBoxY, + borderBoxX, + borderBoxY); + return; + } + if (!hasInlineRun) { foreach (var child in orderedChildren) @@ -508,7 +558,15 @@ private static void LayoutElement( } var autoContentHeight = Math.Max(0f, childCursorY - contentY); - var specifiedContentHeight = ParseLength(styleMap, "height", flowContainingWidth, float.NaN, allowAuto: true); + var specifiedContentHeight = ResolveFlexibleContentDimension( + styleMap, + flowContainingWidth, + float.NaN, + isFlexItem, + isRowDirection, + flexMainSize, + flexCrossSize, + propertyName: "height"); var contentHeight = float.IsNaN(specifiedContentHeight) ? autoContentHeight : Math.Max(specifiedContentHeight, autoContentHeight); var borderBoxWidth = borderLeft + paddingLeft + contentWidth + paddingRight + borderRight; @@ -556,6 +614,363 @@ private static void LayoutElement( previousBlockMarginBottom = effectiveMarginBottom + options.ParagraphSpacing; } + private readonly record struct FlexItemLayoutInfo( + IRenderNode Node, + Dictionary Style, + float Order, + float FlexGrow, + float FlexShrink, + float BaseMainSize, + float CrossSize, + string AlignSelf); + + private static void LayoutFlexContainer( + ElementRenderNode node, + float containingX, + float containingY, + float containingWidth, + ref float cursorY, + ref float previousBlockMarginBottom, + ref bool suppressNextBlockTopMargin, + ref float activeFloatLeftOffset, + ref float activeFloatBottom, + ref bool textIndentConsumed, + RenderTextStyle inheritedTextStyle, + HtmlRenderOptions options, + DisplayList displayList, + float maxY, + Dictionary styleMap, + float borderLeft, + float borderTop, + float borderRight, + float borderBottom, + float paddingLeft, + float paddingRight, + float paddingTop, + float paddingBottom, + BoxStyle box, + float flowBorderBoxX, + float flowBorderBoxY, + float borderBoxX, + float borderBoxY) + { + 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); + var justifyContent = GetJustifyContent(styleMap); + var alignItems = GetAlignItems(styleMap); + var flexWrap = GetFlexWrap(styleMap); + var alignContent = GetAlignContent(styleMap); + var flexItems = OrderChildrenForPainting(node.Children) + .Where(child => child is ElementRenderNode || child is TextRenderNode) + .Select(child => CreateFlexItemLayoutInfo(child, isRowDirection, containingWidth)) + .OrderBy(item => item.Order) + .ToList(); + + if (flexItems.Count == 0) + { + cursorY = flowBorderBoxY + borderTop + paddingTop + borderBottom + paddingBottom; + previousBlockMarginBottom = 0f; + suppressNextBlockTopMargin = false; + return; + } + + var containerMainSize = isRowDirection + ? ParseLength(styleMap, "width", containingWidth, containingWidth, allowAuto: true) + : ParseLength(styleMap, "height", containingWidth, containingWidth, allowAuto: true); + + if (float.IsNaN(containerMainSize) || containerMainSize <= 0f) + { + containerMainSize = isRowDirection ? containingWidth : containingWidth; + } + + var specifiedCrossSize = isRowDirection + ? ParseLength(styleMap, "height", containingWidth, float.NaN, allowAuto: true) + : ParseLength(styleMap, "width", containingWidth, float.NaN, allowAuto: true); + var containerCrossSize = float.IsNaN(specifiedCrossSize) ? 0f : specifiedCrossSize; + + var contentWidth = containingWidth; + var contentHeight = containerCrossSize; + + var lines = new List>(); + var currentLine = new List(); + var currentLineMainSize = 0f; + + foreach (var item in flexItems) + { + if (string.Equals(flexWrap, "wrap", StringComparison.OrdinalIgnoreCase) && currentLine.Count > 0 && currentLineMainSize + item.BaseMainSize > containerMainSize && containerMainSize > 0f) + { + lines.Add(currentLine); + currentLine = new List(); + currentLineMainSize = 0f; + } + + currentLine.Add(item); + currentLineMainSize += item.BaseMainSize; + } + + if (currentLine.Count > 0) + { + lines.Add(currentLine); + } + + var lineCrossSizes = lines.Select(line => line.Count > 0 ? line.Max(item => item.CrossSize) : 0f).ToList(); + var totalCrossSize = lineCrossSizes.Sum(); + var remainingCrossSize = Math.Max(0f, containerCrossSize - totalCrossSize); + var crossSpacing = 0f; + var currentCrossOffset = 0f; + + switch (alignContent) + { + case "center": + currentCrossOffset = remainingCrossSize / 2f; + break; + case "flex-end": + currentCrossOffset = remainingCrossSize; + break; + case "space-between": + crossSpacing = lines.Count > 1 ? remainingCrossSize / Math.Max(1, lines.Count - 1) : 0f; + break; + case "space-around": + crossSpacing = lines.Count > 0 ? remainingCrossSize / Math.Max(1, lines.Count) : 0f; + currentCrossOffset = crossSpacing / 2f; + break; + case "space-evenly": + crossSpacing = lines.Count > 0 ? remainingCrossSize / Math.Max(1, lines.Count + 1) : 0f; + currentCrossOffset = crossSpacing; + break; + default: + currentCrossOffset = 0f; + break; + } + + var childCursorY = containingY; + var childPreviousBlockMarginBottom = 0f; + var childSuppressNextBlockTopMargin = false; + var childActiveFloatLeftOffset = 0f; + var childActiveFloatBottom = 0f; + var childTextIndentConsumed = false; + var totalLineMainSize = 0f; + + for (var lineIndex = 0; lineIndex < lines.Count; lineIndex++) + { + var line = lines[lineIndex]; + var lineBaseSize = line.Sum(item => item.BaseMainSize); + var lineGrowSum = line.Sum(item => item.FlexGrow); + var lineShrinkSum = line.Sum(item => item.FlexShrink); + var lineItems = new List<(FlexItemLayoutInfo Item, float FinalMainSize)>(line.Count); + var availableMainSize = Math.Max(0f, containerMainSize - lineBaseSize); + var lineMainSize = 0f; + + foreach (var item in line) + { + var finalMainSize = item.BaseMainSize; + + if (availableMainSize > 0f && lineGrowSum > 0f) + { + finalMainSize = item.BaseMainSize + (availableMainSize * item.FlexGrow / lineGrowSum); + } + else if (availableMainSize < 0f && lineShrinkSum > 0f) + { + finalMainSize = Math.Max(0f, item.BaseMainSize + (availableMainSize * item.FlexShrink / lineShrinkSum)); + } + + lineItems.Add((item, finalMainSize)); + lineMainSize += finalMainSize; + } + + var spacerCount = Math.Max(0, lineItems.Count - 1); + var lineMainSpacing = 0f; + var lineMainStart = 0f; + + switch (justifyContent) + { + case "center": + lineMainStart = Math.Max(0f, containerMainSize - lineMainSize) / 2f; + break; + case "flex-end": + lineMainStart = Math.Max(0f, containerMainSize - lineMainSize); + break; + case "space-between": + lineMainSpacing = lineItems.Count > 1 ? Math.Max(0f, containerMainSize - lineMainSize) / spacerCount : 0f; + break; + case "space-around": + lineMainSpacing = lineItems.Count > 0 ? Math.Max(0f, containerMainSize - lineMainSize) / lineItems.Count : 0f; + lineMainStart = lineMainSpacing / 2f; + break; + case "space-evenly": + lineMainSpacing = lineItems.Count > 0 ? Math.Max(0f, containerMainSize - lineMainSize) / (lineItems.Count + 1) : 0f; + lineMainStart = lineMainSpacing; + break; + default: + lineMainStart = 0f; + break; + } + + var lineCrossSize = lineItems.Count > 0 ? lineItems.Max(entry => entry.Item.CrossSize) : 0f; + var lineCrossPosition = currentCrossOffset; + var lineCrossStart = 0f; + + if (string.Equals(alignItems, "center", StringComparison.OrdinalIgnoreCase)) + { + lineCrossStart = containerCrossSize > 0f && lineCrossSize < containerCrossSize ? (containerCrossSize - lineCrossSize) / 2f : 0f; + } + else if (string.Equals(alignItems, "flex-end", StringComparison.OrdinalIgnoreCase)) + { + lineCrossStart = containerCrossSize > 0f && lineCrossSize < containerCrossSize ? containerCrossSize - lineCrossSize : 0f; + } + else if (string.Equals(alignItems, "stretch", StringComparison.OrdinalIgnoreCase)) + { + lineCrossStart = 0f; + } + + var mainOffset = isReverseDirection ? containerMainSize - lineMainStart - lineMainSize : lineMainStart; + var currentMainOffset = 0f; + + foreach (var (item, finalMainSize) in lineItems) + { + var resolvedCrossSize = item.CrossSize; + if (string.Equals(alignItems, "stretch", StringComparison.OrdinalIgnoreCase) && resolvedCrossSize <= 0f && containerCrossSize > 0f) + { + resolvedCrossSize = containerCrossSize; + } + + var itemCrossPosition = lineCrossPosition + lineCrossStart; + var alignSelf = item.AlignSelf; + + if (string.Equals(alignSelf, "center", StringComparison.OrdinalIgnoreCase)) + { + itemCrossPosition = containerCrossSize > 0f && resolvedCrossSize < containerCrossSize ? (containerCrossSize - resolvedCrossSize) / 2f : 0f; + } + else if (string.Equals(alignSelf, "flex-end", StringComparison.OrdinalIgnoreCase)) + { + itemCrossPosition = containerCrossSize > 0f && resolvedCrossSize < containerCrossSize ? containerCrossSize - resolvedCrossSize : 0f; + } + else if (string.Equals(alignSelf, "stretch", StringComparison.OrdinalIgnoreCase) && resolvedCrossSize <= 0f && containerCrossSize > 0f) + { + resolvedCrossSize = containerCrossSize; + itemCrossPosition = 0f; + } + else if (!string.Equals(alignSelf, "auto", StringComparison.OrdinalIgnoreCase)) + { + itemCrossPosition = 0f; + } + + var itemOffset = isReverseDirection + ? mainOffset + currentMainOffset + : lineMainStart + currentMainOffset; + var childX = isRowDirection ? containingX + itemOffset : containingX + itemCrossPosition; + var childY = isRowDirection ? containingY + itemCrossPosition + lineCrossPosition : containingY + itemOffset; + var childWidth = isRowDirection ? finalMainSize : resolvedCrossSize; + var childHeight = isRowDirection ? resolvedCrossSize : finalMainSize; + + if (item.Node is TextRenderNode textNode) + { + LayoutTextNode(textNode.Ref, childX, containingWidth, ref childCursorY, ref childPreviousBlockMarginBottom, ref childSuppressNextBlockTopMargin, ref childActiveFloatLeftOffset, ref childActiveFloatBottom, ref childTextIndentConsumed, inheritedTextStyle, options, displayList, maxY); + } + else if (item.Node is ElementRenderNode elementChild) + { + var childContainingWidth = Math.Max(0f, childWidth); + var childContainingHeight = Math.Max(0f, childHeight); + var childCursor = isRowDirection ? containingY + itemCrossPosition + lineCrossPosition : containingY + itemOffset; + var childBlockCursor = childCursor; + var childPreviousBottom = 0f; + var childSuppressMargin = false; + var childTextIndent = false; + var childActiveFloatLeft = 0f; + var childActiveFloatBottomOffset = 0f; + + LayoutNode( + node: elementChild, + containingX: childX, + containingY: childY, + containingWidth: childContainingWidth, + cursorY: ref childBlockCursor, + previousBlockMarginBottom: ref childPreviousBottom, + suppressNextBlockTopMargin: ref childSuppressMargin, + activeFloatLeftOffset: ref childActiveFloatLeft, + activeFloatBottom: ref childActiveFloatBottomOffset, + textIndentConsumed: ref childTextIndent, + textStyle: inheritedTextStyle, + options: options, + displayList: displayList, + maxY: maxY, + isFlexItem: true, + isRowDirection: isRowDirection, + flexMainSize: finalMainSize, + flexCrossSize: resolvedCrossSize); + } + + currentMainOffset += finalMainSize + lineMainSpacing; + } + + currentCrossOffset += lineCrossSize + crossSpacing; + totalLineMainSize = Math.Max(totalLineMainSize, lineMainSize); + } + + var autoContentHeight = Math.Max(0f, (isRowDirection ? containerCrossSize : containerMainSize) - 0f); + var specifiedContentHeight = ParseLength(styleMap, "height", containingWidth, float.NaN, allowAuto: true); + contentHeight = float.IsNaN(specifiedContentHeight) ? Math.Max(autoContentHeight, totalLineMainSize) : Math.Max(specifiedContentHeight, autoContentHeight); + var borderBoxWidth = borderLeft + paddingLeft + containingWidth + paddingRight + borderRight; + var borderBoxHeight = borderTop + paddingTop + contentHeight + paddingBottom + borderBottom; + var canCollapseWithLastChild = borderBottom <= 0f && paddingBottom <= 0f && float.IsNaN(specifiedContentHeight); + var effectiveMarginBottom = ParseLength(styleMap, "margin-bottom", containingWidth, box.Margin.Bottom, allowAuto: false); + + if (canCollapseWithLastChild) + { + effectiveMarginBottom = CollapseMargins(effectiveMarginBottom, childPreviousBlockMarginBottom); + } + + if (box.BackgroundPaint is RenderColorPaint colorPaint && colorPaint.Color.A == 0) + { + displayList.FillRect(new RenderRect(borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight), RenderColor.Transparent); + } + else + { + PaintBackground(displayList, box.BackgroundPaint, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight); + } + + PaintBorder(displayList, box.BorderColor, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderWidth); + PaintOutline(displayList, styleMap, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight); + + if (string.Equals(node.Ref.LocalName, "img", StringComparison.OrdinalIgnoreCase) && + TryResolveImage(node, styleMap, containingWidth, borderBoxX + borderLeft + paddingLeft, borderBoxY + borderTop + paddingTop, out var image, out var imageRect)) + { + displayList.DrawImage(imageRect, image!); + } + + cursorY = flowBorderBoxY + borderBoxHeight; + previousBlockMarginBottom = effectiveMarginBottom + options.ParagraphSpacing; + suppressNextBlockTopMargin = false; + } + + private static float ResolveFlexibleContentDimension( + Dictionary styleMap, + float relativeTo, + float defaultValue, + bool isFlexItem, + bool isRowDirection, + float? flexMainSize, + float? flexCrossSize, + string propertyName) + { + if (!isFlexItem) + { + return ParseLength(styleMap, propertyName, relativeTo, defaultValue, allowAuto: true); + } + + if (string.Equals(propertyName, "width", StringComparison.OrdinalIgnoreCase)) + { + return isRowDirection + ? (flexMainSize.HasValue ? flexMainSize.Value : ParseLength(styleMap, propertyName, relativeTo, defaultValue, allowAuto: true)) + : (flexCrossSize.HasValue ? flexCrossSize.Value : ParseLength(styleMap, propertyName, relativeTo, defaultValue, allowAuto: true)); + } + + return isRowDirection + ? (flexCrossSize.HasValue ? flexCrossSize.Value : ParseLength(styleMap, propertyName, relativeTo, defaultValue, allowAuto: true)) + : (flexMainSize.HasValue ? flexMainSize.Value : ParseLength(styleMap, propertyName, relativeTo, defaultValue, allowAuto: true)); + } + private static IEnumerable CollectTableRows(ElementRenderNode tableNode) { foreach (var child in tableNode.Children) @@ -1045,6 +1460,124 @@ private static void LayoutInlineTextRun( return styleMap.TryGetValue("display", out var display) ? display : null; } + private static bool IsFlexContainer(Dictionary styleMap) + { + var display = GetDisplay(styleMap); + return string.Equals(display, "flex", StringComparison.OrdinalIgnoreCase) || string.Equals(display, "inline-flex", StringComparison.OrdinalIgnoreCase); + } + + private static string GetFlexDirection(Dictionary styleMap) + { + return styleMap.TryGetValue("flex-direction", out var direction) && !string.IsNullOrWhiteSpace(direction) + ? direction.Trim().ToLowerInvariant() + : "row"; + } + + private static string GetJustifyContent(Dictionary styleMap) + { + return styleMap.TryGetValue("justify-content", out var value) && !string.IsNullOrWhiteSpace(value) + ? value.Trim().ToLowerInvariant() + : "flex-start"; + } + + private static string GetAlignItems(Dictionary styleMap) + { + return styleMap.TryGetValue("align-items", out var value) && !string.IsNullOrWhiteSpace(value) + ? value.Trim().ToLowerInvariant() + : "stretch"; + } + + private static string GetFlexWrap(Dictionary styleMap) + { + return styleMap.TryGetValue("flex-wrap", out var value) && !string.IsNullOrWhiteSpace(value) + ? value.Trim().ToLowerInvariant() + : "nowrap"; + } + + private static string GetAlignContent(Dictionary styleMap) + { + return styleMap.TryGetValue("align-content", out var value) && !string.IsNullOrWhiteSpace(value) + ? value.Trim().ToLowerInvariant() + : "stretch"; + } + + private static float GetFlexGrow(Dictionary styleMap) + { + return styleMap.TryGetValue("flex-grow", out var value) && !string.IsNullOrWhiteSpace(value) + ? ParseLengthValue(value.Trim(), 0f, allowAuto: false) + : 0f; + } + + private static float GetFlexShrink(Dictionary styleMap) + { + return styleMap.TryGetValue("flex-shrink", out var value) && !string.IsNullOrWhiteSpace(value) + ? ParseLengthValue(value.Trim(), 1f, allowAuto: false) + : 1f; + } + + private static float GetFlexOrder(Dictionary styleMap) + { + return styleMap.TryGetValue("order", out var value) && !string.IsNullOrWhiteSpace(value) + ? ParseLengthValue(value.Trim(), 0f, allowAuto: false) + : 0f; + } + + private static string GetAlignSelf(Dictionary styleMap, string fallback) + { + if (!styleMap.TryGetValue("align-self", out var value) || string.IsNullOrWhiteSpace(value)) + { + return fallback; + } + + var normalized = value.Trim().ToLowerInvariant(); + return normalized == "auto" ? fallback : normalized; + } + + private static FlexItemLayoutInfo CreateFlexItemLayoutInfo(IRenderNode child, bool isRowDirection, float relativeTo) + { + if (child is not ElementRenderNode elementChild) + { + return new FlexItemLayoutInfo(child, new Dictionary(StringComparer.OrdinalIgnoreCase), 0f, 0f, 1f, 0f, 0f, "auto"); + } + + var childStyle = CreateStyleMap(elementChild.ComputedStyle, elementChild.Ref); + var baseMainSize = ResolveFlexBaseSize(childStyle, isRowDirection, relativeTo); + var crossSize = ResolveFlexCrossSize(childStyle, isRowDirection, relativeTo); + return new FlexItemLayoutInfo( + child, + childStyle, + GetFlexOrder(childStyle), + GetFlexGrow(childStyle), + GetFlexShrink(childStyle), + baseMainSize, + crossSize, + GetAlignSelf(childStyle, "auto")); + } + + private static float ResolveFlexBaseSize(Dictionary styleMap, bool isRowDirection, float relativeTo) + { + var flexBasis = ParseLength(styleMap, "flex-basis", relativeTo, float.NaN, allowAuto: true); + if (!float.IsNaN(flexBasis)) + { + return flexBasis; + } + + var mainSize = isRowDirection + ? ParseLength(styleMap, "width", relativeTo, float.NaN, allowAuto: true) + : ParseLength(styleMap, "height", relativeTo, float.NaN, allowAuto: true); + + return float.IsNaN(mainSize) ? 0f : mainSize; + } + + private static float ResolveFlexCrossSize(Dictionary styleMap, bool isRowDirection, float relativeTo) + { + var crossSize = isRowDirection + ? ParseLength(styleMap, "height", relativeTo, float.NaN, allowAuto: true) + : ParseLength(styleMap, "width", relativeTo, float.NaN, allowAuto: true); + + return float.IsNaN(crossSize) ? 0f : crossSize; + } + private static bool ShouldRenderAsBlock(ICssStyleDeclaration computedStyle) { var display = computedStyle.GetDisplay(); @@ -1314,8 +1847,15 @@ private static bool TryExtractGradientBackground(string styleAttribute, out stri private static Dictionary CreateStyleMap(ICssStyleDeclaration style, IElement? element = null) { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + var inlineStyle = element?.GetAttribute("style"); - AddIfPresent(map, "display", style.GetDisplay()); + var displayValue = style.GetDisplay(); + if (string.IsNullOrWhiteSpace(displayValue)) + { + displayValue = ParseStyleAttributeValue(inlineStyle, "display"); + } + + AddIfPresent(map, "display", displayValue); AddIfPresent(map, "visibility", style.GetVisibility()); AddIfPresent(map, "width", style.GetWidth()); AddIfPresent(map, "height", style.GetHeight()); @@ -1356,6 +1896,16 @@ private static Dictionary CreateStyleMap(ICssStyleDeclaration st AddIfPresent(map, "outline-color", style.GetPropertyValue("outline-color")); AddIfPresent(map, "background-color", style.GetBackgroundColor()); + AddIfPresent(map, "flex-direction", string.IsNullOrWhiteSpace(style.GetPropertyValue("flex-direction")) ? ParseStyleAttributeValue(inlineStyle, "flex-direction") : style.GetPropertyValue("flex-direction")); + AddIfPresent(map, "justify-content", string.IsNullOrWhiteSpace(style.GetPropertyValue("justify-content")) ? ParseStyleAttributeValue(inlineStyle, "justify-content") : style.GetPropertyValue("justify-content")); + AddIfPresent(map, "align-items", string.IsNullOrWhiteSpace(style.GetPropertyValue("align-items")) ? ParseStyleAttributeValue(inlineStyle, "align-items") : style.GetPropertyValue("align-items")); + AddIfPresent(map, "align-self", string.IsNullOrWhiteSpace(style.GetPropertyValue("align-self")) ? ParseStyleAttributeValue(inlineStyle, "align-self") : style.GetPropertyValue("align-self")); + AddIfPresent(map, "flex-wrap", string.IsNullOrWhiteSpace(style.GetPropertyValue("flex-wrap")) ? ParseStyleAttributeValue(inlineStyle, "flex-wrap") : style.GetPropertyValue("flex-wrap")); + AddIfPresent(map, "flex-grow", string.IsNullOrWhiteSpace(style.GetPropertyValue("flex-grow")) ? ParseStyleAttributeValue(inlineStyle, "flex-grow") : style.GetPropertyValue("flex-grow")); + AddIfPresent(map, "flex-shrink", string.IsNullOrWhiteSpace(style.GetPropertyValue("flex-shrink")) ? ParseStyleAttributeValue(inlineStyle, "flex-shrink") : style.GetPropertyValue("flex-shrink")); + AddIfPresent(map, "flex-basis", string.IsNullOrWhiteSpace(style.GetPropertyValue("flex-basis")) ? ParseStyleAttributeValue(inlineStyle, "flex-basis") : style.GetPropertyValue("flex-basis")); + AddIfPresent(map, "order", string.IsNullOrWhiteSpace(style.GetPropertyValue("order")) ? ParseStyleAttributeValue(inlineStyle, "order") : style.GetPropertyValue("order")); + AddIfPresent(map, "align-content", string.IsNullOrWhiteSpace(style.GetPropertyValue("align-content")) ? ParseStyleAttributeValue(inlineStyle, "align-content") : style.GetPropertyValue("align-content")); var backgroundImageValue = element is not null ? element.GetAttribute("data-render-gradient") @@ -1395,6 +1945,31 @@ private static void AddIfPresent(Dictionary map, string property } } + private static string? ParseStyleAttributeValue(string? styleAttribute, string propertyName) + { + if (string.IsNullOrWhiteSpace(styleAttribute)) + { + return null; + } + + foreach (var declaration in styleAttribute.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var separatorIndex = declaration.IndexOf(':'); + if (separatorIndex <= 0) + { + continue; + } + + var candidateProperty = declaration[..separatorIndex].Trim(); + if (string.Equals(candidateProperty, propertyName, StringComparison.OrdinalIgnoreCase)) + { + return declaration[(separatorIndex + 1)..].Trim(); + } + } + + return null; + } + private static bool TryGetFirstCollapsibleChildTopMargin(ElementRenderNode node, float containingWidth, out float marginTop) { foreach (var child in node.Children) From c6dc3ef7e99e88fe3b4050bcb79941b8ace3c2db Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sun, 2 Aug 2026 00:01:56 +0200 Subject: [PATCH 04/24] Added grid spec --- CHANGELOG.md | 1 + .../HtmlRendererTests.cs | 171 ++++++ src/AngleSharp.Renderer/HtmlRenderer.cs | 503 +++++++++++++++++- 3 files changed, 671 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9c498d..eb52f48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Released on Monday, August 3 2026. - Added support for gradients - Added rendering images - Added flexbox layout mode +- Added grid layout mode # 0.2.0 diff --git a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs index d787581..4e7aa55 100644 --- a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs +++ b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs @@ -617,6 +617,177 @@ public async Task BuildDisplayList_UsesFlexBasisForMainSize() Assert.Single(childBackgrounds); } + [Fact] + public async Task BuildDisplayList_LaysOutGridItemsInRowsAndColumns() + { + var document = await ParseAsync(""" + +
+
+
+
+
+
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + { + Width = 200, + Height = 120, + FontSize = 16f, + }); + + var childBackgrounds = displayList.Commands + .OfType() + .Where(command => command.Rect.Width == 20f && command.Rect.Height == 10f) + .OrderBy(command => command.Rect.Y) + .ThenBy(command => command.Rect.X) + .ToArray(); + + Assert.Equal(4, childBackgrounds.Length); + Assert.Equal(16f, childBackgrounds[0].Rect.X); + Assert.Equal(16f, childBackgrounds[0].Rect.Y); + Assert.Equal(66f, childBackgrounds[1].Rect.X); + Assert.Equal(16f, childBackgrounds[1].Rect.Y); + Assert.Equal(16f, childBackgrounds[2].Rect.X); + Assert.Equal(36f, childBackgrounds[2].Rect.Y); + Assert.Equal(66f, childBackgrounds[3].Rect.X); + Assert.Equal(36f, childBackgrounds[3].Rect.Y); + } + + [Fact] + public async Task BuildDisplayList_AppliesGridGapsToTrackPlacement() + { + var document = await ParseAsync(""" + +
+
+
+
+
+
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + { + Width = 200, + Height = 120, + FontSize = 16f, + }); + + var childBackgrounds = displayList.Commands + .OfType() + .Where(command => command.Rect.Width == 20f && command.Rect.Height == 10f) + .OrderBy(command => command.Rect.Y) + .ThenBy(command => command.Rect.X) + .ToArray(); + + Assert.Equal(4, childBackgrounds.Length); + Assert.Equal(16f, childBackgrounds[0].Rect.X); + Assert.Equal(16f, childBackgrounds[0].Rect.Y); + Assert.Equal(76f, childBackgrounds[1].Rect.X); + Assert.Equal(16f, childBackgrounds[1].Rect.Y); + Assert.Equal(16f, childBackgrounds[2].Rect.X); + Assert.Equal(46f, childBackgrounds[2].Rect.Y); + Assert.Equal(76f, childBackgrounds[3].Rect.X); + Assert.Equal(46f, childBackgrounds[3].Rect.Y); + } + + [Fact] + public async Task BuildDisplayList_AppliesExplicitGridItemPlacement() + { + var document = await ParseAsync(""" + +
+
+
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + { + Width = 200, + Height = 120, + FontSize = 16f, + }); + + var childBackground = displayList.Commands + .OfType() + .Single(command => command.Rect.Width == 20f && command.Rect.Height == 10f); + + Assert.Equal(66f, childBackground.Rect.X); + Assert.Equal(36f, childBackground.Rect.Y); + } + + [Fact] + public async Task BuildDisplayList_AppliesSpanBasedGridItemPlacement() + { + var document = await ParseAsync(""" + +
+
+
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + { + Width = 200, + Height = 120, + FontSize = 16f, + }); + + var childBackground = displayList.Commands + .OfType() + .Single(command => command.Rect.Width == 20f && command.Rect.Height == 10f); + + Assert.Equal(16f, childBackground.Rect.X); + Assert.Equal(16f, childBackground.Rect.Y); + } + + [Fact] + public async Task BuildDisplayList_AppliesAutoPlacementAcrossImplicitTracks() + { + var document = await ParseAsync(""" + +
+
+
+
+
+ + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + { + Width = 200, + Height = 120, + FontSize = 16f, + }); + + var childBackgrounds = displayList.Commands + .OfType() + .Where(command => command.Rect.Width == 20f && command.Rect.Height == 10f) + .OrderBy(command => command.Rect.Y) + .ThenBy(command => command.Rect.X) + .ToArray(); + + Assert.Equal(3, childBackgrounds.Length); + Assert.Equal(16f, childBackgrounds[0].Rect.X); + Assert.Equal(16f, childBackgrounds[0].Rect.Y); + Assert.Equal(66f, childBackgrounds[1].Rect.X); + Assert.Equal(16f, childBackgrounds[1].Rect.Y); + Assert.Equal(16f, childBackgrounds[2].Rect.X); + Assert.Equal(36f, childBackgrounds[2].Rect.Y); + } + [Fact] public async Task BuildDisplayList_AppliesLetterSpacingToTextCommands() { diff --git a/src/AngleSharp.Renderer/HtmlRenderer.cs b/src/AngleSharp.Renderer/HtmlRenderer.cs index 754372e..8725648 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.cs @@ -415,6 +415,40 @@ private static void LayoutElement( return; } + if (IsGridContainer(styleMap)) + { + LayoutGridContainer( + node, + contentX, + contentY, + contentWidth, + ref cursorY, + ref previousBlockMarginBottom, + ref suppressNextBlockTopMargin, + ref activeFloatLeftOffset, + ref activeFloatBottom, + ref textIndentConsumed, + currentTextStyle, + options, + displayList, + maxY, + styleMap, + borderLeft, + borderTop, + borderRight, + borderBottom, + paddingLeft, + paddingRight, + paddingTop, + paddingBottom, + box, + flowBorderBoxX, + flowBorderBoxY, + borderBoxX, + borderBoxY); + return; + } + if (!hasInlineRun) { foreach (var child in orderedChildren) @@ -624,6 +658,8 @@ private readonly record struct FlexItemLayoutInfo( float CrossSize, string AlignSelf); + private readonly record struct GridPlacement(int LineIndex, int Span); + private static void LayoutFlexContainer( ElementRenderNode node, float containingX, @@ -1320,6 +1356,341 @@ private static void LayoutTable( suppressNextBlockTopMargin = false; } + private static void LayoutGridContainer( + ElementRenderNode node, + float containingX, + float containingY, + float containingWidth, + ref float cursorY, + ref float previousBlockMarginBottom, + ref bool suppressNextBlockTopMargin, + ref float activeFloatLeftOffset, + ref float activeFloatBottom, + ref bool textIndentConsumed, + RenderTextStyle inheritedTextStyle, + HtmlRenderOptions options, + DisplayList displayList, + float maxY, + Dictionary styleMap, + float borderLeft, + float borderTop, + float borderRight, + float borderBottom, + float paddingLeft, + float paddingRight, + float paddingTop, + float paddingBottom, + BoxStyle box, + float flowBorderBoxX, + float flowBorderBoxY, + float borderBoxX, + float borderBoxY) + { + var columns = ParseGridTrackList(styleMap, "grid-template-columns", containingWidth, 1); + var columnGap = ParseGridGap(styleMap, "column-gap", containingWidth, 0) + ?? ParseGridGap(styleMap, "gap", containingWidth, 0); + var rowGap = ParseGridGap(styleMap, "row-gap", containingWidth, 0) + ?? ParseGridGap(styleMap, "gap", containingWidth, 0); + var resolvedColumnGap = columnGap ?? 0f; + var resolvedRowGap = rowGap ?? 0f; + var gridItems = node.Children + .Where(child => child is ElementRenderNode || (child is TextRenderNode textNode && NormalizeWhitespace(textNode.Ref.Data).Length > 0)) + .ToList(); + var hasExplicitRowTracks = styleMap.TryGetValue("grid-template-rows", out var rowTemplateValue) && !string.IsNullOrWhiteSpace(rowTemplateValue); + var containerHeight = ParseLength(styleMap, "height", containingWidth, containingWidth, allowAuto: true); + var rows = hasExplicitRowTracks + ? ParseGridTrackList(styleMap, "grid-template-rows", containerHeight, 1) + : CreateAutoRows(gridItems.Count, columns.Count, containerHeight); + + var currentColumn = 0; + var currentRow = 0; + + foreach (var child in gridItems) + { + if (child is TextRenderNode textNode) + { + LayoutTextNode(textNode.Ref, containingX, containingWidth, ref cursorY, ref previousBlockMarginBottom, ref suppressNextBlockTopMargin, ref activeFloatLeftOffset, ref activeFloatBottom, ref textIndentConsumed, inheritedTextStyle, options, displayList, maxY); + continue; + } + + if (child is not ElementRenderNode elementChild) + { + continue; + } + + var placementColumn = ResolveGridPlacement(styleMap, elementChild, "grid-column", currentColumn); + var placementRow = ResolveGridPlacement(styleMap, elementChild, "grid-row", currentRow); + var effectivePlacementColumn = placementColumn; + var effectivePlacementRow = placementRow; + + var hasExplicitColumnPlacement = elementChild.Ref.GetAttribute("data-render-grid-column") is not null; + var hasExplicitRowPlacement = elementChild.Ref.GetAttribute("data-render-grid-row") is not null; + + if (hasExplicitColumnPlacement || hasExplicitRowPlacement) + { + effectivePlacementColumn = new GridPlacement(Math.Max(0, placementColumn.LineIndex), placementColumn.Span); + effectivePlacementRow = new GridPlacement(Math.Max(0, placementRow.LineIndex), placementRow.Span); + } + else + { + effectivePlacementColumn = new GridPlacement(Math.Max(0, currentColumn), placementColumn.Span); + effectivePlacementRow = new GridPlacement(Math.Max(0, currentRow), placementRow.Span); + } + var estimatedItemWidth = ResolveGridItemEstimatedSize(elementChild, styleMap, containingWidth, "width"); + var estimatedItemHeight = ResolveGridItemEstimatedSize(elementChild, styleMap, containingWidth, "height"); + var effectiveColumnCount = Math.Max(columns.Count, effectivePlacementColumn.LineIndex + effectivePlacementColumn.Span); + var effectiveRowCount = Math.Max(rows.Count, effectivePlacementRow.LineIndex + effectivePlacementRow.Span); + + if (effectiveColumnCount > columns.Count) + { + columns.AddRange(Enumerable.Repeat(containingWidth, effectiveColumnCount - columns.Count)); + } + + if (effectiveRowCount > rows.Count) + { + rows.AddRange(Enumerable.Repeat(0f, effectiveRowCount - rows.Count)); + } + + EnsureGridTrackSize(columns, effectivePlacementColumn.LineIndex, estimatedItemWidth, containingWidth); + EnsureGridTrackSize(rows, effectivePlacementRow.LineIndex, estimatedItemHeight, 0f); + + var contentX = borderBoxX + borderLeft + paddingLeft; + var contentY = borderBoxY + borderTop + paddingTop; + var cellX = contentX + GetGridTrackOffset(columns, effectivePlacementColumn.LineIndex, resolvedColumnGap); + var cellY = contentY + GetGridTrackOffset(rows, effectivePlacementRow.LineIndex, resolvedRowGap); + var cellWidth = GetGridTrackSpanSize(columns, effectivePlacementColumn.LineIndex, effectivePlacementColumn.Span, resolvedColumnGap, containingWidth); + var cellHeight = GetGridTrackSpanSize(rows, effectivePlacementRow.LineIndex, effectivePlacementRow.Span, resolvedRowGap, containingWidth); + + var childCursor = cellY; + var childPreviousBlockMarginBottom = 0f; + var childSuppressNextBlockTopMargin = false; + var childActiveFloatLeftOffset = 0f; + var childActiveFloatBottom = 0f; + var childTextIndentConsumed = false; + + LayoutNode( + node: elementChild, + containingX: cellX, + containingY: cellY, + containingWidth: Math.Max(0f, cellWidth), + cursorY: ref childCursor, + previousBlockMarginBottom: ref childPreviousBlockMarginBottom, + suppressNextBlockTopMargin: ref childSuppressNextBlockTopMargin, + activeFloatLeftOffset: ref childActiveFloatLeftOffset, + activeFloatBottom: ref childActiveFloatBottom, + textIndentConsumed: ref childTextIndentConsumed, + textStyle: inheritedTextStyle, + options: options, + displayList: displayList, + maxY: maxY, + isFlexItem: false, + isRowDirection: true, + flexMainSize: null, + flexCrossSize: null); + + currentColumn++; + if (currentColumn >= columns.Count) + { + currentColumn = 0; + currentRow++; + } + } + + var gridContentWidth = GetGridContentSize(columns, resolvedColumnGap, containingWidth); + var specifiedHeight = ParseLength(styleMap, "height", containingWidth, containingWidth, allowAuto: true); + var gridContentHeight = GetGridContentSize(rows, resolvedRowGap, specifiedHeight); + var borderBoxWidth = borderLeft + paddingLeft + Math.Max(containingWidth, gridContentWidth) + paddingRight + borderRight; + var borderBoxHeight = borderTop + paddingTop + Math.Max(ParseLength(styleMap, "height", containingWidth, containingWidth, allowAuto: true), gridContentHeight) + paddingBottom + borderBottom; + var canCollapseWithLastChild = borderBottom <= 0f && paddingBottom <= 0f; + var effectiveMarginBottom = ParseLength(styleMap, "margin-bottom", containingWidth, box.Margin.Bottom, allowAuto: false); + + if (canCollapseWithLastChild) + { + effectiveMarginBottom = CollapseMargins(effectiveMarginBottom, previousBlockMarginBottom); + } + + if (box.BackgroundPaint is RenderColorPaint colorPaint && colorPaint.Color.A == 0) + { + displayList.FillRect(new RenderRect(borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight), RenderColor.Transparent); + } + else + { + PaintBackground(displayList, box.BackgroundPaint, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight); + } + + PaintBorder(displayList, box.BorderColor, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderWidth); + PaintOutline(displayList, styleMap, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight); + + cursorY = flowBorderBoxY + borderBoxHeight; + previousBlockMarginBottom = effectiveMarginBottom + options.ParagraphSpacing; + suppressNextBlockTopMargin = false; + } + + private static float GetGridTrackOffset(IReadOnlyList tracks, int index, float gap) + { + if (index <= 0) + { + return 0f; + } + + var offset = 0f; + for (var current = 0; current < index && current < tracks.Count; current++) + { + offset += tracks[current]; + offset += gap; + } + + return offset; + } + + private static float GetGridTrackSpanSize(IReadOnlyList tracks, int index, int span, float gap, float fallback) + { + var totalSize = 0f; + var spanCount = Math.Max(1, span); + + for (var current = 0; current < spanCount; current++) + { + var trackIndex = index + current; + totalSize += GetGridTrackSize(tracks, trackIndex, fallback); + + if (current < spanCount - 1) + { + totalSize += gap; + } + } + + return totalSize; + } + + private static List ParseGridTrackList(Dictionary styleMap, string propertyName, float fallbackSize, int minimumCount) + { + if (!styleMap.TryGetValue(propertyName, out var rawValue) || string.IsNullOrWhiteSpace(rawValue)) + { + var fallbackTracks = new List(Math.Max(1, minimumCount)); + var fallbackTrackSize = Math.Max(0f, fallbackSize / Math.Max(1, minimumCount)); + for (var index = 0; index < Math.Max(1, minimumCount); index++) + { + fallbackTracks.Add(fallbackTrackSize); + } + + return fallbackTracks; + } + + var tracks = new List(); + foreach (var token in rawValue.Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var normalized = token.Trim().ToLowerInvariant(); + var trackSize = normalized switch + { + "auto" => Math.Max(0f, fallbackSize), + _ => ParseLengthValue(normalized, fallbackSize, allowAuto: false) + }; + + tracks.Add(float.IsNaN(trackSize) ? Math.Max(0f, fallbackSize) : Math.Max(0f, trackSize)); + } + + return tracks.Count > 0 ? tracks : new List { Math.Max(0f, fallbackSize) }; + } + + private static List CreateAutoRows(int itemCount, int columnCount, float containerHeight) + { + var rowCount = Math.Max(1, (int)Math.Ceiling((double)itemCount / Math.Max(1, columnCount))); + var fallbackRowSize = containerHeight > 0f ? containerHeight / rowCount : 0f; + return Enumerable.Range(0, rowCount).Select(_ => fallbackRowSize).ToList(); + } + + private static float? ParseGridGap(Dictionary styleMap, string propertyName, float relativeTo, int tokenIndex) + { + if (!styleMap.TryGetValue(propertyName, out var rawValue) || string.IsNullOrWhiteSpace(rawValue)) + { + return null; + } + + var tokens = rawValue.Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (tokens.Length == 0) + { + return null; + } + + var token = tokenIndex >= 0 && tokenIndex < tokens.Length ? tokens[tokenIndex] : tokens[^1]; + var parsed = ParseLengthValue(token, float.NaN, allowAuto: false); + return float.IsNaN(parsed) ? null : parsed; + } + + private static GridPlacement ResolveGridPlacement(Dictionary styleMap, ElementRenderNode elementChild, string propertyName, int fallbackIndex) + { + var childStyleMap = CreateStyleMap(elementChild.ComputedStyle, elementChild.Ref); + if (!childStyleMap.TryGetValue(propertyName, out var rawValue) || string.IsNullOrWhiteSpace(rawValue)) + { + return new GridPlacement(fallbackIndex, 1); + } + + var tokens = rawValue.Split(new[] { ' ', '/', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var startToken = tokens.FirstOrDefault(token => int.TryParse(token, out _)); + + if (startToken is not null && int.TryParse(startToken, out var explicitIndex)) + { + return new GridPlacement(Math.Max(0, explicitIndex - 1), 1); + } + + if (tokens.Length >= 3 && string.Equals(tokens[1], "span", StringComparison.OrdinalIgnoreCase) && int.TryParse(tokens[2], out var spanCount)) + { + return new GridPlacement(Math.Max(0, fallbackIndex), Math.Max(1, spanCount)); + } + + return new GridPlacement(Math.Max(0, fallbackIndex), 1); + } + + private static float ResolveGridItemEstimatedSize(ElementRenderNode elementChild, Dictionary styleMap, float fallbackSize, string propertyName) + { + var rawValue = styleMap.TryGetValue(propertyName, out var value) && !string.IsNullOrWhiteSpace(value) + ? value + : null; + + if (string.IsNullOrWhiteSpace(rawValue)) + { + return fallbackSize; + } + + var parsed = ParseLengthValue(rawValue, fallbackSize, allowAuto: false); + return float.IsNaN(parsed) ? fallbackSize : Math.Max(0f, parsed); + } + + private static void EnsureGridTrackSize(List tracks, int index, float size, float fallbackSize) + { + while (tracks.Count <= index) + { + tracks.Add(Math.Max(0f, fallbackSize)); + } + + if (tracks[index] <= 0f) + { + tracks[index] = Math.Max(0f, Math.Max(size, fallbackSize)); + } + } + + private static float GetGridTrackSize(IReadOnlyList tracks, int index, float fallback) + { + if (index >= 0 && index < tracks.Count) + { + return tracks[index]; + } + + return fallback; + } + + private static float GetGridContentSize(IReadOnlyList tracks, float gap, float fallbackSize) + { + if (tracks.Count <= 1) + { + return Math.Max(fallbackSize, tracks.Sum()); + } + + var trackSize = tracks.Sum(); + var gapSize = (tracks.Count - 1) * gap; + return Math.Max(fallbackSize, trackSize + gapSize); + } + private static void LayoutTextNode( IText textNode, float containingX, @@ -1466,6 +1837,12 @@ private static bool IsFlexContainer(Dictionary styleMap) return string.Equals(display, "flex", StringComparison.OrdinalIgnoreCase) || string.Equals(display, "inline-flex", StringComparison.OrdinalIgnoreCase); } + private static bool IsGridContainer(Dictionary styleMap) + { + var display = GetDisplay(styleMap); + return string.Equals(display, "grid", StringComparison.OrdinalIgnoreCase) || string.Equals(display, "inline-grid", StringComparison.OrdinalIgnoreCase); + } + private static string GetFlexDirection(Dictionary styleMap) { return styleMap.TryGetValue("flex-direction", out var direction) && !string.IsNullOrWhiteSpace(direction) @@ -1789,13 +2166,31 @@ private static void PrepareDocumentForRendering(IDocument document) continue; } - if (!TryExtractGradientBackground(styleAttribute, out var gradientValue, out var updatedStyle)) + var currentStyle = styleAttribute; + var changed = false; + + if (TryExtractGradientBackground(currentStyle, out var gradientValue, out var updatedStyle)) { - continue; + currentStyle = updatedStyle; + changed = true; + element.SetAttribute("data-render-gradient", gradientValue); + } + + if (TryExtractGridDeclarations(currentStyle, out var gridValues, out updatedStyle)) + { + currentStyle = updatedStyle; + changed = true; + + foreach (var entry in gridValues) + { + element.SetAttribute($"data-render-{entry.Key}", entry.Value); + } } - element.SetAttribute("style", updatedStyle); - element.SetAttribute("data-render-gradient", gradientValue); + if (changed) + { + element.SetAttribute("style", currentStyle); + } } } @@ -1844,10 +2239,103 @@ private static bool TryExtractGradientBackground(string styleAttribute, out stri return true; } + private static bool TryExtractGridDeclarations(string styleAttribute, out Dictionary values, out string updatedStyle) + { + values = new Dictionary(StringComparer.OrdinalIgnoreCase); + updatedStyle = styleAttribute; + + if (string.IsNullOrWhiteSpace(styleAttribute)) + { + return false; + } + + var declarations = styleAttribute.Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + var remaining = new List(); + var strippedAny = false; + + foreach (var declaration in declarations) + { + var separator = declaration.IndexOf(':'); + if (separator <= 0) + { + continue; + } + + var property = declaration[..separator].Trim(); + var value = declaration[(separator + 1)..].Trim(); + + if (string.Equals(property, "grid-column", StringComparison.OrdinalIgnoreCase)) + { + values["grid-column"] = value; + strippedAny = true; + continue; + } + + if (string.Equals(property, "grid-row", StringComparison.OrdinalIgnoreCase)) + { + values["grid-row"] = value; + strippedAny = true; + continue; + } + + if (string.Equals(property, "grid-template-columns", StringComparison.OrdinalIgnoreCase)) + { + values["grid-template-columns"] = value; + strippedAny = true; + continue; + } + + if (string.Equals(property, "grid-template-rows", StringComparison.OrdinalIgnoreCase)) + { + values["grid-template-rows"] = value; + strippedAny = true; + continue; + } + + if (string.Equals(property, "column-gap", StringComparison.OrdinalIgnoreCase)) + { + values["column-gap"] = value; + strippedAny = true; + continue; + } + + if (string.Equals(property, "row-gap", StringComparison.OrdinalIgnoreCase)) + { + values["row-gap"] = value; + strippedAny = true; + continue; + } + + if (string.Equals(property, "gap", StringComparison.OrdinalIgnoreCase)) + { + values["gap"] = value; + strippedAny = true; + continue; + } + + remaining.Add(declaration); + } + + if (!strippedAny) + { + return false; + } + + updatedStyle = string.Join(";", remaining); + return true; + } + private static Dictionary CreateStyleMap(ICssStyleDeclaration style, IElement? element = null) { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); var inlineStyle = element?.GetAttribute("style"); + var gridColumnValue = element?.GetAttribute("data-render-grid-column"); + var gridRowValue = element?.GetAttribute("data-render-grid-row"); + var gridTemplateColumnsValue = element?.GetAttribute("data-render-grid-template-columns"); + var gridTemplateRowsValue = element?.GetAttribute("data-render-grid-template-rows"); + var columnGapValue = element?.GetAttribute("data-render-column-gap"); + var rowGapValue = element?.GetAttribute("data-render-row-gap"); + var gapValue = element?.GetAttribute("data-render-gap"); var displayValue = style.GetDisplay(); if (string.IsNullOrWhiteSpace(displayValue)) @@ -1896,6 +2384,13 @@ private static Dictionary CreateStyleMap(ICssStyleDeclaration st AddIfPresent(map, "outline-color", style.GetPropertyValue("outline-color")); AddIfPresent(map, "background-color", style.GetBackgroundColor()); + AddIfPresent(map, "grid-template-columns", !string.IsNullOrWhiteSpace(gridTemplateColumnsValue) ? gridTemplateColumnsValue : (string.IsNullOrWhiteSpace(style.GetPropertyValue("grid-template-columns")) ? ParseStyleAttributeValue(inlineStyle, "grid-template-columns") : style.GetPropertyValue("grid-template-columns"))); + AddIfPresent(map, "grid-template-rows", !string.IsNullOrWhiteSpace(gridTemplateRowsValue) ? gridTemplateRowsValue : (string.IsNullOrWhiteSpace(style.GetPropertyValue("grid-template-rows")) ? ParseStyleAttributeValue(inlineStyle, "grid-template-rows") : style.GetPropertyValue("grid-template-rows"))); + AddIfPresent(map, "column-gap", !string.IsNullOrWhiteSpace(columnGapValue) ? columnGapValue : (string.IsNullOrWhiteSpace(style.GetPropertyValue("column-gap")) ? ParseStyleAttributeValue(inlineStyle, "column-gap") : style.GetPropertyValue("column-gap"))); + AddIfPresent(map, "row-gap", !string.IsNullOrWhiteSpace(rowGapValue) ? rowGapValue : (string.IsNullOrWhiteSpace(style.GetPropertyValue("row-gap")) ? ParseStyleAttributeValue(inlineStyle, "row-gap") : style.GetPropertyValue("row-gap"))); + AddIfPresent(map, "gap", !string.IsNullOrWhiteSpace(gapValue) ? gapValue : (string.IsNullOrWhiteSpace(style.GetPropertyValue("gap")) ? ParseStyleAttributeValue(inlineStyle, "gap") : style.GetPropertyValue("gap"))); + AddIfPresent(map, "grid-column", !string.IsNullOrWhiteSpace(gridColumnValue) ? gridColumnValue : ParseStyleAttributeValue(inlineStyle, "grid-column")); + AddIfPresent(map, "grid-row", !string.IsNullOrWhiteSpace(gridRowValue) ? gridRowValue : ParseStyleAttributeValue(inlineStyle, "grid-row")); AddIfPresent(map, "flex-direction", string.IsNullOrWhiteSpace(style.GetPropertyValue("flex-direction")) ? ParseStyleAttributeValue(inlineStyle, "flex-direction") : style.GetPropertyValue("flex-direction")); AddIfPresent(map, "justify-content", string.IsNullOrWhiteSpace(style.GetPropertyValue("justify-content")) ? ParseStyleAttributeValue(inlineStyle, "justify-content") : style.GetPropertyValue("justify-content")); AddIfPresent(map, "align-items", string.IsNullOrWhiteSpace(style.GetPropertyValue("align-items")) ? ParseStyleAttributeValue(inlineStyle, "align-items") : style.GetPropertyValue("align-items")); From 7272c7a8afadc1509003c60ab5e562dc53bfd357 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sun, 2 Aug 2026 10:51:38 +0200 Subject: [PATCH 05/24] Stabilize img rendering --- .../HtmlRendererTests.cs | 128 ++++++++++++++++- src/AngleSharp.Renderer/HtmlRenderer.cs | 131 ++++++++++++++---- 2 files changed, 230 insertions(+), 29 deletions(-) diff --git a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs index 4e7aa55..31bd093 100644 --- a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs +++ b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs @@ -1,7 +1,12 @@ using AngleSharp; using AngleSharp.Css; +using AngleSharp.Dom; +using AngleSharp.Io; using AngleSharp.Renderer.Rendering; +using System.Net; +using System.Threading; + namespace AngleSharp.Renderer.Tests; public sealed class HtmlRendererTests @@ -47,6 +52,45 @@ public async Task BuildDisplayList_PaintsImageElementsFromCurrentDownload() Assert.NotEmpty(imageCommand.Image.Data); } + [Fact] + public async Task BuildDisplayList_CachesHttpImagePayloadPerDocument() + { + 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 HtmlRenderOptions + { + Width = 240, + Height = 160, + FontSize = 16f, + }); + + var second = renderer.BuildDisplayList(document, new HtmlRenderOptions + { + Width = 240, + Height = 160, + FontSize = 16f, + }); + + Assert.Single(first.Commands.OfType()); + Assert.Single(second.Commands.OfType()); + Assert.Equal(1, requester.ContentReadSessionCount); + } + [Fact] public async Task BuildDisplayList_ParsesLinearGradientBackgrounds() { @@ -1600,9 +1644,87 @@ public async Task BuildDisplayList_UsesZIndexOverSourceOrderForPositionedSibling Assert.True(blueIndex > redIndex); } - private static async Task ParseAsync(string html) + private static async Task ParseAsync(string html, IConfiguration? configuration = null, string? address = null) { - var context = BrowsingContext.New(Configuration.Default.WithCss()); - return await context.OpenAsync(request => request.Content(html)); + var context = BrowsingContext.New(configuration ?? Configuration.Default.WithCss()); + + return await context.OpenAsync(request => + { + if (!string.IsNullOrWhiteSpace(address)) + { + request.Address(address); + } + + request.Content(html); + }); + } + + private sealed class SingleResponseImageRequester : BaseRequester + { + private readonly ReadTrackingMemoryStream _stream; + + public SingleResponseImageRequester(byte[] imageData) + { + _stream = new ReadTrackingMemoryStream(imageData); + } + + public int ContentReadSessionCount => _stream.ReadSessionCount; + + public override bool SupportsProtocol(string protocol) + { + return string.Equals(protocol, "http", StringComparison.OrdinalIgnoreCase) || + string.Equals(protocol, "https", StringComparison.OrdinalIgnoreCase); + } + + protected override Task PerformRequestAsync(Request request, CancellationToken cancel) + { + var response = new DefaultResponse + { + Address = request.Address, + StatusCode = HttpStatusCode.OK, + Headers = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Content-Type"] = "image/png", + }, + Content = _stream, + }; + + return Task.FromResult(response); + } + + private sealed class ReadTrackingMemoryStream : MemoryStream + { + public ReadTrackingMemoryStream(byte[] buffer) + : base(buffer) + { + } + + public int ReadSessionCount { get; private set; } + + public override int Read(byte[] buffer, int offset, int count) + { + if (Position == 0) + { + ReadSessionCount++; + } + + return base.Read(buffer, offset, count); + } + + public override int Read(Span buffer) + { + if (Position == 0) + { + ReadSessionCount++; + } + + return base.Read(buffer); + } + + protected override void Dispose(bool disposing) + { + // Keep the backing stream alive for deterministic test behavior. + } + } } } \ No newline at end of file diff --git a/src/AngleSharp.Renderer/HtmlRenderer.cs b/src/AngleSharp.Renderer/HtmlRenderer.cs index 8725648..d99bde6 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.IO; using System.Linq; +using System.Runtime.CompilerServices; using System.Text; using AngleSharp.Css; @@ -20,8 +21,18 @@ namespace AngleSharp.Renderer; ///
public sealed class HtmlRenderer { + // Per-document cache keeps image payloads stable across repeated renders of the same DOM instance. + private static readonly ConditionalWeakTable s_imageCacheByDocument = new(); + private readonly IRenderBackend _backend; + private sealed class DocumentImageCache + { + public Dictionary Resources { get; } = new(StringComparer.Ordinal); + } + + private sealed record CachedImageResource(byte[] Bytes, string MimeType, int NaturalWidth, int NaturalHeight); + /// /// Creates a new renderer with a default Skia backend. /// @@ -2739,10 +2750,102 @@ private static bool TryResolveImage(ElementRenderNode node, Dictionary 0f) + { + width = (height / naturalHeight) * naturalWidth; + } + else if (!float.IsNaN(width) && float.IsNaN(height) && naturalHeight > 0f) + { + height = (width / naturalWidth) * naturalHeight; + } + + if (float.IsNaN(width) || float.IsNaN(height) || width <= 0f || height <= 0f) + { + width = naturalWidth; + height = naturalHeight; + } + + image = new RenderedImage(imageResource.Bytes, (int)Math.Max(1, Math.Round(width)), (int)Math.Max(1, Math.Round(height)), imageResource.MimeType); + rect = new RenderRect(x, y, width, height); + return true; + } + + private static bool TryGetOrLoadImageResource(IElement element, string? source, out CachedImageResource? imageResource) + { + imageResource = null; + if (string.IsNullOrWhiteSpace(source)) + { + return false; + } + + var cacheKey = source.Trim(); + var cache = GetImageCache(element); + + if (cache is not null) + { + lock (cache.Resources) + { + if (cache.Resources.TryGetValue(cacheKey, out imageResource)) + { + return imageResource is not null; + } + } + } + + if (!TryLoadImageResource(element, source, out imageResource)) + { + if (cache is not null) + { + lock (cache.Resources) + { + cache.Resources[cacheKey] = null; + } + } + + return false; + } + + if (cache is not null) + { + lock (cache.Resources) + { + cache.Resources[cacheKey] = imageResource; + } + } + + return true; + } + + private static DocumentImageCache? GetImageCache(IElement element) + { + var owner = element.Owner; + return owner is null ? null : s_imageCacheByDocument.GetValue(owner, static _ => new DocumentImageCache()); + } + + private static bool TryLoadImageResource(IElement element, string? source, out CachedImageResource? imageResource) + { + imageResource = null; + byte[]? bytes = null; string? mimeType = null; - if (node.Ref is ILoadableElement loadableElement && loadableElement.CurrentDownload is { Task: not null } download) + if (element is ILoadableElement loadableElement && loadableElement.CurrentDownload is { Task: not null } download) { IResponse? response; @@ -2784,34 +2887,10 @@ private static bool TryResolveImage(ElementRenderNode node, Dictionary 0f) - { - width = (height / naturalHeight) * naturalWidth; - } - else if (!float.IsNaN(width) && float.IsNaN(height) && naturalHeight > 0f) - { - height = (width / naturalWidth) * naturalHeight; - } - - if (float.IsNaN(width) || float.IsNaN(height) || width <= 0f || height <= 0f) - { - width = naturalWidth; - height = naturalHeight; - } - - image = new RenderedImage(bytes, (int)Math.Max(1, Math.Round(width)), (int)Math.Max(1, Math.Round(height)), mimeType ?? "image/unknown"); - rect = new RenderRect(x, y, width, height); + imageResource = new CachedImageResource(bytes, mimeType ?? "image/unknown", naturalWidth, naturalHeight); return true; } From 392c6d88910dbe5d01d7a3cadc7899b60c3b1d9c Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sun, 2 Aug 2026 15:12:59 +0200 Subject: [PATCH 06/24] Started with CSSOM View spec impl --- CHANGELOG.md | 1 + .../ElementCssomViewExtensionsTests.cs | 93 ++++++++ .../AngleSharp.Renderer.csproj | 2 +- .../ElementCssomViewExtensions.cs | 223 ++++++++++++++++++ src/AngleSharp.Renderer/HtmlRenderer.cs | 163 +++++++++++++ 5 files changed, 481 insertions(+), 1 deletion(-) create mode 100644 src/AngleSharp.Renderer.Tests/ElementCssomViewExtensionsTests.cs create mode 100644 src/AngleSharp.Renderer/ElementCssomViewExtensions.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index eb52f48..41f0cc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Released on Monday, August 3 2026. - Added rendering images - Added flexbox layout mode - Added grid layout mode +- Added CSSOM View specification # 0.2.0 diff --git a/src/AngleSharp.Renderer.Tests/ElementCssomViewExtensionsTests.cs b/src/AngleSharp.Renderer.Tests/ElementCssomViewExtensionsTests.cs new file mode 100644 index 0000000..8da70d1 --- /dev/null +++ b/src/AngleSharp.Renderer.Tests/ElementCssomViewExtensionsTests.cs @@ -0,0 +1,93 @@ +using AngleSharp; +using AngleSharp.Css; +using AngleSharp.Dom; + +namespace AngleSharp.Renderer.Tests; + +public sealed class ElementCssomViewExtensionsTests +{ + [Fact] + public async Task GetBoundingClientRect_AndClientMetrics_AreDerivedFromBorderBox() + { + var document = await ParseAsync(""" + + + +
+ + + """); + + var element = document.QuerySelector("#box"); + Assert.NotNull(element); + + var rect = element!.GetBoundingClientRect(); + var rects = element.GetClientRects(); + + Assert.Equal(54d, rect.Width); + Assert.Equal(34d, rect.Height); + Assert.Equal(1, rects.Length); + Assert.Equal(50, element.GetClientWidth()); + Assert.Equal(30, element.GetClientHeight()); + Assert.Equal(54, element.GetOffsetWidth()); + Assert.Equal(34, element.GetOffsetHeight()); + Assert.Equal(50, element.GetScrollWidth()); + Assert.Equal(30, element.GetScrollHeight()); + } + + [Fact] + public async Task GetClientRects_ReturnsEmpty_WhenElementHasNoLayoutBox() + { + var document = await ParseAsync(""" + + + + + + + """); + + var element = document.QuerySelector("#hidden"); + Assert.NotNull(element); + + var rect = element!.GetBoundingClientRect(); + var rects = element.GetClientRects(); + + Assert.Equal(0, rects.Length); + Assert.Equal(0d, rect.Width); + Assert.Equal(0d, rect.Height); + Assert.Equal(0, element.GetOffsetWidth()); + Assert.Equal(0, element.GetOffsetHeight()); + } + + [Fact] + public async Task OffsetParent_AndOffsets_AreResolvedFromPositionedAncestor() + { + var document = await ParseAsync(""" + + + +
+
+
+ + + """); + + var parent = document.QuerySelector("#parent"); + var child = document.QuerySelector("#child"); + + Assert.NotNull(parent); + Assert.NotNull(child); + + Assert.Same(parent, child!.GetOffsetParent()); + Assert.Equal(10, child.GetOffsetLeft()); + Assert.Equal(10, child.GetOffsetTop()); + } + + private static async Task ParseAsync(string html) + { + var context = BrowsingContext.New(Configuration.Default.WithCss()); + return await context.OpenAsync(request => request.Content(html)); + } +} diff --git a/src/AngleSharp.Renderer/AngleSharp.Renderer.csproj b/src/AngleSharp.Renderer/AngleSharp.Renderer.csproj index 030e078..09c8a44 100644 --- a/src/AngleSharp.Renderer/AngleSharp.Renderer.csproj +++ b/src/AngleSharp.Renderer/AngleSharp.Renderer.csproj @@ -20,7 +20,7 @@ true true snupkg - 1.5.0 + 1.7.0 diff --git a/src/AngleSharp.Renderer/ElementCssomViewExtensions.cs b/src/AngleSharp.Renderer/ElementCssomViewExtensions.cs new file mode 100644 index 0000000..c05aa1f --- /dev/null +++ b/src/AngleSharp.Renderer/ElementCssomViewExtensions.cs @@ -0,0 +1,223 @@ +namespace AngleSharp.Dom; + +using AngleSharp.Attributes; +using AngleSharp.Dom.Geometry; +using AngleSharp.Renderer; + +/// +/// Provides CSSOM View-style geometry helpers for elements. +/// +public static class ElementCssomViewExtensions +{ + /// + /// Returns the element's border-box rectangle in viewport coordinates. + /// + [DomName("getBoundingClientRect")] + public static IDomRect GetBoundingClientRect(this IElement element, HtmlRenderOptions? options = null) + { + ArgumentNullException.ThrowIfNull(element); + + var metricsMap = GetMetricsMap(element, options); + if (metricsMap is null || !metricsMap.TryGetValue(element, out var metrics)) + { + return new DomRect(); + } + + return new DomRect(metrics.BorderBoxX, metrics.BorderBoxY, metrics.BorderBoxWidth, metrics.BorderBoxHeight); + } + + /// + /// Returns the list of border-box fragments for the element. + /// + [DomName("getClientRects")] + public static IDomRectList GetClientRects(this IElement element, HtmlRenderOptions? options = null) + { + ArgumentNullException.ThrowIfNull(element); + + var metricsMap = GetMetricsMap(element, options); + if (metricsMap is null || !metricsMap.TryGetValue(element, out var metrics)) + { + return new DomRectList(); + } + + if (metrics.BorderBoxWidth <= 0f && metrics.BorderBoxHeight <= 0f) + { + return new DomRectList(); + } + + return new DomRectList(new IDomRect[] + { + new DomRect(metrics.BorderBoxX, metrics.BorderBoxY, metrics.BorderBoxWidth, metrics.BorderBoxHeight), + }); + } + + /// + /// Returns the inner width including padding, excluding borders. + /// + [DomName("clientWidth")] + public static int GetClientWidth(this IElement element, HtmlRenderOptions? options = null) + { + return GetRoundedDimension(element, options, static metrics => metrics.BorderBoxWidth - metrics.BorderLeft - metrics.BorderRight); + } + + /// + /// Returns the inner height including padding, excluding borders. + /// + [DomName("clientHeight")] + public static int GetClientHeight(this IElement element, HtmlRenderOptions? options = null) + { + return GetRoundedDimension(element, options, static metrics => metrics.BorderBoxHeight - metrics.BorderTop - metrics.BorderBottom); + } + + /// + /// Returns the width of the element's scrolling area. + /// + [DomName("scrollWidth")] + public static int GetScrollWidth(this IElement element, HtmlRenderOptions? options = null) + { + return GetClientWidth(element, options); + } + + /// + /// Returns the height of the element's scrolling area. + /// + [DomName("scrollHeight")] + public static int GetScrollHeight(this IElement element, HtmlRenderOptions? options = null) + { + return GetClientHeight(element, options); + } + + /// + /// Returns the border-box width. + /// + [DomName("offsetWidth")] + public static int GetOffsetWidth(this IElement element, HtmlRenderOptions? options = null) + { + return GetRoundedDimension(element, options, static metrics => metrics.BorderBoxWidth); + } + + /// + /// Returns the border-box height. + /// + [DomName("offsetHeight")] + public static int GetOffsetHeight(this IElement element, HtmlRenderOptions? options = null) + { + return GetRoundedDimension(element, options, static metrics => metrics.BorderBoxHeight); + } + + /// + /// Returns the left offset relative to the offset parent's padding edge. + /// + [DomName("offsetLeft")] + public static int GetOffsetLeft(this IElement element, HtmlRenderOptions? options = null) + { + ArgumentNullException.ThrowIfNull(element); + + var metricsMap = GetMetricsMap(element, options); + if (metricsMap is null || !metricsMap.TryGetValue(element, out var metrics)) + { + return 0; + } + + var offsetParent = GetOffsetParent(element); + if (offsetParent is null || !metricsMap.TryGetValue(offsetParent, out var parentMetrics)) + { + return (int)Math.Round(metrics.BorderBoxX); + } + + var relativeLeft = metrics.BorderBoxX - (parentMetrics.BorderBoxX + parentMetrics.BorderLeft); + return (int)Math.Round(relativeLeft); + } + + /// + /// Returns the top offset relative to the offset parent's padding edge. + /// + [DomName("offsetTop")] + public static int GetOffsetTop(this IElement element, HtmlRenderOptions? options = null) + { + ArgumentNullException.ThrowIfNull(element); + + var metricsMap = GetMetricsMap(element, options); + if (metricsMap is null || !metricsMap.TryGetValue(element, out var metrics)) + { + return 0; + } + + var offsetParent = GetOffsetParent(element); + if (offsetParent is null || !metricsMap.TryGetValue(offsetParent, out var parentMetrics)) + { + return (int)Math.Round(metrics.BorderBoxY); + } + + var relativeTop = metrics.BorderBoxY - (parentMetrics.BorderBoxY + parentMetrics.BorderTop); + return (int)Math.Round(relativeTop); + } + + /// + /// Returns the nearest offset parent. + /// + [DomName("offsetParent")] + public static IElement? GetOffsetParent(this IElement element) + { + ArgumentNullException.ThrowIfNull(element); + + if (IsPositionFixed(element)) + { + return null; + } + + var ancestor = element.ParentElement; + + while (ancestor is not null) + { + if (!IsPositionStatic(ancestor)) + { + return ancestor; + } + + ancestor = ancestor.ParentElement; + } + + return element.Owner?.Body; + } + + private static int GetRoundedDimension(IElement element, HtmlRenderOptions? options, Func selector) + { + ArgumentNullException.ThrowIfNull(element); + + var metricsMap = GetMetricsMap(element, options); + if (metricsMap is null || !metricsMap.TryGetValue(element, out var metrics)) + { + return 0; + } + + var value = Math.Max(0f, selector(metrics)); + return (int)Math.Round(value); + } + + private static IReadOnlyDictionary? GetMetricsMap(IElement element, HtmlRenderOptions? options) + { + var owner = element.Owner; + return owner is null ? null : HtmlRenderer.CaptureLayoutMetrics(owner, options); + } + + private static bool IsPositionFixed(IElement element) + { + var style = element.ComputeCurrentStyle(); + var position = style?.GetPropertyValue("position"); + return string.Equals(position?.Trim(), "fixed", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsPositionStatic(IElement element) + { + var style = element.ComputeCurrentStyle(); + var position = style?.GetPropertyValue("position"); + + if (string.IsNullOrWhiteSpace(position)) + { + return true; + } + + return string.Equals(position.Trim(), "static", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/AngleSharp.Renderer/HtmlRenderer.cs b/src/AngleSharp.Renderer/HtmlRenderer.cs index d99bde6..3e54f14 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Runtime.CompilerServices; using System.Text; +using System.Threading; using AngleSharp.Css; using AngleSharp.Css.Dom; @@ -23,6 +24,7 @@ public sealed class HtmlRenderer { // Per-document cache keeps image payloads stable across repeated renders of the same DOM instance. private static readonly ConditionalWeakTable s_imageCacheByDocument = new(); + private static readonly AsyncLocal s_layoutCapture = new(); private readonly IRenderBackend _backend; @@ -33,6 +35,92 @@ private sealed class DocumentImageCache private sealed record CachedImageResource(byte[] Bytes, string MimeType, int NaturalWidth, int NaturalHeight); + internal readonly record struct ElementLayoutMetrics( + float BorderBoxX, + float BorderBoxY, + float BorderBoxWidth, + float BorderBoxHeight, + float BorderLeft, + float BorderRight, + float BorderTop, + float BorderBottom, + float PaddingLeft, + float PaddingRight, + float PaddingTop, + float PaddingBottom); + + private sealed class LayoutCapture + { + private readonly Dictionary _metricsByElement = new(ReferenceEqualityComparer.Instance); + + public void Record(IElement element, ElementLayoutMetrics metrics) + { + _metricsByElement[element] = metrics; + } + + public IReadOnlyDictionary Snapshot() + { + return _metricsByElement; + } + } + + internal static IReadOnlyDictionary CaptureLayoutMetrics(IDocument document, HtmlRenderOptions? options = null) + { + ArgumentNullException.ThrowIfNull(document); + + var effectiveOptions = options ?? new HtmlRenderOptions(); + var viewport = new RenderViewport(effectiveOptions.Width, effectiveOptions.Height); + var capture = new LayoutCapture(); + var previous = s_layoutCapture.Value; + s_layoutCapture.Value = capture; + + try + { + _ = BuildDisplayList(document, viewport, effectiveOptions); + return capture.Snapshot(); + } + finally + { + s_layoutCapture.Value = previous; + } + } + + private static void RecordLayoutMetrics( + IElement element, + float borderBoxX, + float borderBoxY, + float borderBoxWidth, + float borderBoxHeight, + float borderLeft, + float borderRight, + float borderTop, + float borderBottom, + float paddingLeft, + float paddingRight, + float paddingTop, + float paddingBottom) + { + var capture = s_layoutCapture.Value; + if (capture is null) + { + return; + } + + capture.Record(element, new ElementLayoutMetrics( + borderBoxX, + borderBoxY, + borderBoxWidth, + borderBoxHeight, + borderLeft, + borderRight, + borderTop, + borderBottom, + paddingLeft, + paddingRight, + paddingTop, + paddingBottom)); + } + /// /// Creates a new renderer with a default Skia backend. /// @@ -628,6 +716,21 @@ private static void LayoutElement( effectiveMarginBottom = CollapseMargins(marginBottom, childPreviousBlockMarginBottom); } + RecordLayoutMetrics( + node.Ref, + borderBoxX, + borderBoxY, + borderBoxWidth, + borderBoxHeight, + borderLeft, + borderRight, + borderTop, + borderBottom, + paddingLeft, + paddingRight, + paddingTop, + paddingBottom); + PaintBackground(displayList, box.BackgroundPaint, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight); PaintBorder(displayList, box.BorderColor, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderWidth); PaintOutline(displayList, styleMap, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight); @@ -977,6 +1080,21 @@ private static void LayoutFlexContainer( PaintBackground(displayList, box.BackgroundPaint, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight); } + RecordLayoutMetrics( + node.Ref, + borderBoxX, + borderBoxY, + borderBoxWidth, + borderBoxHeight, + borderLeft, + borderRight, + borderTop, + borderBottom, + paddingLeft, + paddingRight, + paddingTop, + paddingBottom); + PaintBorder(displayList, box.BorderColor, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderWidth); PaintOutline(displayList, styleMap, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight); @@ -1312,6 +1430,21 @@ private static void LayoutTable( } var effectiveHeight = Math.Max(20f, contentHeight + placement.PaddingTop + placement.PaddingBottom + placement.BorderTopWidth + placement.BorderBottomWidth); + RecordLayoutMetrics( + placement.CellNode.Ref, + cellX, + cellY, + cellWidth, + effectiveHeight, + placement.BorderLeftWidth, + placement.BorderRightWidth, + placement.BorderTopWidth, + placement.BorderBottomWidth, + placement.PaddingLeft, + placement.PaddingRight, + placement.PaddingTop, + placement.PaddingBottom); + displayList.FillRect(new RenderRect(cellX, cellY, cellWidth, effectiveHeight), placement.BackgroundColor); if (!borderCollapse) @@ -1361,6 +1494,21 @@ private static void LayoutTable( } } + RecordLayoutMetrics( + tableNode.Ref, + tableX, + tableY, + tableWidth, + tableHeight, + borderLeft: 0f, + borderRight: 0f, + borderTop: 0f, + borderBottom: 0f, + paddingLeft: 0f, + paddingRight: 0f, + paddingTop: 0f, + paddingBottom: 0f); + displayList.FillRect(new RenderRect(tableX, tableY, tableWidth, tableHeight), RenderColor.Transparent); cursorY = tableY + tableHeight + 4f; previousBlockMarginBottom = 0f; @@ -1529,6 +1677,21 @@ private static void LayoutGridContainer( PaintBackground(displayList, box.BackgroundPaint, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight); } + RecordLayoutMetrics( + node.Ref, + borderBoxX, + borderBoxY, + borderBoxWidth, + borderBoxHeight, + borderLeft, + borderRight, + borderTop, + borderBottom, + paddingLeft, + paddingRight, + paddingTop, + paddingBottom); + PaintBorder(displayList, box.BorderColor, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderWidth); PaintOutline(displayList, styleMap, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight); From 4ee90bc5961657060cbf11407f4ef89f1d2853eb Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sun, 2 Aug 2026 17:25:50 +0200 Subject: [PATCH 07/24] Improved public API --- .../ElementCssomViewExtensionsTests.cs | 185 ++++++- .../HtmlRendererTests.cs | 450 ++++++++---------- .../VisualConformanceTests.cs | 113 ++--- .../web-safe-font-families.linux.png | Bin 9230 -> 9230 bytes .../DocumentRenderingExtensions.cs | 26 +- .../ElementCssomViewExtensions.cs | 227 ++++++++- src/AngleSharp.Renderer/HtmlRenderOptions.cs | 59 --- src/AngleSharp.Renderer/HtmlRenderer.cs | 202 +++++--- src/AngleSharp.Renderer/IDomHarness.cs | 61 +++ .../InteractiveHtmlRendererState.cs | 214 +++++++++ .../InteractiveHtmlRendererStateExtensions.cs | 34 ++ .../Rendering/DisplayList.cs | 5 +- .../Rendering/IRenderBackend.cs | 2 +- 13 files changed, 1097 insertions(+), 481 deletions(-) delete mode 100644 src/AngleSharp.Renderer/HtmlRenderOptions.cs create mode 100644 src/AngleSharp.Renderer/IDomHarness.cs create mode 100644 src/AngleSharp.Renderer/InteractiveHtmlRendererState.cs create mode 100644 src/AngleSharp.Renderer/InteractiveHtmlRendererStateExtensions.cs diff --git a/src/AngleSharp.Renderer.Tests/ElementCssomViewExtensionsTests.cs b/src/AngleSharp.Renderer.Tests/ElementCssomViewExtensionsTests.cs index 8da70d1..68081f8 100644 --- a/src/AngleSharp.Renderer.Tests/ElementCssomViewExtensionsTests.cs +++ b/src/AngleSharp.Renderer.Tests/ElementCssomViewExtensionsTests.cs @@ -6,6 +6,17 @@ namespace AngleSharp.Renderer.Tests; public sealed class ElementCssomViewExtensionsTests { + [Fact] + public void GetInteractiveHtmlRendererState_ReturnsSingleInstancePerContext() + { + var context = BrowsingContext.New(CreateConfiguration(120, 80)); + + var first = context.GetDomHarness(); + var second = context.GetDomHarness(); + + Assert.Same(first, second); + } + [Fact] public async Task GetBoundingClientRect_AndClientMetrics_AreDerivedFromBorderBox() { @@ -27,6 +38,8 @@ public async Task GetBoundingClientRect_AndClientMetrics_AreDerivedFromBorderBox Assert.Equal(54d, rect.Width); Assert.Equal(34d, rect.Height); Assert.Equal(1, rects.Length); + Assert.Equal(2, element.GetClientLeft()); + Assert.Equal(2, element.GetClientTop()); Assert.Equal(50, element.GetClientWidth()); Assert.Equal(30, element.GetClientHeight()); Assert.Equal(54, element.GetOffsetWidth()); @@ -85,9 +98,179 @@ public async Task OffsetParent_AndOffsets_AreResolvedFromPositionedAncestor() Assert.Equal(10, child.GetOffsetTop()); } + [Fact] + public async Task OffsetParent_IsNull_ForFixedPositionedElements() + { + var document = await ParseAsync(""" + + + +
+ + + """); + + var child = document.QuerySelector("#child"); + Assert.NotNull(child); + + Assert.Null(child!.GetOffsetParent()); + } + + [Fact] + public async Task ScrollMetrics_IncludeOverflowingDescendants() + { + var document = await ParseAsync(""" + + + +
+
+
+ + + """); + + var viewport = document.QuerySelector("#viewport"); + Assert.NotNull(viewport); + + Assert.Equal(40, viewport!.GetClientWidth()); + Assert.Equal(20, viewport.GetClientHeight()); + Assert.True(viewport.GetScrollWidth() > viewport.GetClientWidth()); + Assert.True(viewport.GetScrollHeight() > viewport.GetClientHeight()); + } + + [Fact] + public async Task ScrollPositions_AreMutable_AndClamped() + { + var document = await ParseAsync(""" + + + +
+
+
+ + + """); + + var viewport = document.QuerySelector("#viewport"); + Assert.NotNull(viewport); + + var maxLeft = viewport!.GetScrollWidth() - viewport.GetClientWidth(); + var maxTop = viewport.GetScrollHeight() - viewport.GetClientHeight(); + Assert.True(maxLeft > 0); + Assert.True(maxTop > 0); + + viewport.SetScrollLeft(500); + viewport.SetScrollTop(500); + Assert.Equal(maxLeft, viewport.GetScrollLeft()); + Assert.Equal(maxTop, viewport.GetScrollTop()); + + viewport.ScrollTo(-10, -5); + Assert.Equal(0d, viewport.GetScrollLeft()); + Assert.Equal(0d, viewport.GetScrollTop()); + + viewport.ScrollBy(maxLeft + 20, maxTop + 20); + Assert.Equal(maxLeft, viewport.GetScrollLeft()); + Assert.Equal(maxTop, viewport.GetScrollTop()); + } + + [Fact] + public async Task ScrollPositions_AreIsolatedPerBrowsingContext() + { + const string html = """ + + + +
+
+
+ + + """; + + var contextA = BrowsingContext.New(CreateConfiguration(120, 80)); + var contextB = BrowsingContext.New(CreateConfiguration(120, 80)); + + var documentA = await contextA.OpenAsync(request => request.Content(html)); + var documentB = await contextB.OpenAsync(request => request.Content(html)); + + var viewportA = documentA.QuerySelector("#viewport"); + var viewportB = documentB.QuerySelector("#viewport"); + + Assert.NotNull(viewportA); + Assert.NotNull(viewportB); + + viewportA!.SetScrollLeft(25); + viewportA.SetScrollTop(15); + + Assert.True(viewportA.GetScrollLeft() > 0d); + Assert.True(viewportA.GetScrollTop() > 0d); + Assert.Equal(0d, viewportB!.GetScrollLeft()); + Assert.Equal(0d, viewportB.GetScrollTop()); + } + + [Fact] + public async Task DomHarness_RaisesPaintInvalidated_OnInteractionChanges() + { + var context = BrowsingContext.New(CreateConfiguration(160, 100)); + var document = await context.OpenAsync(request => request.Content(""" + + + +
+
+
+ + + """)); + + var harness = context.GetDomHarness(); + var viewport = document.QuerySelector("#viewport"); + Assert.NotNull(viewport); + + var invalidatedCount = 0; + harness.PaintInvalidated += (_, _) => invalidatedCount++; + + viewport!.SetScrollLeft(20); + viewport.SetScrollTop(10); + harness.MousePosition = (20d, 20d); + + Assert.Same(viewport, harness.HoveredElement); + + Assert.True(invalidatedCount >= 3); + } + + [Fact] + public async Task DomHarness_PaintsOnAssignedRenderDevice() + { + var context = BrowsingContext.New(CreateConfiguration(210, 130)); + _ = await context.OpenAsync(request => request.Content("
")); + + var harness = context.GetDomHarness(); + var image = harness.PaintToPng(); + + Assert.Equal(210, image.Width); + Assert.Equal(130, image.Height); + } + private static async Task ParseAsync(string html) { - var context = BrowsingContext.New(Configuration.Default.WithCss()); + var context = BrowsingContext.New(CreateConfiguration(240, 160)); return await context.OpenAsync(request => request.Content(html)); } + + private static IConfiguration CreateConfiguration(int width, int height) + { + return Configuration.Default + .WithCss() + .WithRenderDevice(new DefaultRenderDevice + { + ViewPortWidth = width, + ViewPortHeight = height, + DeviceWidth = width, + DeviceHeight = height, + FontSize = 16, + }); + } } diff --git a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs index 31bd093..b225bc7 100644 --- a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs +++ b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs @@ -17,10 +17,10 @@ public async Task BuildDisplayList_IncludesBackgroundAndTextCommands() var document = await ParseAsync("

Title

Hello renderer world from AngleSharp.

"); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 360, - Height = 240, + ViewPortWidth = 360, + ViewPortHeight = 240, FontSize = 16f, }); @@ -39,10 +39,10 @@ public async Task BuildDisplayList_PaintsImageElementsFromCurrentDownload() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 240, - Height = 160, + ViewPortWidth = 240, + ViewPortHeight = 160, FontSize = 16f, }); @@ -72,17 +72,17 @@ public async Task BuildDisplayList_CachesHttpImagePayloadPerDocument() var renderer = new HtmlRenderer(); - var first = renderer.BuildDisplayList(document, new HtmlRenderOptions + var first = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 240, - Height = 160, + ViewPortWidth = 240, + ViewPortHeight = 160, FontSize = 16f, }); - var second = renderer.BuildDisplayList(document, new HtmlRenderOptions + var second = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 240, - Height = 160, + ViewPortWidth = 240, + ViewPortHeight = 160, FontSize = 16f, }); @@ -101,10 +101,10 @@ public async Task BuildDisplayList_ParsesLinearGradientBackgrounds() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 240, - Height = 160, + ViewPortWidth = 240, + ViewPortHeight = 160, FontSize = 16f, }); @@ -130,10 +130,10 @@ public async Task BuildDisplayList_ParsesRadialGradientBackgrounds() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 240, - Height = 160, + ViewPortWidth = 240, + ViewPortHeight = 160, FontSize = 16f, }); @@ -159,10 +159,10 @@ public async Task BuildDisplayList_ParsesConicGradientBackgrounds() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 240, - Height = 160, + ViewPortWidth = 240, + ViewPortHeight = 160, FontSize = 16f, }); @@ -192,10 +192,10 @@ public async Task BuildDisplayList_PropagatesFontSizeStyleAndDecorationToTextCom """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 360, - Height = 240, + ViewPortWidth = 360, + ViewPortHeight = 240, FontSize = 16f, }); @@ -229,10 +229,10 @@ one two three four five six seven eight nine ten eleven twelve """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 220, - Height = 180, + ViewPortWidth = 220, + ViewPortHeight = 180, FontSize = 10f, }); @@ -261,10 +261,10 @@ public async Task BuildDisplayList_AppliesColspanToCellGeometry() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 240, - Height = 160, + ViewPortWidth = 240, + ViewPortHeight = 160, FontSize = 16f, }); @@ -289,10 +289,10 @@ public async Task BuildDisplayList_CollapsesAdjacentCellBordersWhenRequested() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 240, - Height = 160, + ViewPortWidth = 240, + ViewPortHeight = 160, FontSize = 16f, }); @@ -320,10 +320,10 @@ public async Task BuildDisplayList_UsesColumnWidthsFromColgroup() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 240, - Height = 160, + ViewPortWidth = 240, + ViewPortHeight = 160, FontSize = 16f, }); @@ -347,10 +347,10 @@ public async Task BuildDisplayList_LaysOutFlexItemsInCenteredRow() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 200, - Height = 120, + ViewPortWidth = 200, + ViewPortHeight = 120, FontSize = 16f, }); @@ -384,10 +384,10 @@ public async Task BuildDisplayList_LaysOutFlexItemsInColumnDirection() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 200, - Height = 140, + ViewPortWidth = 200, + ViewPortHeight = 140, FontSize = 16f, }); @@ -421,10 +421,10 @@ public async Task BuildDisplayList_AppliesFlexGrowToItems() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 200, - Height = 120, + ViewPortWidth = 200, + ViewPortHeight = 120, FontSize = 16f, }); @@ -452,10 +452,10 @@ public async Task BuildDisplayList_WrapsItemsToNewLinesWhenNeeded() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 200, - Height = 120, + ViewPortWidth = 200, + ViewPortHeight = 120, FontSize = 16f, }); @@ -483,10 +483,10 @@ public async Task BuildDisplayList_UsesAlignSelfForIndividualItems() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 200, - Height = 120, + ViewPortWidth = 200, + ViewPortHeight = 120, FontSize = 16f, }); @@ -513,10 +513,10 @@ public async Task BuildDisplayList_UsesFlexEndJustification() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 200, - Height = 120, + ViewPortWidth = 200, + ViewPortHeight = 120, FontSize = 16f, }); @@ -527,8 +527,8 @@ public async Task BuildDisplayList_UsesFlexEndJustification() .ToArray(); Assert.Equal(2, childBackgrounds.Length); - Assert.Equal(76f, childBackgrounds[0].Rect.X); - Assert.Equal(96f, childBackgrounds[1].Rect.X); + Assert.Equal(60f, childBackgrounds[0].Rect.X); + Assert.Equal(80f, childBackgrounds[1].Rect.X); } [Fact] @@ -544,10 +544,10 @@ public async Task BuildDisplayList_UsesSpaceBetweenJustification() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 200, - Height = 120, + ViewPortWidth = 200, + ViewPortHeight = 120, FontSize = 16f, }); @@ -558,8 +558,8 @@ public async Task BuildDisplayList_UsesSpaceBetweenJustification() .ToArray(); Assert.Equal(2, childBackgrounds.Length); - Assert.Equal(31f, childBackgrounds[0].Rect.X); - Assert.Equal(81f, childBackgrounds[1].Rect.X); + Assert.Equal(15f, childBackgrounds[0].Rect.X); + Assert.Equal(65f, childBackgrounds[1].Rect.X); } [Fact] @@ -575,10 +575,10 @@ public async Task BuildDisplayList_UsesRowReverseDirection() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 200, - Height = 120, + ViewPortWidth = 200, + ViewPortHeight = 120, FontSize = 16f, }); @@ -610,10 +610,10 @@ public async Task BuildDisplayList_UsesColumnReverseDirection() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 200, - Height = 140, + ViewPortWidth = 200, + ViewPortHeight = 140, FontSize = 16f, }); @@ -645,10 +645,10 @@ public async Task BuildDisplayList_UsesFlexBasisForMainSize() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 200, - Height = 120, + ViewPortWidth = 200, + ViewPortHeight = 120, FontSize = 16f, }); @@ -676,10 +676,10 @@ public async Task BuildDisplayList_LaysOutGridItemsInRowsAndColumns() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 200, - Height = 120, + ViewPortWidth = 200, + ViewPortHeight = 120, FontSize = 16f, }); @@ -691,14 +691,14 @@ public async Task BuildDisplayList_LaysOutGridItemsInRowsAndColumns() .ToArray(); Assert.Equal(4, childBackgrounds.Length); - Assert.Equal(16f, childBackgrounds[0].Rect.X); - Assert.Equal(16f, childBackgrounds[0].Rect.Y); - Assert.Equal(66f, childBackgrounds[1].Rect.X); - Assert.Equal(16f, childBackgrounds[1].Rect.Y); - Assert.Equal(16f, childBackgrounds[2].Rect.X); - Assert.Equal(36f, childBackgrounds[2].Rect.Y); - Assert.Equal(66f, childBackgrounds[3].Rect.X); - Assert.Equal(36f, childBackgrounds[3].Rect.Y); + Assert.Equal(0f, childBackgrounds[0].Rect.X); + Assert.Equal(0f, childBackgrounds[0].Rect.Y); + Assert.Equal(50f, childBackgrounds[1].Rect.X); + Assert.Equal(0f, childBackgrounds[1].Rect.Y); + Assert.Equal(0f, childBackgrounds[2].Rect.X); + Assert.Equal(20f, childBackgrounds[2].Rect.Y); + Assert.Equal(50f, childBackgrounds[3].Rect.X); + Assert.Equal(20f, childBackgrounds[3].Rect.Y); } [Fact] @@ -716,10 +716,10 @@ public async Task BuildDisplayList_AppliesGridGapsToTrackPlacement() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 200, - Height = 120, + ViewPortWidth = 200, + ViewPortHeight = 120, FontSize = 16f, }); @@ -731,14 +731,14 @@ public async Task BuildDisplayList_AppliesGridGapsToTrackPlacement() .ToArray(); Assert.Equal(4, childBackgrounds.Length); - Assert.Equal(16f, childBackgrounds[0].Rect.X); - Assert.Equal(16f, childBackgrounds[0].Rect.Y); - Assert.Equal(76f, childBackgrounds[1].Rect.X); - Assert.Equal(16f, childBackgrounds[1].Rect.Y); - Assert.Equal(16f, childBackgrounds[2].Rect.X); - Assert.Equal(46f, childBackgrounds[2].Rect.Y); - Assert.Equal(76f, childBackgrounds[3].Rect.X); - Assert.Equal(46f, childBackgrounds[3].Rect.Y); + Assert.Equal(0f, childBackgrounds[0].Rect.X); + Assert.Equal(0f, childBackgrounds[0].Rect.Y); + Assert.Equal(60f, childBackgrounds[1].Rect.X); + Assert.Equal(0f, childBackgrounds[1].Rect.Y); + Assert.Equal(0f, childBackgrounds[2].Rect.X); + Assert.Equal(30f, childBackgrounds[2].Rect.Y); + Assert.Equal(60f, childBackgrounds[3].Rect.X); + Assert.Equal(30f, childBackgrounds[3].Rect.Y); } [Fact] @@ -753,10 +753,10 @@ public async Task BuildDisplayList_AppliesExplicitGridItemPlacement() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 200, - Height = 120, + ViewPortWidth = 200, + ViewPortHeight = 120, FontSize = 16f, }); @@ -764,8 +764,8 @@ public async Task BuildDisplayList_AppliesExplicitGridItemPlacement() .OfType() .Single(command => command.Rect.Width == 20f && command.Rect.Height == 10f); - Assert.Equal(66f, childBackground.Rect.X); - Assert.Equal(36f, childBackground.Rect.Y); + Assert.Equal(50f, childBackground.Rect.X); + Assert.Equal(20f, childBackground.Rect.Y); } [Fact] @@ -780,10 +780,10 @@ public async Task BuildDisplayList_AppliesSpanBasedGridItemPlacement() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 200, - Height = 120, + ViewPortWidth = 200, + ViewPortHeight = 120, FontSize = 16f, }); @@ -791,8 +791,8 @@ public async Task BuildDisplayList_AppliesSpanBasedGridItemPlacement() .OfType() .Single(command => command.Rect.Width == 20f && command.Rect.Height == 10f); - Assert.Equal(16f, childBackground.Rect.X); - Assert.Equal(16f, childBackground.Rect.Y); + Assert.Equal(0f, childBackground.Rect.X); + Assert.Equal(0f, childBackground.Rect.Y); } [Fact] @@ -809,10 +809,10 @@ public async Task BuildDisplayList_AppliesAutoPlacementAcrossImplicitTracks() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 200, - Height = 120, + ViewPortWidth = 200, + ViewPortHeight = 120, FontSize = 16f, }); @@ -824,12 +824,12 @@ public async Task BuildDisplayList_AppliesAutoPlacementAcrossImplicitTracks() .ToArray(); Assert.Equal(3, childBackgrounds.Length); - Assert.Equal(16f, childBackgrounds[0].Rect.X); - Assert.Equal(16f, childBackgrounds[0].Rect.Y); - Assert.Equal(66f, childBackgrounds[1].Rect.X); - Assert.Equal(16f, childBackgrounds[1].Rect.Y); - Assert.Equal(16f, childBackgrounds[2].Rect.X); - Assert.Equal(36f, childBackgrounds[2].Rect.Y); + Assert.Equal(0f, childBackgrounds[0].Rect.X); + Assert.Equal(0f, childBackgrounds[0].Rect.Y); + Assert.Equal(50f, childBackgrounds[1].Rect.X); + Assert.Equal(0f, childBackgrounds[1].Rect.Y); + Assert.Equal(0f, childBackgrounds[2].Rect.X); + Assert.Equal(20f, childBackgrounds[2].Rect.Y); } [Fact] @@ -842,10 +842,10 @@ public async Task BuildDisplayList_AppliesLetterSpacingToTextCommands() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 200, - Height = 120, + ViewPortWidth = 200, + ViewPortHeight = 120, FontSize = 16f, }); @@ -866,10 +866,10 @@ Decorated text """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 240, - Height = 120, + ViewPortWidth = 240, + ViewPortHeight = 120, FontSize = 16f, }); @@ -890,10 +890,10 @@ public async Task BuildDisplayList_IndentsFirstLineOfBlockText() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 240, - Height = 160, + ViewPortWidth = 240, + ViewPortHeight = 160, FontSize = 16f, }); @@ -916,10 +916,10 @@ public async Task BuildDisplayList_ShiftsInlineTextWithVerticalAlign() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 240, - Height = 160, + ViewPortWidth = 240, + ViewPortHeight = 160, FontSize = 16f, }); @@ -941,10 +941,10 @@ public async Task RenderToPng_UsesFontFamilyFallbackList() """); var renderer = new HtmlRenderer(); - var image = renderer.RenderToPng(document, new HtmlRenderOptions + var image = renderer.RenderToPng(document, new DefaultRenderDevice { - Width = 240, - Height = 120, + ViewPortWidth = 240, + ViewPortHeight = 120, FontSize = 18f, }); @@ -958,10 +958,10 @@ public async Task RenderToPng_ReturnsPngPayload() var document = await ParseAsync("

PNG smoke test output.

"); var renderer = new HtmlRenderer(); - var image = renderer.RenderToPng(document, new HtmlRenderOptions + var image = renderer.RenderToPng(document, new DefaultRenderDevice { - Width = 320, - Height = 180, + ViewPortWidth = 320, + ViewPortHeight = 180, }); Assert.Equal("image/png", image.MimeType); @@ -982,12 +982,10 @@ public async Task BuildDisplayList_RendersBoxBackgroundFromPaddingAndMargins() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 300, - Height = 200, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 300, + ViewPortHeight = 200, }); var backgrounds = displayList.Commands.OfType().ToArray(); @@ -1011,12 +1009,10 @@ public async Task BuildDisplayList_RendersPerSideBorderWidths() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 300, - Height = 200, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 300, + ViewPortHeight = 200, }); var fills = displayList.Commands.OfType().ToArray(); @@ -1048,12 +1044,10 @@ public async Task BuildDisplayList_ResolvesPercentageWidthAgainstContainingBlock """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 300, - Height = 150, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 300, + ViewPortHeight = 150, }); var fills = displayList.Commands.OfType().ToArray(); @@ -1074,12 +1068,10 @@ public async Task BuildDisplayList_CentersBlockWithAutoHorizontalMargins() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 300, - Height = 150, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 300, + ViewPortHeight = 150, }); var fills = displayList.Commands.OfType().ToArray(); @@ -1101,12 +1093,10 @@ public async Task BuildDisplayList_CollapsesAdjacentVerticalMargins() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 300, - Height = 200, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 300, + ViewPortHeight = 200, }); var fills = displayList.Commands.OfType().ToArray(); @@ -1131,12 +1121,10 @@ public async Task BuildDisplayList_CollapsesParentAndFirstChildTopMargins() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 320, - Height = 240, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 320, + ViewPortHeight = 240, }); var fills = displayList.Commands.OfType().ToArray(); @@ -1161,12 +1149,10 @@ public async Task BuildDisplayList_DoesNotCollapseParentAndFirstChildTopMarginsW """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 320, - Height = 240, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 320, + ViewPortHeight = 240, }); var fills = displayList.Commands.OfType().ToArray(); @@ -1192,12 +1178,10 @@ public async Task BuildDisplayList_CollapsesParentAndLastChildBottomMargins() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 320, - Height = 260, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 320, + ViewPortHeight = 260, }); var fills = displayList.Commands.OfType().ToArray(); @@ -1216,12 +1200,10 @@ public async Task BuildDisplayList_DoesNotPaintBorderWhenStyleIsNone() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 300, - Height = 200, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 300, + ViewPortHeight = 200, }); var fills = displayList.Commands.OfType().ToArray(); @@ -1239,12 +1221,10 @@ public async Task BuildDisplayList_DoesNotPaintDisplayNoneElement() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 200, - Height = 120, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 200, + ViewPortHeight = 120, }); var fills = displayList.Commands.OfType().ToArray(); @@ -1261,12 +1241,10 @@ public async Task BuildDisplayList_DoesNotPaintVisibilityHiddenElement() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 200, - Height = 120, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 200, + ViewPortHeight = 120, }); var fills = displayList.Commands.OfType().ToArray(); @@ -1283,12 +1261,10 @@ public async Task BuildDisplayList_RendersInlineBlockBox() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 200, - Height = 120, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 200, + ViewPortHeight = 120, }); var redFill = displayList.Commands @@ -1310,12 +1286,10 @@ public async Task BuildDisplayList_RespectsDisplayBlock() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 200, - Height = 120, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 200, + ViewPortHeight = 120, }); var greenFill = displayList.Commands @@ -1336,12 +1310,10 @@ public async Task BuildDisplayList_TreatsInvalidDisplayFixedAsDefaultBlock() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 180, - Height = 80, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 180, + ViewPortHeight = 80, }); var redFill = displayList.Commands @@ -1361,12 +1333,10 @@ public async Task BuildDisplayList_TreatsInvalidDisplayRelativeAsDefaultBlock() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 180, - Height = 80, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 180, + ViewPortHeight = 80, }); var blueFill = displayList.Commands @@ -1387,12 +1357,10 @@ public async Task BuildDisplayList_FloatsLeftAndWrapsFollowingBlock() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 180, - Height = 100, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 180, + ViewPortHeight = 100, }); var fills = displayList.Commands.OfType().ToArray(); @@ -1420,12 +1388,10 @@ public async Task BuildDisplayList_AppliesRelativePositionOffsetWithoutChangingF """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 200, - Height = 120, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 200, + ViewPortHeight = 120, }); var fills = displayList.Commands.OfType().ToArray(); @@ -1448,12 +1414,10 @@ public async Task BuildDisplayList_RendersFixedPositionRelativeToViewportAndExcl """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 200, - Height = 120, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 200, + ViewPortHeight = 120, }); var fills = displayList.Commands.OfType().ToArray(); @@ -1475,12 +1439,10 @@ public async Task BuildDisplayList_DistinguishesPaddingFromMargin() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 220, - Height = 120, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 220, + ViewPortHeight = 120, }); var redFill = displayList.Commands @@ -1504,12 +1466,10 @@ public async Task BuildDisplayList_PaintsOutlineOutsideBorderWithoutChangingFlow """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 200, - Height = 120, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 200, + ViewPortHeight = 120, }); var fills = displayList.Commands.OfType().ToArray(); @@ -1538,12 +1498,10 @@ public async Task BuildDisplayList_RendersAbsolutePositionRelativeToContainingBl """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 240, - Height = 140, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 240, + ViewPortHeight = 140, }); var fills = displayList.Commands.OfType().ToArray(); @@ -1568,12 +1526,10 @@ public async Task BuildDisplayList_PaintsHigherZIndexAfterLowerForPositionedOver """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 220, - Height = 120, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 220, + ViewPortHeight = 120, }); var fills = displayList.Commands.OfType().ToArray(); @@ -1597,12 +1553,10 @@ public async Task BuildDisplayList_PaintsNegativeZIndexBeforeInFlowBackground() """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 220, - Height = 120, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 220, + ViewPortHeight = 120, }); var fills = displayList.Commands.OfType().ToArray(); @@ -1627,12 +1581,10 @@ public async Task BuildDisplayList_UsesZIndexOverSourceOrderForPositionedSibling """); var renderer = new HtmlRenderer(); - var displayList = renderer.BuildDisplayList(document, new HtmlRenderOptions + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice { - Width = 220, - Height = 120, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 220, + ViewPortHeight = 120, }); var fills = displayList.Commands.OfType().ToArray(); diff --git a/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs b/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs index 0a6bf36..ceec361 100644 --- a/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs +++ b/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs @@ -1,6 +1,7 @@ namespace AngleSharp.Renderer.Tests; using AngleSharp; +using AngleSharp.Css; using AngleSharp.Html.Dom; [Trait("Category", "Visual")] @@ -21,12 +22,10 @@ public async Task RenderToPng_PaintsBoxBackgroundAndBorderAtExpectedPixels() """); var renderer = new HtmlRenderer(); - var image = renderer.RenderToPng(document, new HtmlRenderOptions + var image = renderer.RenderToPng(document, new DefaultRenderDevice { - Width = 120, - Height = 120, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 120, + ViewPortHeight = 120, }); VisualSnapshotVerifier.VerifyOrCreate( @@ -51,12 +50,10 @@ public async Task RenderToPng_CentersAutoMarginBlock() """); var renderer = new HtmlRenderer(); - var image = renderer.RenderToPng(document, new HtmlRenderOptions + var image = renderer.RenderToPng(document, new DefaultRenderDevice { - Width = 120, - Height = 80, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 120, + ViewPortHeight = 80, }); VisualSnapshotVerifier.VerifyOrCreate( @@ -82,12 +79,10 @@ public async Task RenderToPng_ShowsCollapsedVerticalMarginGap() """); var renderer = new HtmlRenderer(); - var image = renderer.RenderToPng(document, new HtmlRenderOptions + var image = renderer.RenderToPng(document, new DefaultRenderDevice { - Width = 120, - Height = 120, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 120, + ViewPortHeight = 120, }); VisualSnapshotVerifier.VerifyOrCreate( @@ -115,12 +110,10 @@ public async Task RenderToPng_RendersSimpleTableLayout() """); var renderer = new HtmlRenderer(); - var image = renderer.RenderToPng(document, new HtmlRenderOptions + var image = renderer.RenderToPng(document, new DefaultRenderDevice { - Width = 180, - Height = 120, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 180, + ViewPortHeight = 120, }); VisualSnapshotVerifier.VerifyOrCreate( @@ -150,12 +143,10 @@ public async Task RenderToPng_RendersTableCellBordersAndWidths() """); var renderer = new HtmlRenderer(); - var image = renderer.RenderToPng(document, new HtmlRenderOptions + var image = renderer.RenderToPng(document, new DefaultRenderDevice { - Width = 220, - Height = 120, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 220, + ViewPortHeight = 120, }); VisualSnapshotVerifier.VerifyOrCreate( @@ -191,12 +182,10 @@ public async Task RenderToPng_RendersTableWithColspanAndRowspan() """); var renderer = new HtmlRenderer(); - var image = renderer.RenderToPng(document, new HtmlRenderOptions + var image = renderer.RenderToPng(document, new DefaultRenderDevice { - Width = 220, - Height = 140, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 220, + ViewPortHeight = 140, }); VisualSnapshotVerifier.VerifyOrCreate( @@ -309,12 +298,10 @@ public async Task RenderToPng_PaintsAbsolutePositionedElementOutOfFlow() """); var renderer = new HtmlRenderer(); - var image = renderer.RenderToPng(document, new HtmlRenderOptions + var image = renderer.RenderToPng(document, new DefaultRenderDevice { - Width = 160, - Height = 100, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 160, + ViewPortHeight = 100, }); VisualSnapshotVerifier.VerifyOrCreate( @@ -342,12 +329,10 @@ public async Task RenderToPng_PaintsHigherZIndexAboveLowerZIndex() """); var renderer = new HtmlRenderer(); - var image = renderer.RenderToPng(document, new HtmlRenderOptions + var image = renderer.RenderToPng(document, new DefaultRenderDevice { - Width = 160, - Height = 100, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 160, + ViewPortHeight = 100, }); VisualSnapshotVerifier.VerifyOrCreate( @@ -374,12 +359,10 @@ public async Task RenderToPng_PaintsNegativeZIndexBehindInFlowContent() """); var renderer = new HtmlRenderer(); - var image = renderer.RenderToPng(document, new HtmlRenderOptions + var image = renderer.RenderToPng(document, new DefaultRenderDevice { - Width = 160, - Height = 100, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 160, + ViewPortHeight = 100, }); VisualSnapshotVerifier.VerifyOrCreate( @@ -410,12 +393,10 @@ public async Task RenderToPng_RendersMixedTextSizesStylesAndDecorations() """); var renderer = new HtmlRenderer(); - var image = renderer.RenderToPng(document, new HtmlRenderOptions + var image = renderer.RenderToPng(document, new DefaultRenderDevice { - Width = 260, - Height = 120, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 260, + ViewPortHeight = 120, FontSize = 16f, }); @@ -446,12 +427,10 @@ one two three four five six seven eight nine ten eleven twelve thirteen fourteen """); var renderer = new HtmlRenderer(); - var image = renderer.RenderToPng(document, new HtmlRenderOptions + var image = renderer.RenderToPng(document, new DefaultRenderDevice { - Width = 220, - Height = 180, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 220, + ViewPortHeight = 180, FontSize = 12f, }); @@ -482,12 +461,10 @@ Decoration test """); var renderer = new HtmlRenderer(); - var image = renderer.RenderToPng(document, new HtmlRenderOptions + var image = renderer.RenderToPng(document, new DefaultRenderDevice { - Width = 240, - Height = 100, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 240, + ViewPortHeight = 100, FontSize = 18f, }); @@ -521,12 +498,10 @@ Indented text that wraps to a second line. """); var renderer = new HtmlRenderer(); - var image = renderer.RenderToPng(document, new HtmlRenderOptions + var image = renderer.RenderToPng(document, new DefaultRenderDevice { - Width = 260, - Height = 160, - Padding = 0f, - ParagraphSpacing = 0f, + ViewPortWidth = 260, + ViewPortHeight = 160, FontSize = 16f, }); @@ -551,12 +526,10 @@ public async Task RenderToPng_RendersWebSafeFontFamiliesDifferently() """); var renderer = new HtmlRenderer(); - var image = renderer.RenderToPng(document, new HtmlRenderOptions + var image = renderer.RenderToPng(document, new DefaultRenderDevice { - Width = 320, - Height = 200, - Padding = 0f, - ParagraphSpacing = 4f, + ViewPortWidth = 320, + ViewPortHeight = 200, FontSize = 16f, }); diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/web-safe-font-families.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/web-safe-font-families.linux.png index d1c4c815cbc27c0eaf08e9bc8e394a1c6a7971bf..2122eaa8fb4b631a6fcdd568a54cd09ad26b578b 100644 GIT binary patch delta 2851 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 delta 2854 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 cv9qIxh /// Provides convenience extension methods for document rendering. @@ -12,11 +13,24 @@ public static class DocumentRenderingExtensions /// Renders a document to PNG bytes with the default renderer. ///
/// The source document. - /// Optional rendering settings. /// The rendered PNG image. - public static RenderedImage RenderToPng(this IDocument document, HtmlRenderOptions? options = null) + public static RenderedImage RenderToPng(this IDocument document) { + var renderDevice = document.Context.GetService(); + return document.RenderToPng(renderDevice!); + } + + /// + /// Renders a document to PNG bytes with the default renderer. + /// + /// The source document. + /// The render device used for rendering. + /// The rendered PNG image. + public static RenderedImage RenderToPng(this IDocument document, IRenderDevice renderDevice) + { + ArgumentNullException.ThrowIfNull(renderDevice); + var renderer = new HtmlRenderer(); - return renderer.RenderToPng(document, options); + return renderer.RenderToPng(document, renderDevice); } } \ No newline at end of file diff --git a/src/AngleSharp.Renderer/ElementCssomViewExtensions.cs b/src/AngleSharp.Renderer/ElementCssomViewExtensions.cs index c05aa1f..9a828d8 100644 --- a/src/AngleSharp.Renderer/ElementCssomViewExtensions.cs +++ b/src/AngleSharp.Renderer/ElementCssomViewExtensions.cs @@ -1,5 +1,6 @@ namespace AngleSharp.Dom; +using AngleSharp; using AngleSharp.Attributes; using AngleSharp.Dom.Geometry; using AngleSharp.Renderer; @@ -13,11 +14,11 @@ public static class ElementCssomViewExtensions /// Returns the element's border-box rectangle in viewport coordinates. ///
[DomName("getBoundingClientRect")] - public static IDomRect GetBoundingClientRect(this IElement element, HtmlRenderOptions? options = null) + public static IDomRect GetBoundingClientRect(this IElement element) { ArgumentNullException.ThrowIfNull(element); - var metricsMap = GetMetricsMap(element, options); + var metricsMap = GetMetricsMap(element); if (metricsMap is null || !metricsMap.TryGetValue(element, out var metrics)) { return new DomRect(); @@ -30,11 +31,11 @@ public static IDomRect GetBoundingClientRect(this IElement element, HtmlRenderOp /// Returns the list of border-box fragments for the element. /// [DomName("getClientRects")] - public static IDomRectList GetClientRects(this IElement element, HtmlRenderOptions? options = null) + public static IDomRectList GetClientRects(this IElement element) { ArgumentNullException.ThrowIfNull(element); - var metricsMap = GetMetricsMap(element, options); + var metricsMap = GetMetricsMap(element); if (metricsMap is null || !metricsMap.TryGetValue(element, out var metrics)) { return new DomRectList(); @@ -55,65 +56,166 @@ public static IDomRectList GetClientRects(this IElement element, HtmlRenderOptio /// Returns the inner width including padding, excluding borders. /// [DomName("clientWidth")] - public static int GetClientWidth(this IElement element, HtmlRenderOptions? options = null) + public static int GetClientWidth(this IElement element) { - return GetRoundedDimension(element, options, static metrics => metrics.BorderBoxWidth - metrics.BorderLeft - metrics.BorderRight); + return GetRoundedDimension(element, static metrics => metrics.BorderBoxWidth - metrics.BorderLeft - metrics.BorderRight); + } + + /// + /// Returns the left border width. + /// + [DomName("clientLeft")] + public static int GetClientLeft(this IElement element) + { + return GetRoundedDimension(element, static metrics => metrics.BorderLeft); } /// /// Returns the inner height including padding, excluding borders. /// [DomName("clientHeight")] - public static int GetClientHeight(this IElement element, HtmlRenderOptions? options = null) + public static int GetClientHeight(this IElement element) { - return GetRoundedDimension(element, options, static metrics => metrics.BorderBoxHeight - metrics.BorderTop - metrics.BorderBottom); + return GetRoundedDimension(element, static metrics => metrics.BorderBoxHeight - metrics.BorderTop - metrics.BorderBottom); + } + + /// + /// Returns the top border width. + /// + [DomName("clientTop")] + public static int GetClientTop(this IElement element) + { + return GetRoundedDimension(element, static metrics => metrics.BorderTop); } /// /// Returns the width of the element's scrolling area. /// [DomName("scrollWidth")] - public static int GetScrollWidth(this IElement element, HtmlRenderOptions? options = null) + public static int GetScrollWidth(this IElement element) { - return GetClientWidth(element, options); + return GetScrollExtents(element).Width; } /// /// Returns the height of the element's scrolling area. /// [DomName("scrollHeight")] - public static int GetScrollHeight(this IElement element, HtmlRenderOptions? options = null) + public static int GetScrollHeight(this IElement element) + { + return GetScrollExtents(element).Height; + } + + /// + /// Returns the current horizontal scroll position. + /// + [DomName("scrollLeft")] + public static double GetScrollLeft(this IElement element) + { + ArgumentNullException.ThrowIfNull(element); + + var maxLeft = GetMaxScrollLeft(element); + var state = GetInteractiveState(element); + return state is null ? 0d : state.GetScrollLeft(element, maxLeft); + } + + /// + /// Sets the horizontal scroll position. + /// + [DomName("scrollLeft")] + public static void SetScrollLeft(this IElement element, double value) + { + ArgumentNullException.ThrowIfNull(element); + + var state = GetInteractiveState(element); + if (state is null) + { + return; + } + + state.SetScrollLeft(element, value, GetMaxScrollLeft(element)); + } + + /// + /// Returns the current vertical scroll position. + /// + [DomName("scrollTop")] + public static double GetScrollTop(this IElement element) + { + ArgumentNullException.ThrowIfNull(element); + + var maxTop = GetMaxScrollTop(element); + var state = GetInteractiveState(element); + return state is null ? 0d : state.GetScrollTop(element, maxTop); + } + + /// + /// Sets the vertical scroll position. + /// + [DomName("scrollTop")] + public static void SetScrollTop(this IElement element, double value) + { + ArgumentNullException.ThrowIfNull(element); + + var state = GetInteractiveState(element); + if (state is null) + { + return; + } + + state.SetScrollTop(element, value, GetMaxScrollTop(element)); + } + + /// + /// Sets scroll positions to absolute coordinates. + /// + [DomName("scrollTo")] + public static void ScrollTo(this IElement element, double x, double y) { - return GetClientHeight(element, options); + ArgumentNullException.ThrowIfNull(element); + + SetScrollLeft(element, x); + SetScrollTop(element, y); + } + + /// + /// Adjusts scroll positions by the supplied deltas. + /// + [DomName("scrollBy")] + public static void ScrollBy(this IElement element, double x, double y) + { + ArgumentNullException.ThrowIfNull(element); + + ScrollTo(element, GetScrollLeft(element) + x, GetScrollTop(element) + y); } /// /// Returns the border-box width. /// [DomName("offsetWidth")] - public static int GetOffsetWidth(this IElement element, HtmlRenderOptions? options = null) + public static int GetOffsetWidth(this IElement element) { - return GetRoundedDimension(element, options, static metrics => metrics.BorderBoxWidth); + return GetRoundedDimension(element, static metrics => metrics.BorderBoxWidth); } /// /// Returns the border-box height. /// [DomName("offsetHeight")] - public static int GetOffsetHeight(this IElement element, HtmlRenderOptions? options = null) + public static int GetOffsetHeight(this IElement element) { - return GetRoundedDimension(element, options, static metrics => metrics.BorderBoxHeight); + return GetRoundedDimension(element, static metrics => metrics.BorderBoxHeight); } /// /// Returns the left offset relative to the offset parent's padding edge. /// [DomName("offsetLeft")] - public static int GetOffsetLeft(this IElement element, HtmlRenderOptions? options = null) + public static int GetOffsetLeft(this IElement element) { ArgumentNullException.ThrowIfNull(element); - var metricsMap = GetMetricsMap(element, options); + var metricsMap = GetMetricsMap(element); if (metricsMap is null || !metricsMap.TryGetValue(element, out var metrics)) { return 0; @@ -133,11 +235,11 @@ public static int GetOffsetLeft(this IElement element, HtmlRenderOptions? option /// Returns the top offset relative to the offset parent's padding edge. /// [DomName("offsetTop")] - public static int GetOffsetTop(this IElement element, HtmlRenderOptions? options = null) + public static int GetOffsetTop(this IElement element) { ArgumentNullException.ThrowIfNull(element); - var metricsMap = GetMetricsMap(element, options); + var metricsMap = GetMetricsMap(element); if (metricsMap is null || !metricsMap.TryGetValue(element, out var metrics)) { return 0; @@ -181,11 +283,11 @@ public static int GetOffsetTop(this IElement element, HtmlRenderOptions? options return element.Owner?.Body; } - private static int GetRoundedDimension(IElement element, HtmlRenderOptions? options, Func selector) + private static int GetRoundedDimension(IElement element, Func selector) { ArgumentNullException.ThrowIfNull(element); - var metricsMap = GetMetricsMap(element, options); + var metricsMap = GetMetricsMap(element); if (metricsMap is null || !metricsMap.TryGetValue(element, out var metrics)) { return 0; @@ -195,10 +297,87 @@ private static int GetRoundedDimension(IElement element, HtmlRenderOptions? opti return (int)Math.Round(value); } - private static IReadOnlyDictionary? GetMetricsMap(IElement element, HtmlRenderOptions? options) + private static IReadOnlyDictionary? GetMetricsMap(IElement element) { var owner = element.Owner; - return owner is null ? null : HtmlRenderer.CaptureLayoutMetrics(owner, options); + if (owner is null) + { + return null; + } + + var harness = owner.Context.GetDomHarness(); + return HtmlRenderer.CaptureLayoutMetrics(owner, harness.RenderDevice); + } + + private static IDomHarness? GetInteractiveState(IElement element) + { + var owner = element.Owner; + return owner?.Context.GetDomHarness(); + } + + private static (int Width, int Height) GetScrollExtents(IElement element) + { + ArgumentNullException.ThrowIfNull(element); + + var metricsMap = GetMetricsMap(element); + if (metricsMap is null || !metricsMap.TryGetValue(element, out var elementMetrics)) + { + return (0, 0); + } + + var clientWidth = Math.Max(0d, elementMetrics.BorderBoxWidth - elementMetrics.BorderLeft - elementMetrics.BorderRight); + var clientHeight = Math.Max(0d, elementMetrics.BorderBoxHeight - elementMetrics.BorderTop - elementMetrics.BorderBottom); + var paddingBoxLeft = elementMetrics.BorderBoxX + elementMetrics.BorderLeft; + var paddingBoxTop = elementMetrics.BorderBoxY + elementMetrics.BorderTop; + var rightExtent = paddingBoxLeft + clientWidth; + var bottomExtent = paddingBoxTop + clientHeight; + + foreach (var (candidate, candidateMetrics) in metricsMap) + { + if (ReferenceEquals(candidate, element) || !IsDescendantOf(candidate, element)) + { + continue; + } + + rightExtent = Math.Max(rightExtent, candidateMetrics.BorderBoxX + candidateMetrics.BorderBoxWidth); + bottomExtent = Math.Max(bottomExtent, candidateMetrics.BorderBoxY + candidateMetrics.BorderBoxHeight); + } + + var scrollWidth = (int)Math.Round(Math.Max(clientWidth, rightExtent - paddingBoxLeft)); + var scrollHeight = (int)Math.Round(Math.Max(clientHeight, bottomExtent - paddingBoxTop)); + + return (Math.Max(0, scrollWidth), Math.Max(0, scrollHeight)); + } + + private static double GetMaxScrollLeft(IElement element) + { + var extents = GetScrollExtents(element); + var maxLeft = extents.Width - GetClientWidth(element); + return Math.Max(0d, maxLeft); + } + + private static double GetMaxScrollTop(IElement element) + { + var extents = GetScrollExtents(element); + var maxTop = extents.Height - GetClientHeight(element); + return Math.Max(0d, maxTop); + } + + private static bool IsDescendantOf(IElement candidate, IElement ancestor) + { + var current = candidate.ParentElement; + + while (current is not null) + { + if (ReferenceEquals(current, ancestor)) + { + return true; + } + + current = current.ParentElement; + } + + return false; } private static bool IsPositionFixed(IElement element) diff --git a/src/AngleSharp.Renderer/HtmlRenderOptions.cs b/src/AngleSharp.Renderer/HtmlRenderOptions.cs deleted file mode 100644 index 7f5e14f..0000000 --- a/src/AngleSharp.Renderer/HtmlRenderOptions.cs +++ /dev/null @@ -1,59 +0,0 @@ -using AngleSharp.Renderer.Rendering; - -namespace AngleSharp.Renderer; - -/// -/// Defines options for HTML-to-image rendering. -/// -public sealed class HtmlRenderOptions -{ - /// - /// Gets or sets the viewport width. - /// - public int Width { get; set; } = 1024; - - /// - /// Gets or sets the viewport height. - /// - public int Height { get; set; } = 768; - - /// - /// Gets or sets the background color of the output image. - /// - public RenderColor BackgroundColor { get; set; } = RenderColor.White; - - /// - /// Gets or sets the foreground text color. - /// - public RenderColor TextColor { get; set; } = RenderColor.Black; - - /// - /// Gets or sets the content padding in pixels. - /// - public float Padding { get; set; } = 16f; - - /// - /// Gets or sets the fallback font family used by the first draft renderer. - /// - public string FontFamily { get; set; } = "sans-serif"; - - /// - /// Gets or sets the fallback font size in pixels. - /// - public float FontSize { get; set; } = 16f; - - /// - /// Gets or sets the line-height multiplier used during layout. - /// - public float LineHeightMultiplier { get; set; } = 1.35f; - - /// - /// Gets or sets the additional spacing inserted between block paragraphs. - /// - public float ParagraphSpacing { get; set; } = 8f; - - /// - /// Gets or sets the average character width factor used by draft text measurement. - /// - public float AverageCharacterWidthFactor { get; set; } = 0.55f; -} \ No newline at end of file diff --git a/src/AngleSharp.Renderer/HtmlRenderer.cs b/src/AngleSharp.Renderer/HtmlRenderer.cs index 3e54f14..5fe926c 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.cs @@ -1,3 +1,5 @@ +namespace AngleSharp.Renderer; + using System.Globalization; using System.IO; using System.Linq; @@ -15,8 +17,6 @@ using SkiaSharp; -namespace AngleSharp.Renderer; - /// /// Renders HTML documents into image output. /// @@ -49,6 +49,18 @@ internal readonly record struct ElementLayoutMetrics( float PaddingTop, float PaddingBottom); + private readonly record struct LayoutContext( + int Width, + int Height, + RenderColor BackgroundColor, + RenderColor TextColor, + float Padding, + string FontFamily, + float FontSize, + float LineHeightMultiplier, + float ParagraphSpacing, + float AverageCharacterWidthFactor); + private sealed class LayoutCapture { private readonly Dictionary _metricsByElement = new(ReferenceEqualityComparer.Instance); @@ -64,19 +76,20 @@ public IReadOnlyDictionary Snapshot() } } - internal static IReadOnlyDictionary CaptureLayoutMetrics(IDocument document, HtmlRenderOptions? options = null) + internal static IReadOnlyDictionary CaptureLayoutMetrics(IDocument document, IRenderDevice renderDevice) { ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(renderDevice); - var effectiveOptions = options ?? new HtmlRenderOptions(); - var viewport = new RenderViewport(effectiveOptions.Width, effectiveOptions.Height); + var context = CreateLayoutContext(document, renderDevice); + var viewport = new RenderViewport(context.Width, context.Height); var capture = new LayoutCapture(); var previous = s_layoutCapture.Value; s_layoutCapture.Value = capture; try { - _ = BuildDisplayList(document, viewport, effectiveOptions); + _ = BuildDisplayList(document, viewport, context, renderDevice); return capture.Snapshot(); } finally @@ -139,19 +152,68 @@ public HtmlRenderer(IRenderBackend backend) _backend = backend; } + private static LayoutContext CreateLayoutContext(IDocument document, IRenderDevice renderDevice) + { + var width = Math.Max(1, (int)Math.Round(Convert.ToDouble(renderDevice.ViewPortWidth))); + var height = Math.Max(1, (int)Math.Round(Convert.ToDouble(renderDevice.ViewPortHeight))); + var defaultFontSize = Math.Max(1f, (float)renderDevice.FontSize); + var defaultFontFamily = "sans-serif"; + var defaultLineHeight = 1.35f; + var defaultTextColor = RenderColor.Black; + var defaultBackgroundColor = RenderColor.White; + + var rootElement = document.Body ?? document.DocumentElement; + if (rootElement is not null) + { + var styleMap = CreateStyleMap(rootElement.ComputeCurrentStyle(), rootElement); + defaultFontSize = ParseLength(styleMap, "font-size", defaultFontSize, defaultFontSize, allowAuto: false); + defaultFontFamily = styleMap.TryGetValue("font-family", out var family) && !string.IsNullOrWhiteSpace(family) + ? family.Trim('\'', '"', ' ') + : defaultFontFamily; + defaultLineHeight = ParseLineHeight(styleMap, defaultLineHeight); + defaultTextColor = ParseColor(styleMap.TryGetValue("color", out var colorValue) ? colorValue : null, defaultTextColor); + defaultBackgroundColor = ParseColor(styleMap.TryGetValue("background-color", out var backgroundValue) ? backgroundValue : null, defaultBackgroundColor); + } + + return new LayoutContext( + Width: width, + Height: height, + BackgroundColor: defaultBackgroundColor, + TextColor: defaultTextColor, + Padding: 0f, + FontFamily: defaultFontFamily, + FontSize: defaultFontSize, + LineHeightMultiplier: defaultLineHeight, + ParagraphSpacing: 0f, + AverageCharacterWidthFactor: 0.55f); + } + + /// + /// Renders the given document to a PNG image. + /// + /// The source document. + /// The rendered PNG image. + public RenderedImage RenderToPng(IDocument document) + { + ArgumentNullException.ThrowIfNull(document); + var renderDevice = document.Context.GetService(); + return RenderToPng(document, renderDevice!); + } + /// /// Renders the given document to a PNG image. /// /// The source document. - /// Optional rendering settings. + /// The render device used for viewport and typography defaults. /// The rendered PNG image. - public RenderedImage RenderToPng(IDocument document, HtmlRenderOptions? options = null) + public RenderedImage RenderToPng(IDocument document, IRenderDevice renderDevice) { ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(renderDevice); - var effectiveOptions = options ?? new HtmlRenderOptions(); - var viewport = new RenderViewport(effectiveOptions.Width, effectiveOptions.Height); - var displayList = BuildDisplayList(document, viewport, effectiveOptions); + var context = CreateLayoutContext(document, renderDevice); + var viewport = new RenderViewport(context.Width, context.Height); + var displayList = BuildDisplayList(document, viewport, context, renderDevice); return _backend.RenderToPng(displayList, viewport); } @@ -160,21 +222,34 @@ public RenderedImage RenderToPng(IDocument document, HtmlRenderOptions? options /// Builds a display list from the given document. /// /// The source document. - /// Optional rendering settings. /// The generated display list. - public DisplayList BuildDisplayList(IDocument document, HtmlRenderOptions? options = null) + public DisplayList BuildDisplayList(IDocument document) + { + ArgumentNullException.ThrowIfNull(document); + var renderDevice = document.Context.GetService(); + return BuildDisplayList(document, renderDevice!); + } + + /// + /// Builds a display list from the given document. + /// + /// The source document. + /// The render device used for viewport and typography defaults. + /// The generated display list. + public DisplayList BuildDisplayList(IDocument document, IRenderDevice renderDevice) { ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(renderDevice); - var effectiveOptions = options ?? new HtmlRenderOptions(); - var viewport = new RenderViewport(effectiveOptions.Width, effectiveOptions.Height); - return BuildDisplayList(document, viewport, effectiveOptions); + var context = CreateLayoutContext(document, renderDevice); + var viewport = new RenderViewport(context.Width, context.Height); + return BuildDisplayList(document, viewport, context, renderDevice); } - private static DisplayList BuildDisplayList(IDocument document, RenderViewport viewport, HtmlRenderOptions options) + private static DisplayList BuildDisplayList(IDocument document, RenderViewport viewport, LayoutContext context, IRenderDevice renderDevice) { var displayList = new DisplayList(); - displayList.FillRect(new RenderRect(0f, 0f, viewport.Width, viewport.Height), options.BackgroundColor); + displayList.FillRect(new RenderRect(0f, 0f, viewport.Width, viewport.Height), context.BackgroundColor); var window = document.DefaultView; if (window is null) @@ -184,29 +259,20 @@ private static DisplayList BuildDisplayList(IDocument document, RenderViewport v PrepareDocumentForRendering(document); - var renderDevice = new DefaultRenderDevice - { - ViewPortWidth = viewport.Width, - ViewPortHeight = viewport.Height, - DeviceWidth = viewport.Width, - DeviceHeight = viewport.Height, - FontSize = options.FontSize, - }; - var renderTree = window.Render(renderDevice); var body = document.Body; var root = body is null ? renderTree : renderTree.Find(body) ?? renderTree; - var contentX = options.Padding; - var contentY = options.Padding; - var contentWidth = viewport.Width - (2f * options.Padding); + var contentX = context.Padding; + var contentY = context.Padding; + var contentWidth = viewport.Width - (2f * context.Padding); if (contentWidth <= 0f) { return displayList; } - var textStyle = new RenderTextStyle(options.FontSize, options.TextColor, options.FontFamily, options.LineHeightMultiplier, 400f, false, false, false, options.TextColor, global::AngleSharp.Renderer.Rendering.RenderTextDecorationStyle.Solid, TextAlign.Left, 0f, 0f, 0f); + var textStyle = new RenderTextStyle(context.FontSize, context.TextColor, context.FontFamily, context.LineHeightMultiplier, 400f, false, false, false, context.TextColor, global::AngleSharp.Renderer.Rendering.RenderTextDecorationStyle.Solid, TextAlign.Left, 0f, 0f, 0f); var cursorY = contentY; var previousBlockMarginBottom = 0f; var suppressNextBlockTopMargin = false; @@ -228,11 +294,11 @@ private static DisplayList BuildDisplayList(IDocument document, RenderViewport v activeFloatBottom: ref activeFloatBottom, textIndentConsumed: ref textIndentConsumed, textStyle: textStyle, - options: options, + context: context, displayList: displayList, - maxY: viewport.Height - options.Padding); + maxY: viewport.Height - context.Padding); - if (cursorY > viewport.Height - options.Padding) + if (cursorY > viewport.Height - context.Padding) { break; } @@ -253,7 +319,7 @@ private static void LayoutNode( ref float activeFloatBottom, ref bool textIndentConsumed, RenderTextStyle textStyle, - HtmlRenderOptions options, + LayoutContext context, DisplayList displayList, float maxY, bool isFlexItem = false, @@ -264,10 +330,10 @@ private static void LayoutNode( switch (node) { case TextRenderNode textNode: - LayoutTextNode(textNode.Ref, containingX, containingWidth, ref cursorY, ref previousBlockMarginBottom, ref suppressNextBlockTopMargin, ref activeFloatLeftOffset, ref activeFloatBottom, ref textIndentConsumed, textStyle, options, displayList, maxY); + LayoutTextNode(textNode.Ref, containingX, containingWidth, ref cursorY, ref previousBlockMarginBottom, ref suppressNextBlockTopMargin, ref activeFloatLeftOffset, ref activeFloatBottom, ref textIndentConsumed, textStyle, context, displayList, maxY); return; case ElementRenderNode element: - LayoutElement(element, containingX, containingY, containingWidth, ref cursorY, ref previousBlockMarginBottom, ref suppressNextBlockTopMargin, ref activeFloatLeftOffset, ref activeFloatBottom, ref textIndentConsumed, textStyle, options, displayList, maxY, isFlexItem, isRowDirection, flexMainSize, flexCrossSize); + LayoutElement(element, containingX, containingY, containingWidth, ref cursorY, ref previousBlockMarginBottom, ref suppressNextBlockTopMargin, ref activeFloatLeftOffset, ref activeFloatBottom, ref textIndentConsumed, textStyle, context, displayList, maxY, isFlexItem, isRowDirection, flexMainSize, flexCrossSize); return; default: return; @@ -286,7 +352,7 @@ private static void LayoutElement( ref float activeFloatBottom, ref bool textIndentConsumed, RenderTextStyle inheritedTextStyle, - HtmlRenderOptions options, + LayoutContext context, DisplayList displayList, float maxY, bool isFlexItem = false, @@ -321,7 +387,7 @@ private static void LayoutElement( if (string.Equals(display, "table", StringComparison.OrdinalIgnoreCase)) { - LayoutTable(node, containingX, containingY, containingWidth, ref cursorY, ref previousBlockMarginBottom, ref suppressNextBlockTopMargin, ref activeFloatLeftOffset, ref activeFloatBottom, ref textIndentConsumed, inheritedTextStyle, options, displayList, maxY); + LayoutTable(node, containingX, containingY, containingWidth, ref cursorY, ref previousBlockMarginBottom, ref suppressNextBlockTopMargin, ref activeFloatLeftOffset, ref activeFloatBottom, ref textIndentConsumed, inheritedTextStyle, context, displayList, maxY); return; } @@ -350,7 +416,7 @@ private static void LayoutElement( var inlineText = NormalizeWhitespace(element.TextContent ?? string.Empty); if (inlineText.Length > 0) { - LayoutWrappedText(inlineText, flowContainingX, flowContainingWidth, ref cursorY, currentTextStyle, options, displayList, maxY, textIndentConsumed ? 0f : currentTextStyle.TextIndent); + LayoutWrappedText(inlineText, flowContainingX, flowContainingWidth, ref cursorY, currentTextStyle, context, displayList, maxY, textIndentConsumed ? 0f : currentTextStyle.TextIndent); textIndentConsumed = true; } @@ -450,12 +516,12 @@ private static void LayoutElement( } var borderBoxX = isFixed - ? options.Padding + leftOffset + ? context.Padding + leftOffset : isAbsolute ? containingX + leftOffset : flowBorderBoxX + (isRelative ? leftOffset : 0f); var borderBoxY = isFixed - ? options.Padding + topOffset + ? context.Padding + topOffset : isAbsolute ? containingY + topOffset : flowBorderBoxY + (isRelative ? topOffset : 0f); @@ -494,7 +560,7 @@ private static void LayoutElement( ref activeFloatBottom, ref textIndentConsumed, currentTextStyle, - options, + context, displayList, maxY, styleMap, @@ -528,7 +594,7 @@ private static void LayoutElement( ref activeFloatBottom, ref textIndentConsumed, currentTextStyle, - options, + context, displayList, maxY, styleMap, @@ -565,7 +631,7 @@ private static void LayoutElement( ref childActiveFloatBottom, ref textIndentConsumed, currentTextStyle, - options, + context, displayList, maxY); } @@ -583,7 +649,7 @@ private static void LayoutElement( activeFloatBottom: ref childActiveFloatBottom, textIndentConsumed: ref childTextIndentConsumed, textStyle: currentTextStyle, - options: options, + context: context, displayList: displayList, maxY: maxY); } @@ -622,7 +688,7 @@ private static void LayoutElement( activeFloatBottom: ref childActiveFloatBottom, textIndentConsumed: ref childTextIndentConsumed, textStyle: currentTextStyle, - options: options, + context: context, displayList: displayList, maxY: maxY); } @@ -642,7 +708,7 @@ private static void LayoutElement( currentTextStyle, flowContainingX, flowContainingWidth, - options.AverageCharacterWidthFactor, + context.AverageCharacterWidthFactor, ref inlineCursorX, ref inlineLineTop, ref inlineLineHeight, @@ -673,7 +739,7 @@ private static void LayoutElement( childTextStyle, flowContainingX, flowContainingWidth, - options.AverageCharacterWidthFactor, + context.AverageCharacterWidthFactor, ref inlineCursorX, ref inlineLineTop, ref inlineLineHeight, @@ -759,7 +825,7 @@ private static void LayoutElement( } cursorY = flowBorderBoxY + borderBoxHeight; - previousBlockMarginBottom = effectiveMarginBottom + options.ParagraphSpacing; + previousBlockMarginBottom = effectiveMarginBottom + context.ParagraphSpacing; } private readonly record struct FlexItemLayoutInfo( @@ -786,7 +852,7 @@ private static void LayoutFlexContainer( ref float activeFloatBottom, ref bool textIndentConsumed, RenderTextStyle inheritedTextStyle, - HtmlRenderOptions options, + LayoutContext context, DisplayList displayList, float maxY, Dictionary styleMap, @@ -1016,7 +1082,7 @@ private static void LayoutFlexContainer( if (item.Node is TextRenderNode textNode) { - LayoutTextNode(textNode.Ref, childX, containingWidth, ref childCursorY, ref childPreviousBlockMarginBottom, ref childSuppressNextBlockTopMargin, ref childActiveFloatLeftOffset, ref childActiveFloatBottom, ref childTextIndentConsumed, inheritedTextStyle, options, displayList, maxY); + LayoutTextNode(textNode.Ref, childX, containingWidth, ref childCursorY, ref childPreviousBlockMarginBottom, ref childSuppressNextBlockTopMargin, ref childActiveFloatLeftOffset, ref childActiveFloatBottom, ref childTextIndentConsumed, inheritedTextStyle, context, displayList, maxY); } else if (item.Node is ElementRenderNode elementChild) { @@ -1042,7 +1108,7 @@ private static void LayoutFlexContainer( activeFloatBottom: ref childActiveFloatBottomOffset, textIndentConsumed: ref childTextIndent, textStyle: inheritedTextStyle, - options: options, + context: context, displayList: displayList, maxY: maxY, isFlexItem: true, @@ -1105,7 +1171,7 @@ private static void LayoutFlexContainer( } cursorY = flowBorderBoxY + borderBoxHeight; - previousBlockMarginBottom = effectiveMarginBottom + options.ParagraphSpacing; + previousBlockMarginBottom = effectiveMarginBottom + context.ParagraphSpacing; suppressNextBlockTopMargin = false; } @@ -1167,7 +1233,7 @@ private static void LayoutTable( ref float activeFloatBottom, ref bool textIndentConsumed, RenderTextStyle inheritedTextStyle, - HtmlRenderOptions options, + LayoutContext context, DisplayList displayList, float maxY) { @@ -1323,7 +1389,7 @@ private static void LayoutTable( var paddingRight = placement.PaddingRight; var borderLeftWidth = placement.BorderLeftWidth; var borderRightWidth = placement.BorderRightWidth; - var textWidth = placement.Text.Length > 0 ? EstimateTextWidth(placement.Text, placement.CellTextStyle.FontSize, options.AverageCharacterWidthFactor, placement.CellTextStyle.LetterSpacing) : 0f; + var textWidth = placement.Text.Length > 0 ? EstimateTextWidth(placement.Text, placement.CellTextStyle.FontSize, context.AverageCharacterWidthFactor, placement.CellTextStyle.LetterSpacing) : 0f; var minCellWidth = textWidth + paddingLeft + paddingRight + borderLeftWidth + borderRightWidth + 8f; var widthPerColumn = float.IsNaN(specifiedCellWidth) ? minCellWidth / Math.Max(1, placement.ColumnSpan) : specifiedCellWidth / Math.Max(1, placement.ColumnSpan); @@ -1381,7 +1447,7 @@ private static void LayoutTable( if (contentWidth > 0f && text.Length > 0) { - var wrappedLines = WrapText(text, contentWidth, placement.CellTextStyle.FontSize, options.AverageCharacterWidthFactor, placement.CellTextStyle.LetterSpacing); + var wrappedLines = WrapText(text, contentWidth, placement.CellTextStyle.FontSize, context.AverageCharacterWidthFactor, placement.CellTextStyle.LetterSpacing); var lineHeight = placement.CellTextStyle.FontSize * placement.CellTextStyle.LineHeightMultiplier; contentHeight = wrappedLines.Count * lineHeight; } @@ -1424,7 +1490,7 @@ private static void LayoutTable( var contentHeight = 0f; if (contentWidth > 0f && placement.Text.Length > 0) { - var wrappedLines = WrapText(placement.Text, contentWidth, placement.CellTextStyle.FontSize, options.AverageCharacterWidthFactor, placement.CellTextStyle.LetterSpacing); + var wrappedLines = WrapText(placement.Text, contentWidth, placement.CellTextStyle.FontSize, context.AverageCharacterWidthFactor, placement.CellTextStyle.LetterSpacing); var lineHeight = placement.CellTextStyle.FontSize * placement.CellTextStyle.LineHeightMultiplier; contentHeight = wrappedLines.Count * lineHeight; } @@ -1457,7 +1523,7 @@ private static void LayoutTable( if (contentWidth > 0f && placement.Text.Length > 0) { - var wrappedLines = WrapText(placement.Text, contentWidth, placement.CellTextStyle.FontSize, options.AverageCharacterWidthFactor, placement.CellTextStyle.LetterSpacing); + var wrappedLines = WrapText(placement.Text, contentWidth, placement.CellTextStyle.FontSize, context.AverageCharacterWidthFactor, placement.CellTextStyle.LetterSpacing); var lineHeight = placement.CellTextStyle.FontSize * placement.CellTextStyle.LineHeightMultiplier; var lineX = cellX + placement.PaddingLeft + placement.BorderLeftWidth; var lineY = cellY + placement.PaddingTop + placement.BorderTopWidth + lineHeight; @@ -1527,7 +1593,7 @@ private static void LayoutGridContainer( ref float activeFloatBottom, ref bool textIndentConsumed, RenderTextStyle inheritedTextStyle, - HtmlRenderOptions options, + LayoutContext context, DisplayList displayList, float maxY, Dictionary styleMap, @@ -1568,7 +1634,7 @@ private static void LayoutGridContainer( { if (child is TextRenderNode textNode) { - LayoutTextNode(textNode.Ref, containingX, containingWidth, ref cursorY, ref previousBlockMarginBottom, ref suppressNextBlockTopMargin, ref activeFloatLeftOffset, ref activeFloatBottom, ref textIndentConsumed, inheritedTextStyle, options, displayList, maxY); + LayoutTextNode(textNode.Ref, containingX, containingWidth, ref cursorY, ref previousBlockMarginBottom, ref suppressNextBlockTopMargin, ref activeFloatLeftOffset, ref activeFloatBottom, ref textIndentConsumed, inheritedTextStyle, context, displayList, maxY); continue; } @@ -1639,7 +1705,7 @@ private static void LayoutGridContainer( activeFloatBottom: ref childActiveFloatBottom, textIndentConsumed: ref childTextIndentConsumed, textStyle: inheritedTextStyle, - options: options, + context: context, displayList: displayList, maxY: maxY, isFlexItem: false, @@ -1696,7 +1762,7 @@ private static void LayoutGridContainer( PaintOutline(displayList, styleMap, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight); cursorY = flowBorderBoxY + borderBoxHeight; - previousBlockMarginBottom = effectiveMarginBottom + options.ParagraphSpacing; + previousBlockMarginBottom = effectiveMarginBottom + context.ParagraphSpacing; suppressNextBlockTopMargin = false; } @@ -1876,7 +1942,7 @@ private static void LayoutTextNode( ref float activeFloatBottom, ref bool textIndentConsumed, RenderTextStyle textStyle, - HtmlRenderOptions options, + LayoutContext context, DisplayList displayList, float maxY) { @@ -1897,7 +1963,7 @@ private static void LayoutTextNode( } var localFloatLeftOffset = cursorY < activeFloatBottom ? activeFloatLeftOffset : 0f; - LayoutWrappedText(text, containingX + localFloatLeftOffset, containingWidth - localFloatLeftOffset, ref cursorY, textStyle, options, displayList, maxY, textIndentConsumed ? 0f : textStyle.TextIndent); + LayoutWrappedText(text, containingX + localFloatLeftOffset, containingWidth - localFloatLeftOffset, ref cursorY, textStyle, context, displayList, maxY, textIndentConsumed ? 0f : textStyle.TextIndent); textIndentConsumed = true; } @@ -1907,13 +1973,13 @@ private static void LayoutWrappedText( float maxWidth, ref float cursorY, RenderTextStyle textStyle, - HtmlRenderOptions options, + LayoutContext context, DisplayList displayList, float maxY, float firstLineIndent) { var lineHeight = textStyle.FontSize * textStyle.LineHeightMultiplier; - var lines = WrapText(text, maxWidth, textStyle.FontSize, options.AverageCharacterWidthFactor, textStyle.LetterSpacing); + var lines = WrapText(text, maxWidth, textStyle.FontSize, context.AverageCharacterWidthFactor, textStyle.LetterSpacing); for (var index = 0; index < lines.Count; index++) { @@ -1925,7 +1991,7 @@ private static void LayoutWrappedText( return; } - var lineWidth = EstimateTextWidth(line, textStyle.FontSize, options.AverageCharacterWidthFactor, textStyle.LetterSpacing); + var lineWidth = EstimateTextWidth(line, textStyle.FontSize, context.AverageCharacterWidthFactor, textStyle.LetterSpacing); var lineMaxWidth = index == 0 ? Math.Max(0f, maxWidth - firstLineIndent) : maxWidth; var lineX = x + (index == 0 ? firstLineIndent : 0f) + ResolveTextAlignmentOffset(textStyle.TextAlign, lineMaxWidth, lineWidth); var baselineY = cursorY + textStyle.VerticalAlignOffset; diff --git a/src/AngleSharp.Renderer/IDomHarness.cs b/src/AngleSharp.Renderer/IDomHarness.cs new file mode 100644 index 0000000..7b6a415 --- /dev/null +++ b/src/AngleSharp.Renderer/IDomHarness.cs @@ -0,0 +1,61 @@ +using AngleSharp.Css; +using AngleSharp.Dom; +using AngleSharp.Renderer.Rendering; + +namespace AngleSharp.Renderer; + +/// +/// Represents an interactive harness bound to a browsing context. +/// +public interface IDomHarness +{ + /// + /// Raised whenever interaction state changes require repainting. + /// + event EventHandler? PaintInvalidated; + + /// + /// Gets the browsing context associated with this harness. + /// + IBrowsingContext Context { get; } + + /// + /// Gets the render device associated with this harness. + /// + IRenderDevice RenderDevice { get; } + + /// + /// Gets the currently hovered element (derived from the mouse cursor position). + /// + IElement? HoveredElement { get; } + + /// + /// Gets or sets the current mouse cursor position in viewport coordinates. + /// + (double X, double Y) MousePosition { get; set; } + + /// + /// Gets the horizontal scroll offset for the given element. + /// + double GetScrollLeft(IElement element, double maxLeft); + + /// + /// Sets the horizontal scroll offset for the given element. + /// + void SetScrollLeft(IElement element, double value, double maxLeft); + + /// + /// Gets the vertical scroll offset for the given element. + /// + double GetScrollTop(IElement element, double maxTop); + + /// + /// Sets the vertical scroll offset for the given element. + /// + void SetScrollTop(IElement element, double value, double maxTop); + + /// + /// Renders the active document to PNG using the harness-bound render device. + /// + RenderedImage PaintToPng(); +} diff --git a/src/AngleSharp.Renderer/InteractiveHtmlRendererState.cs b/src/AngleSharp.Renderer/InteractiveHtmlRendererState.cs new file mode 100644 index 0000000..ac846bd --- /dev/null +++ b/src/AngleSharp.Renderer/InteractiveHtmlRendererState.cs @@ -0,0 +1,214 @@ +using AngleSharp.Css; +using AngleSharp.Dom; +using AngleSharp.Renderer.Rendering; +using System.Linq; +using System.Runtime.CompilerServices; + +namespace AngleSharp.Renderer; + +/// +/// Stores interactive renderer state (scroll positions, hover state, and render device) per browsing context. +/// +internal sealed class InteractiveHtmlRendererState : IDomHarness +{ + private readonly ConditionalWeakTable _elementStates = new(); + private readonly HtmlRenderer _renderer; + private IElement? _hoveredElement; + private (double X, double Y) _mousePosition; + + private sealed class ElementInteractionState + { + public double ScrollLeft { get; set; } + + public double ScrollTop { get; set; } + } + + public InteractiveHtmlRendererState(IBrowsingContext context, IRenderDevice renderDevice) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(renderDevice); + + Context = context; + RenderDevice = renderDevice; + _renderer = new HtmlRenderer(); + } + + public event EventHandler? PaintInvalidated; + + public IBrowsingContext Context { get; } + + /// + /// Gets the render device used for interactive measurements. + /// + public IRenderDevice RenderDevice { get; } + + /// + /// Gets the currently hovered element, if any. + /// + public IElement? HoveredElement => _hoveredElement; + + /// + /// Gets or sets the current mouse cursor position. + /// + public (double X, double Y) MousePosition + { + get => _mousePosition; + set + { + if (Math.Abs(_mousePosition.X - value.X) < double.Epsilon && + Math.Abs(_mousePosition.Y - value.Y) < double.Epsilon) + { + return; + } + + _mousePosition = value; + var hoverChanged = UpdateHoveredElementFromMousePosition(); + + if (!hoverChanged) + { + PaintInvalidated?.Invoke(this, EventArgs.Empty); + } + } + } + + /// + /// Gets the horizontal scroll position for an element, clamped to the supplied maximum. + /// + public double GetScrollLeft(IElement element, double maxLeft) + { + ArgumentNullException.ThrowIfNull(element); + + var state = _elementStates.GetValue(element, static _ => new ElementInteractionState()); + state.ScrollLeft = Clamp(state.ScrollLeft, 0d, maxLeft); + return state.ScrollLeft; + } + + /// + /// Sets the horizontal scroll position for an element with clamping. + /// + public void SetScrollLeft(IElement element, double value, double maxLeft) + { + ArgumentNullException.ThrowIfNull(element); + + var state = _elementStates.GetValue(element, static _ => new ElementInteractionState()); + var next = Clamp(value, 0d, maxLeft); + if (Math.Abs(state.ScrollLeft - next) < double.Epsilon) + { + return; + } + + state.ScrollLeft = next; + PaintInvalidated?.Invoke(this, EventArgs.Empty); + } + + /// + /// Gets the vertical scroll position for an element, clamped to the supplied maximum. + /// + public double GetScrollTop(IElement element, double maxTop) + { + ArgumentNullException.ThrowIfNull(element); + + var state = _elementStates.GetValue(element, static _ => new ElementInteractionState()); + state.ScrollTop = Clamp(state.ScrollTop, 0d, maxTop); + return state.ScrollTop; + } + + /// + /// Sets the vertical scroll position for an element with clamping. + /// + public void SetScrollTop(IElement element, double value, double maxTop) + { + ArgumentNullException.ThrowIfNull(element); + + var state = _elementStates.GetValue(element, static _ => new ElementInteractionState()); + var next = Clamp(value, 0d, maxTop); + if (Math.Abs(state.ScrollTop - next) < double.Epsilon) + { + return; + } + + state.ScrollTop = next; + PaintInvalidated?.Invoke(this, EventArgs.Empty); + } + + private bool UpdateHoveredElementFromMousePosition() + { + var targetDocument = Context.Active; + IElement? nextHovered = null; + + if (targetDocument is not null) + { + var metrics = HtmlRenderer.CaptureLayoutMetrics(targetDocument, RenderDevice); + nextHovered = FindTopMostElementAt(metrics, _mousePosition.X, _mousePosition.Y); + } + + if (ReferenceEquals(_hoveredElement, nextHovered)) + { + return false; + } + + _hoveredElement = nextHovered; + PaintInvalidated?.Invoke(this, EventArgs.Empty); + return true; + } + + private static IElement? FindTopMostElementAt(IReadOnlyDictionary metrics, double x, double y) + { + return metrics + .Where(pair => Contains(pair.Value, x, y)) + .OrderByDescending(pair => GetDepth(pair.Key)) + .ThenBy(pair => Math.Max(0f, pair.Value.BorderBoxWidth) * Math.Max(0f, pair.Value.BorderBoxHeight)) + .Select(pair => pair.Key) + .FirstOrDefault(); + } + + private static bool Contains(HtmlRenderer.ElementLayoutMetrics metrics, double x, double y) + { + var left = metrics.BorderBoxX; + var top = metrics.BorderBoxY; + var right = metrics.BorderBoxX + metrics.BorderBoxWidth; + var bottom = metrics.BorderBoxY + metrics.BorderBoxHeight; + + return x >= left && x <= right && y >= top && y <= bottom; + } + + private static int GetDepth(IElement element) + { + var depth = 0; + var current = element.ParentElement; + + while (current is not null) + { + depth++; + current = current.ParentElement; + } + + return depth; + } + + public RenderedImage PaintToPng() + { + var targetDocument = Context.Active; + if (targetDocument is null) + { + throw new InvalidOperationException("No active document is available for painting."); + } + + return _renderer.RenderToPng(targetDocument, RenderDevice); + } + + private static double Clamp(double value, double min, double max) + { + if (value < min) + { + return min; + } + + if (value > max) + { + return max; + } + + return value; + } +} diff --git a/src/AngleSharp.Renderer/InteractiveHtmlRendererStateExtensions.cs b/src/AngleSharp.Renderer/InteractiveHtmlRendererStateExtensions.cs new file mode 100644 index 0000000..89e2b8c --- /dev/null +++ b/src/AngleSharp.Renderer/InteractiveHtmlRendererStateExtensions.cs @@ -0,0 +1,34 @@ +namespace AngleSharp; + +using AngleSharp.Css; +using AngleSharp.Renderer; +using System.Linq; +using System.Runtime.CompilerServices; + +/// +/// Extension methods for retrieving interactive renderer state from a browsing context. +/// +public static class InteractiveHtmlRendererStateExtensions +{ + private static readonly ConditionalWeakTable s_harnesses = new(); + + /// + /// Gets the interactive DOM harness for the browsing context. + /// + public static IDomHarness GetDomHarness(this IBrowsingContext context) + { + ArgumentNullException.ThrowIfNull(context); + + return s_harnesses.GetValue(context, static browsingContext => + { + var renderDevice = browsingContext.GetServices().FirstOrDefault(); + + if (renderDevice is null) + { + throw new InvalidOperationException("No IRenderDevice service is registered in the browsing context. Register a render device service in IConfiguration before creating the context."); + } + + return new InteractiveHtmlRendererState(browsingContext, renderDevice); + }); + } +} diff --git a/src/AngleSharp.Renderer/Rendering/DisplayList.cs b/src/AngleSharp.Renderer/Rendering/DisplayList.cs index a550870..c605fc8 100644 --- a/src/AngleSharp.Renderer/Rendering/DisplayList.cs +++ b/src/AngleSharp.Renderer/Rendering/DisplayList.cs @@ -1,8 +1,7 @@ -using System.Collections.Generic; -using System.Collections.ObjectModel; - namespace AngleSharp.Renderer.Rendering; +using System.Collections.ObjectModel; + /// /// Represents an ordered sequence of draw commands. /// diff --git a/src/AngleSharp.Renderer/Rendering/IRenderBackend.cs b/src/AngleSharp.Renderer/Rendering/IRenderBackend.cs index f973d06..80b3e39 100644 --- a/src/AngleSharp.Renderer/Rendering/IRenderBackend.cs +++ b/src/AngleSharp.Renderer/Rendering/IRenderBackend.cs @@ -12,4 +12,4 @@ public interface IRenderBackend /// The target viewport. /// The resulting image bytes and metadata. RenderedImage RenderToPng(DisplayList displayList, RenderViewport viewport); -} \ No newline at end of file +} From 9a2c022fdb3ef6819d576e4c354b8b8c68e91233 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sun, 2 Aug 2026 17:32:05 +0200 Subject: [PATCH 08/24] Added caret position --- .../ElementCssomViewExtensionsTests.cs | 39 ++++ .../DocumentCssomViewExtensions.cs | 219 ++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 src/AngleSharp.Renderer/DocumentCssomViewExtensions.cs diff --git a/src/AngleSharp.Renderer.Tests/ElementCssomViewExtensionsTests.cs b/src/AngleSharp.Renderer.Tests/ElementCssomViewExtensionsTests.cs index 68081f8..ff36131 100644 --- a/src/AngleSharp.Renderer.Tests/ElementCssomViewExtensionsTests.cs +++ b/src/AngleSharp.Renderer.Tests/ElementCssomViewExtensionsTests.cs @@ -254,6 +254,45 @@ public async Task DomHarness_PaintsOnAssignedRenderDevice() Assert.Equal(130, image.Height); } + [Fact] + public async Task CaretPositionFromPoint_ReturnsCaretInTextNode() + { + var document = await ParseAsync(""" + + + +

hello

+ + + """); + + var caret = document.CaretPositionFromPoint(22d, 10d); + + Assert.NotNull(caret); + Assert.IsAssignableFrom(caret!.OffsetNode); + Assert.InRange(caret.Offset, 1, 3); + + var rect = caret.GetClientRect(); + Assert.True(rect.Height > 0d); + } + + [Fact] + public async Task CaretPositionFromPoint_ReturnsNull_OutsideRenderedContent() + { + var document = await ParseAsync(""" + + + +

hello

+ + + """); + + var caret = document.CaretPositionFromPoint(-100d, -100d); + + Assert.Null(caret); + } + private static async Task ParseAsync(string html) { var context = BrowsingContext.New(CreateConfiguration(240, 160)); diff --git a/src/AngleSharp.Renderer/DocumentCssomViewExtensions.cs b/src/AngleSharp.Renderer/DocumentCssomViewExtensions.cs new file mode 100644 index 0000000..ee68775 --- /dev/null +++ b/src/AngleSharp.Renderer/DocumentCssomViewExtensions.cs @@ -0,0 +1,219 @@ +namespace AngleSharp.Dom; + +using AngleSharp.Attributes; +using AngleSharp.Dom.Geometry; +using AngleSharp.Renderer; +using System.Linq; + +/// +/// Provides CSSOM View-style helpers for documents. +/// +public static class DocumentCssomViewExtensions +{ + /// + /// Returns a caret position for the given viewport coordinates. + /// + [DomName("caretPositionFromPoint")] + public static ICaretPosition? CaretPositionFromPoint(this IDocument document, double x, double y) + { + ArgumentNullException.ThrowIfNull(document); + + var harness = document.Context.GetDomHarness(); + var metricsMap = HtmlRenderer.CaptureLayoutMetrics(document, harness.RenderDevice); + var targetElement = FindTopMostElementAt(metricsMap, x, y); + + if (targetElement is null) + { + return null; + } + + if (!metricsMap.TryGetValue(targetElement, out var metrics)) + { + return null; + } + + var textNode = GetFirstTextNode(targetElement); + if (textNode is null) + { + var rect = new DomRect(metrics.BorderBoxX, metrics.BorderBoxY, 0d, metrics.BorderBoxHeight); + return new CaretPosition(targetElement, 0, rect); + } + + var style = targetElement.ComputeCurrentStyle(); + var fontSize = ParseLengthOrDefault(style?.GetPropertyValue("font-size"), (float)harness.RenderDevice.FontSize); + var lineHeightFactor = ParseLineHeightFactor(style?.GetPropertyValue("line-height"), 1.35f); + var caretHeight = Math.Max(1d, fontSize * lineHeightFactor); + var contentLeft = metrics.BorderBoxX + metrics.BorderLeft + metrics.PaddingLeft; + var contentTop = metrics.BorderBoxY + metrics.BorderTop + metrics.PaddingTop; + + var text = textNode.Data ?? string.Empty; + var averageCharWidth = Math.Max(1d, fontSize * 0.55d); + var maxX = contentLeft + (text.Length * averageCharWidth); + var clampedX = Math.Max(contentLeft, Math.Min(x, maxX)); + var offset = (int)Math.Round((clampedX - contentLeft) / averageCharWidth, MidpointRounding.AwayFromZero); + offset = Math.Clamp(offset, 0, text.Length); + + var caretX = contentLeft + (offset * averageCharWidth); + var rectAtCaret = new DomRect(caretX, contentTop, 0d, caretHeight); + + return new CaretPosition(textNode, offset, rectAtCaret); + } + + private static IText? GetFirstTextNode(IElement element) + { + foreach (var child in element.ChildNodes) + { + if (child is IText text && !string.IsNullOrEmpty(text.Data)) + { + return text; + } + + if (child is IElement childElement) + { + var nested = GetFirstTextNode(childElement); + if (nested is not null) + { + return nested; + } + } + } + + return null; + } + + private static IElement? FindTopMostElementAt(IReadOnlyDictionary metrics, double x, double y) + { + return metrics + .Where(pair => Contains(pair.Value, x, y)) + .OrderByDescending(pair => GetDepth(pair.Key)) + .ThenBy(pair => Math.Max(0f, pair.Value.BorderBoxWidth) * Math.Max(0f, pair.Value.BorderBoxHeight)) + .Select(pair => pair.Key) + .FirstOrDefault(); + } + + private static bool Contains(HtmlRenderer.ElementLayoutMetrics metrics, double x, double y) + { + var left = metrics.BorderBoxX; + var top = metrics.BorderBoxY; + var right = metrics.BorderBoxX + metrics.BorderBoxWidth; + var bottom = metrics.BorderBoxY + metrics.BorderBoxHeight; + + return x >= left && x <= right && y >= top && y <= bottom; + } + + private static int GetDepth(IElement element) + { + var depth = 0; + var current = element.ParentElement; + + while (current is not null) + { + depth++; + current = current.ParentElement; + } + + return depth; + } + + private static float ParseLengthOrDefault(string? value, float fallback) + { + if (string.IsNullOrWhiteSpace(value)) + { + return fallback; + } + + var normalized = value.Trim(); + + if (normalized.EndsWith("px", StringComparison.OrdinalIgnoreCase)) + { + normalized = normalized[..^2].Trim(); + } + + if (float.TryParse(normalized, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var parsed)) + { + return Math.Max(1f, parsed); + } + + return fallback; + } + + private static float ParseLineHeightFactor(string? value, float fallback) + { + if (string.IsNullOrWhiteSpace(value)) + { + return fallback; + } + + var normalized = value.Trim().ToLowerInvariant(); + + if (normalized == "normal") + { + return fallback; + } + + if (normalized.EndsWith("%", StringComparison.Ordinal) && + float.TryParse(normalized[..^1], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var percent)) + { + return Math.Max(0.1f, percent / 100f); + } + + if (normalized.EndsWith("px", StringComparison.Ordinal)) + { + normalized = normalized[..^2].Trim(); + } + + if (float.TryParse(normalized, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var parsed)) + { + return Math.Max(0.1f, parsed); + } + + return fallback; + } +} + +/// +/// Represents a caret position in the document. +/// +[DomName("CaretPosition")] +[DomExposed("Window")] +public interface ICaretPosition +{ + /// + /// Gets the node that contains the caret. + /// + [DomName("offsetNode")] + INode OffsetNode { get; } + + /// + /// Gets the UTF-16 code unit offset within . + /// + [DomName("offset")] + int Offset { get; } + + /// + /// Gets the caret client rectangle. + /// + [DomName("getClientRect")] + IDomRect GetClientRect(); +} + +internal sealed class CaretPosition : ICaretPosition +{ + private readonly IDomRect _clientRect; + + public CaretPosition(INode offsetNode, int offset, IDomRect clientRect) + { + ArgumentNullException.ThrowIfNull(offsetNode); + ArgumentNullException.ThrowIfNull(clientRect); + + OffsetNode = offsetNode; + Offset = Math.Max(0, offset); + _clientRect = clientRect; + } + + public INode OffsetNode { get; } + + public int Offset { get; } + + public IDomRect GetClientRect() => _clientRect; +} From c67ab96d4bade62fbd1aa2df361336de0ca33df7 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sun, 2 Aug 2026 17:52:42 +0200 Subject: [PATCH 09/24] Further enhancements for CSSOM view --- .../ElementCssomViewExtensionsTests.cs | 73 ++++++++ src/AngleSharp.Renderer/CaretPosition.cs | 24 +++ .../DocumentCssomViewExtensions.cs | 46 ----- .../ElementCssomViewExtensions.cs | 163 ++++++++++++++++++ src/AngleSharp.Renderer/ICaretPosition.cs | 30 ++++ src/AngleSharp.Renderer/ScrollBehavior.cs | 28 +++ .../ScrollIntoViewOptions.cs | 29 ++++ .../ScrollLogicalPosition.cs | 34 ++++ src/AngleSharp.Renderer/ScrollToOptions.cs | 29 ++++ 9 files changed, 410 insertions(+), 46 deletions(-) create mode 100644 src/AngleSharp.Renderer/CaretPosition.cs create mode 100644 src/AngleSharp.Renderer/ICaretPosition.cs create mode 100644 src/AngleSharp.Renderer/ScrollBehavior.cs create mode 100644 src/AngleSharp.Renderer/ScrollIntoViewOptions.cs create mode 100644 src/AngleSharp.Renderer/ScrollLogicalPosition.cs create mode 100644 src/AngleSharp.Renderer/ScrollToOptions.cs diff --git a/src/AngleSharp.Renderer.Tests/ElementCssomViewExtensionsTests.cs b/src/AngleSharp.Renderer.Tests/ElementCssomViewExtensionsTests.cs index ff36131..49bffa8 100644 --- a/src/AngleSharp.Renderer.Tests/ElementCssomViewExtensionsTests.cs +++ b/src/AngleSharp.Renderer.Tests/ElementCssomViewExtensionsTests.cs @@ -173,6 +173,79 @@ public async Task ScrollPositions_AreMutable_AndClamped() viewport.ScrollBy(maxLeft + 20, maxTop + 20); Assert.Equal(maxLeft, viewport.GetScrollLeft()); Assert.Equal(maxTop, viewport.GetScrollTop()); + + viewport.Scroll(new ScrollToOptions { Left = 5, Top = 7 }); + Assert.Equal(5d, viewport.GetScrollLeft()); + Assert.Equal(7d, viewport.GetScrollTop()); + + viewport.ScrollBy(new ScrollToOptions { Left = 3, Top = 4 }); + Assert.Equal(8d, viewport.GetScrollLeft()); + Assert.Equal(11d, viewport.GetScrollTop()); + + viewport.Scroll(2, 3); + Assert.Equal(2d, viewport.GetScrollLeft()); + Assert.Equal(3d, viewport.GetScrollTop()); + } + + [Fact] + public async Task ScrollIntoView_ScrollsScrollableAncestor() + { + var document = await ParseAsync(""" + + + +
+
+
+ + + """); + + var viewport = document.QuerySelector("#viewport"); + var target = document.QuerySelector("#target"); + + Assert.NotNull(viewport); + Assert.NotNull(target); + + target!.ScrollIntoView(); + + Assert.True(viewport!.GetScrollLeft() > 0d); + Assert.True(viewport.GetScrollTop() > 0d); + } + + [Fact] + public async Task ScrollIntoView_RespectsBooleanAndOptionsVariants() + { + var document = await ParseAsync(""" + + + +
+
+
+ + + """); + + var viewport = document.QuerySelector("#viewport"); + var target = document.QuerySelector("#target"); + + Assert.NotNull(viewport); + Assert.NotNull(target); + + target!.ScrollIntoView(false); + var bottomAlignedTop = viewport!.GetScrollTop(); + + viewport.ScrollTo(0, 0); + target.ScrollIntoView(new ScrollIntoViewOptions + { + Block = ScrollLogicalPosition.Center, + Inline = ScrollLogicalPosition.Center, + }); + + Assert.True(bottomAlignedTop >= viewport.GetScrollTop()); + Assert.True(viewport.GetScrollLeft() > 0d); + Assert.True(viewport.GetScrollTop() > 0d); } [Fact] diff --git a/src/AngleSharp.Renderer/CaretPosition.cs b/src/AngleSharp.Renderer/CaretPosition.cs new file mode 100644 index 0000000..24f0dc5 --- /dev/null +++ b/src/AngleSharp.Renderer/CaretPosition.cs @@ -0,0 +1,24 @@ +namespace AngleSharp.Dom; + +using AngleSharp.Dom.Geometry; + +internal sealed class CaretPosition : ICaretPosition +{ + private readonly IDomRect _clientRect; + + public CaretPosition(INode offsetNode, int offset, IDomRect clientRect) + { + ArgumentNullException.ThrowIfNull(offsetNode); + ArgumentNullException.ThrowIfNull(clientRect); + + OffsetNode = offsetNode; + Offset = Math.Max(0, offset); + _clientRect = clientRect; + } + + public INode OffsetNode { get; } + + public int Offset { get; } + + public IDomRect GetClientRect() => _clientRect; +} diff --git a/src/AngleSharp.Renderer/DocumentCssomViewExtensions.cs b/src/AngleSharp.Renderer/DocumentCssomViewExtensions.cs index ee68775..738dfd9 100644 --- a/src/AngleSharp.Renderer/DocumentCssomViewExtensions.cs +++ b/src/AngleSharp.Renderer/DocumentCssomViewExtensions.cs @@ -171,49 +171,3 @@ private static float ParseLineHeightFactor(string? value, float fallback) } } -/// -/// Represents a caret position in the document. -/// -[DomName("CaretPosition")] -[DomExposed("Window")] -public interface ICaretPosition -{ - /// - /// Gets the node that contains the caret. - /// - [DomName("offsetNode")] - INode OffsetNode { get; } - - /// - /// Gets the UTF-16 code unit offset within . - /// - [DomName("offset")] - int Offset { get; } - - /// - /// Gets the caret client rectangle. - /// - [DomName("getClientRect")] - IDomRect GetClientRect(); -} - -internal sealed class CaretPosition : ICaretPosition -{ - private readonly IDomRect _clientRect; - - public CaretPosition(INode offsetNode, int offset, IDomRect clientRect) - { - ArgumentNullException.ThrowIfNull(offsetNode); - ArgumentNullException.ThrowIfNull(clientRect); - - OffsetNode = offsetNode; - Offset = Math.Max(0, offset); - _clientRect = clientRect; - } - - public INode OffsetNode { get; } - - public int Offset { get; } - - public IDomRect GetClientRect() => _clientRect; -} diff --git a/src/AngleSharp.Renderer/ElementCssomViewExtensions.cs b/src/AngleSharp.Renderer/ElementCssomViewExtensions.cs index 9a828d8..8a8098e 100644 --- a/src/AngleSharp.Renderer/ElementCssomViewExtensions.cs +++ b/src/AngleSharp.Renderer/ElementCssomViewExtensions.cs @@ -178,6 +178,38 @@ public static void ScrollTo(this IElement element, double x, double y) SetScrollTop(element, y); } + /// + /// Sets scroll positions from options. + /// + [DomName("scrollTo")] + public static void ScrollTo(this IElement element, ScrollToOptions options) + { + ArgumentNullException.ThrowIfNull(element); + ArgumentNullException.ThrowIfNull(options); + + var targetX = options.Left ?? GetScrollLeft(element); + var targetY = options.Top ?? GetScrollTop(element); + ScrollTo(element, targetX, targetY); + } + + /// + /// Scroll alias for setting absolute coordinates. + /// + [DomName("scroll")] + public static void Scroll(this IElement element, double x, double y) + { + ScrollTo(element, x, y); + } + + /// + /// Scroll alias for setting absolute coordinates from options. + /// + [DomName("scroll")] + public static void Scroll(this IElement element, ScrollToOptions options) + { + ScrollTo(element, options); + } + /// /// Adjusts scroll positions by the supplied deltas. /// @@ -189,6 +221,53 @@ public static void ScrollBy(this IElement element, double x, double y) ScrollTo(element, GetScrollLeft(element) + x, GetScrollTop(element) + y); } + /// + /// Adjusts scroll positions by the supplied option deltas. + /// + [DomName("scrollBy")] + public static void ScrollBy(this IElement element, ScrollToOptions options) + { + ArgumentNullException.ThrowIfNull(element); + ArgumentNullException.ThrowIfNull(options); + + var deltaX = options.Left ?? 0d; + var deltaY = options.Top ?? 0d; + ScrollBy(element, deltaX, deltaY); + } + + /// + /// Scrolls ancestor containers to reveal the element. + /// + [DomName("scrollIntoView")] + public static void ScrollIntoView(this IElement element) + { + ScrollIntoView(element, true); + } + + /// + /// Scrolls ancestor containers to reveal the element using legacy align-to-top behavior. + /// + [DomName("scrollIntoView")] + public static void ScrollIntoView(this IElement element, bool alignToTop) + { + ArgumentNullException.ThrowIfNull(element); + + var block = alignToTop ? ScrollLogicalPosition.Start : ScrollLogicalPosition.End; + ScrollElementIntoView(element, block, ScrollLogicalPosition.Nearest); + } + + /// + /// Scrolls ancestor containers to reveal the element using options. + /// + [DomName("scrollIntoView")] + public static void ScrollIntoView(this IElement element, ScrollIntoViewOptions options) + { + ArgumentNullException.ThrowIfNull(element); + ArgumentNullException.ThrowIfNull(options); + + ScrollElementIntoView(element, options.Block, options.Inline); + } + /// /// Returns the border-box width. /// @@ -399,4 +478,88 @@ private static bool IsPositionStatic(IElement element) return string.Equals(position.Trim(), "static", StringComparison.OrdinalIgnoreCase); } + + private static void ScrollElementIntoView(IElement element, ScrollLogicalPosition block, ScrollLogicalPosition inline) + { + var metricsMap = GetMetricsMap(element); + if (metricsMap is null || !metricsMap.TryGetValue(element, out var elementMetrics)) + { + return; + } + + var ancestor = element.ParentElement; + + while (ancestor is not null) + { + if (!metricsMap.TryGetValue(ancestor, out var ancestorMetrics) || !IsScrollable(ancestor, ancestorMetrics)) + { + ancestor = ancestor.ParentElement; + continue; + } + + var clientWidth = Math.Max(0d, ancestorMetrics.BorderBoxWidth - ancestorMetrics.BorderLeft - ancestorMetrics.BorderRight); + var clientHeight = Math.Max(0d, ancestorMetrics.BorderBoxHeight - ancestorMetrics.BorderTop - ancestorMetrics.BorderBottom); + var paddingLeft = ancestorMetrics.BorderBoxX + ancestorMetrics.BorderLeft; + var paddingTop = ancestorMetrics.BorderBoxY + ancestorMetrics.BorderTop; + var elementLeft = elementMetrics.BorderBoxX - paddingLeft; + var elementTop = elementMetrics.BorderBoxY - paddingTop; + var elementRight = elementLeft + elementMetrics.BorderBoxWidth; + var elementBottom = elementTop + elementMetrics.BorderBoxHeight; + + var currentLeft = ancestor.GetScrollLeft(); + var currentTop = ancestor.GetScrollTop(); + var targetLeft = ResolveScrollOffset(currentLeft, elementLeft, elementRight, clientWidth, inline); + var targetTop = ResolveScrollOffset(currentTop, elementTop, elementBottom, clientHeight, block); + + ancestor.SetScrollLeft(targetLeft); + ancestor.SetScrollTop(targetTop); + ancestor = ancestor.ParentElement; + } + } + + private static double ResolveScrollOffset(double current, double start, double end, double viewportSize, ScrollLogicalPosition position) + { + if (viewportSize <= 0d) + { + return current; + } + + return position switch + { + ScrollLogicalPosition.Start => start, + ScrollLogicalPosition.End => end - viewportSize, + ScrollLogicalPosition.Center => start - ((viewportSize - (end - start)) / 2d), + _ => ResolveNearest(current, start, end, viewportSize), + }; + } + + private static double ResolveNearest(double current, double start, double end, double viewportSize) + { + var viewportEnd = current + viewportSize; + var size = end - start; + + if (size > viewportSize) + { + return start; + } + + if (start < current) + { + return start; + } + + if (end > viewportEnd) + { + return end - viewportSize; + } + + return current; + } + + private static bool IsScrollable(IElement element, HtmlRenderer.ElementLayoutMetrics metrics) + { + var clientWidth = Math.Max(0d, metrics.BorderBoxWidth - metrics.BorderLeft - metrics.BorderRight); + var clientHeight = Math.Max(0d, metrics.BorderBoxHeight - metrics.BorderTop - metrics.BorderBottom); + return element.GetScrollWidth() > clientWidth || element.GetScrollHeight() > clientHeight; + } } diff --git a/src/AngleSharp.Renderer/ICaretPosition.cs b/src/AngleSharp.Renderer/ICaretPosition.cs new file mode 100644 index 0000000..35a92c1 --- /dev/null +++ b/src/AngleSharp.Renderer/ICaretPosition.cs @@ -0,0 +1,30 @@ +namespace AngleSharp.Dom; + +using AngleSharp.Attributes; +using AngleSharp.Dom.Geometry; + +/// +/// Represents a caret position in the document. +/// +[DomName("CaretPosition")] +[DomExposed("Window")] +public interface ICaretPosition +{ + /// + /// Gets the node that contains the caret. + /// + [DomName("offsetNode")] + INode OffsetNode { get; } + + /// + /// Gets the UTF-16 code unit offset within . + /// + [DomName("offset")] + int Offset { get; } + + /// + /// Gets the caret client rectangle. + /// + [DomName("getClientRect")] + IDomRect GetClientRect(); +} diff --git a/src/AngleSharp.Renderer/ScrollBehavior.cs b/src/AngleSharp.Renderer/ScrollBehavior.cs new file mode 100644 index 0000000..d649466 --- /dev/null +++ b/src/AngleSharp.Renderer/ScrollBehavior.cs @@ -0,0 +1,28 @@ +namespace AngleSharp.Dom; + +using AngleSharp.Attributes; + +/// +/// Defines how a scroll operation should be animated. +/// +[DomName("ScrollBehavior")] +public enum ScrollBehavior +{ + /// + /// Uses automatic behavior. + /// + [DomName("auto")] + Auto, + + /// + /// Scrolls instantly. + /// + [DomName("instant")] + Instant, + + /// + /// Scrolls smoothly. + /// + [DomName("smooth")] + Smooth, +} diff --git a/src/AngleSharp.Renderer/ScrollIntoViewOptions.cs b/src/AngleSharp.Renderer/ScrollIntoViewOptions.cs new file mode 100644 index 0000000..bff5e42 --- /dev/null +++ b/src/AngleSharp.Renderer/ScrollIntoViewOptions.cs @@ -0,0 +1,29 @@ +namespace AngleSharp.Dom; + +using AngleSharp.Attributes; + +/// +/// Options for scrolling an element into view. +/// +[DomName("ScrollIntoViewOptions")] +[DomExposed("Window")] +public sealed class ScrollIntoViewOptions +{ + /// + /// Gets or sets the scroll behavior. + /// + [DomName("behavior")] + public ScrollBehavior Behavior { get; set; } = ScrollBehavior.Auto; + + /// + /// Gets or sets vertical alignment mode. + /// + [DomName("block")] + public ScrollLogicalPosition Block { get; set; } = ScrollLogicalPosition.Start; + + /// + /// Gets or sets horizontal alignment mode. + /// + [DomName("inline")] + public ScrollLogicalPosition Inline { get; set; } = ScrollLogicalPosition.Nearest; +} diff --git a/src/AngleSharp.Renderer/ScrollLogicalPosition.cs b/src/AngleSharp.Renderer/ScrollLogicalPosition.cs new file mode 100644 index 0000000..60743c7 --- /dev/null +++ b/src/AngleSharp.Renderer/ScrollLogicalPosition.cs @@ -0,0 +1,34 @@ +namespace AngleSharp.Dom; + +using AngleSharp.Attributes; + +/// +/// Defines logical alignment positions for scrolling. +/// +[DomName("ScrollLogicalPosition")] +public enum ScrollLogicalPosition +{ + /// + /// Aligns the start edge. + /// + [DomName("start")] + Start, + + /// + /// Centers the target. + /// + [DomName("center")] + Center, + + /// + /// Aligns the end edge. + /// + [DomName("end")] + End, + + /// + /// Uses the nearest edge that requires minimal movement. + /// + [DomName("nearest")] + Nearest, +} diff --git a/src/AngleSharp.Renderer/ScrollToOptions.cs b/src/AngleSharp.Renderer/ScrollToOptions.cs new file mode 100644 index 0000000..588cde4 --- /dev/null +++ b/src/AngleSharp.Renderer/ScrollToOptions.cs @@ -0,0 +1,29 @@ +namespace AngleSharp.Dom; + +using AngleSharp.Attributes; + +/// +/// Options for absolute element scrolling. +/// +[DomName("ScrollToOptions")] +[DomExposed("Window")] +public sealed class ScrollToOptions +{ + /// + /// Gets or sets the horizontal destination. + /// + [DomName("left")] + public double? Left { get; set; } + + /// + /// Gets or sets the vertical destination. + /// + [DomName("top")] + public double? Top { get; set; } + + /// + /// Gets or sets the scroll behavior. + /// + [DomName("behavior")] + public ScrollBehavior Behavior { get; set; } = ScrollBehavior.Auto; +} From 62ed273aad23ede8644ab29f25bbbc8ffdc46bdb Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sun, 2 Aug 2026 17:53:03 +0200 Subject: [PATCH 10/24] Changelog updated --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41f0cc2..8ed0e03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ Released on Monday, August 3 2026. +- Improved public API with `GetDomHarness` - Added support for gradients - Added rendering images - Added flexbox layout mode From dece00627fdbd75fe4ac15c2e46f013f124d7dc6 Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Sat, 8 Aug 2026 15:32:23 +0300 Subject: [PATCH 11/24] Move the build orchestrator to the Fallout stable channel (10.4.0) Fallout v10.4.0 is the first stable-channel release; the previously pinned 11.0.18 belongs to the edge channel. The stable CLI ships as Fallout.GlobalTool (the fallout command is unchanged), and Fallout.Common 10.4.0 resolves the patched System.Security.Cryptography.Xml 10.0.10 on its own, so the manual transitive pin is no longer needed (Fallout-build/Fallout#618). Co-Authored-By: Claude Fable 5 --- .config/dotnet-tools.json | 4 ++-- build/_build.csproj | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 6a8c8a1..a8483d3 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -2,8 +2,8 @@ "version": 1, "isRoot": true, "tools": { - "fallout.cli": { - "version": "11.0.18", + "fallout.globaltool": { + "version": "10.4.0", "commands": [ "fallout" ] diff --git a/build/_build.csproj b/build/_build.csproj index 647ac26..11c89b0 100644 --- a/build/_build.csproj +++ b/build/_build.csproj @@ -11,9 +11,7 @@ - - - + From 35d6b81c3b1bbc061dbc0fc26e457446cce8a523 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sat, 5 Sep 2026 23:24:58 +0200 Subject: [PATCH 12/24] Prepare next release --- CHANGELOG.md | 2 +- src/AngleSharp.Renderer.Docs/package.json | 2 +- src/AngleSharp.Renderer/AngleSharp.Renderer.csproj | 5 +++-- src/Directory.Build.props | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ed0e03..2325b0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # 0.3.0 -Released on Monday, August 3 2026. +Released on Monday, September 7 2026. - Improved public API with `GetDomHarness` - Added support for gradients diff --git a/src/AngleSharp.Renderer.Docs/package.json b/src/AngleSharp.Renderer.Docs/package.json index d7e6cee..8f45842 100644 --- a/src/AngleSharp.Renderer.Docs/package.json +++ b/src/AngleSharp.Renderer.Docs/package.json @@ -1,6 +1,6 @@ { "name": "@anglesharp/renderer", - "version": "0.2.0", + "version": "0.3.0", "preview": true, "description": "The doclet for the AngleSharp.Renderer documentation.", "keywords": [ diff --git a/src/AngleSharp.Renderer/AngleSharp.Renderer.csproj b/src/AngleSharp.Renderer/AngleSharp.Renderer.csproj index 09c8a44..1f67325 100644 --- a/src/AngleSharp.Renderer/AngleSharp.Renderer.csproj +++ b/src/AngleSharp.Renderer/AngleSharp.Renderer.csproj @@ -20,7 +20,8 @@ true true snupkg - 1.7.0 + 1.8.0 + 1.1.0 @@ -29,7 +30,7 @@ - + diff --git a/src/Directory.Build.props b/src/Directory.Build.props index b2aac2c..6d96d9b 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.2.0 + 0.3.0 enable latest true From 197695c65a6d178d6484e2cef3963b318d862f72 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sun, 6 Sep 2026 00:28:26 +0200 Subject: [PATCH 13/24] Updated defaults --- .github/workflows/ci.yml | 6 +++--- build/Build.cs | 25 ++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3f20f7..735a182 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,11 +98,11 @@ jobs: - name: Build run: | if ($env:GITHUB_REF -eq "refs/heads/main") { - .\build.ps1 -Target Publish -AngleSharpVersion 1.5.0 + .\build.ps1 -Target Publish -AngleSharpVersion 1.8.0 -AngleSharpCssVersion 1.1.0 } elseif ($env:GITHUB_REF -eq "refs/heads/devel") { - .\build.ps1 -Target PrePublish -AngleSharpVersion 1.5.0 + .\build.ps1 -Target PrePublish -AngleSharpVersion 1.8.0 -AngleSharpCssVersion 1.1.0 } else { - .\build.ps1 -AngleSharpVersion 1.5.0 + .\build.ps1 -AngleSharpVersion 1.8.0 -AngleSharpCssVersion 1.1.0 } - name: Upload visual assets diff --git a/build/Build.cs b/build/Build.cs index ed9d51c..736ae5a 100644 --- a/build/Build.cs +++ b/build/Build.cs @@ -26,9 +26,12 @@ class Build : FalloutBuild [Fallout.Common.Parameter("ReleaseNotesFilePath - To determine the SemanticVersion")] readonly AbsolutePath ReleaseNotesFilePath = RootDirectory / "CHANGELOG.md"; - [Fallout.Common.Parameter("AngleSharp package version override (e.g. 1.0.0 for compatibility checks)")] + [Fallout.Common.Parameter("AngleSharp package version override (e.g. 1.8.0 for compatibility checks)")] readonly string AngleSharpVersion; + [Fallout.Common.Parameter("AngleSharp.Css package version override (e.g. 1.1.0 for compatibility checks)")] + readonly string AngleSharpCssVersion; + [Solution] readonly Solution Solution; @@ -118,6 +121,11 @@ protected override void OnBuildInitialized() settings = settings.SetProperty("AngleSharpVersion", AngleSharpVersion); } + if (!String.IsNullOrEmpty(AngleSharpCssVersion)) + { + settings = settings.SetProperty("AngleSharpCssVersion", AngleSharpCssVersion); + } + return settings; }); }); @@ -140,6 +148,11 @@ protected override void OnBuildInitialized() settings = settings.SetProperty("AngleSharpVersion", AngleSharpVersion); } + if (!String.IsNullOrEmpty(AngleSharpCssVersion)) + { + settings = settings.SetProperty("AngleSharpCssVersion", AngleSharpCssVersion); + } + return settings; }); }); @@ -162,6 +175,11 @@ protected override void OnBuildInitialized() settings = settings.SetProperty("AngleSharpVersion", AngleSharpVersion); } + if (!String.IsNullOrEmpty(AngleSharpCssVersion)) + { + settings = settings.SetProperty("AngleSharpCssVersion", AngleSharpCssVersion); + } + return settings; }); }); @@ -188,6 +206,11 @@ protected override void OnBuildInitialized() settings = settings.SetProperty("AngleSharpVersion", AngleSharpVersion); } + if (!String.IsNullOrEmpty(AngleSharpCssVersion)) + { + settings = settings.SetProperty("AngleSharpCssVersion", AngleSharpCssVersion); + } + return settings; }); }); From bbe959bc819c5430fc134a25113f7084d4af81fb Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sun, 6 Sep 2026 00:31:44 +0200 Subject: [PATCH 14/24] Updated missing case --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 735a182..7c1c724 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,7 +71,7 @@ jobs: - name: Build env: ANGLESHARP_SNAPSHOT_STRICT: 1 - run: ./build.sh -AngleSharpVersion 1.5.0 + run: ./build.sh -AngleSharpVersion 1.8.0 -AngleSharpCssVersion 1.1.0 - name: Upload visual assets if: always() From 15170125ea57e472ea6cd9d81a2c15df02c9b851 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sun, 6 Sep 2026 00:49:39 +0200 Subject: [PATCH 15/24] Improved snapshot testing --- .github/workflows/ci.yml | 79 +++++----- .github/workflows/update-snapshots.yml | 137 ++++++++++++++++++ AGENTS.md | 33 ++++- .../AngleSharp.Renderer.Tests.csproj | 2 +- .../SnapshotBaselineCoverageTests.cs | 97 +++++++++++++ .../VisualSnapshotVerifier.cs | 65 ++++++--- 6 files changed, 354 insertions(+), 59 deletions(-) create mode 100644 .github/workflows/update-snapshots.yml create mode 100644 src/AngleSharp.Renderer.Tests/SnapshotBaselineCoverageTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c1c724..199c766 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,10 @@ env: NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} DOCS_PATH: ${{ secrets.DOCS_PATH }} DOCS_BRANCH: ${{ secrets.DOCS_BRANCH }} + # Snapshot baselines are per-platform and committed; a missing one is a gap, never a pass. + ANGLESHARP_SNAPSHOT_STRICT: 1 + ANGLESHARP_VERSION: 1.8.0 + ANGLESHARP_CSS_VERSION: 1.1.0 jobs: can_document: @@ -44,73 +48,74 @@ jobs: cd $DOCS_PATH npx pilet publish --fresh --url https://feed.piral.cloud/api/v1/pilet/anglesharp --api-key ${{ secrets.PIRAL_FEED_KEY }} - linux: - runs-on: ubuntu-22.04 + # Every platform that has committed snapshot baselines has to run them. Skia rasterizes + # glyphs through a different scaler per platform, so a baseline is only ever exercised by + # the platform it was recorded on. The runner images are pinned on purpose: bumping one + # changes the rasterization and requires regenerating the baselines via `update-snapshots`. + test: + name: Test (${{ matrix.name }}) + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + include: + - name: linux + os: ubuntu-22.04 + - name: windows + os: windows-2022 + - name: macos + os: macos-14 steps: - uses: actions/checkout@v5 - - name: Install deterministic fonts for snapshots - run: | - sudo apt-get update - sudo apt-get install -y fonts-dejavu-core fontconfig - fc-cache -f - fc-match "DejaVu Serif" - fc-match "DejaVu Sans" - fc-match "DejaVu Sans Mono" - fc-list | grep -q "DejaVu Serif" - fc-list | grep -q "DejaVu Sans" - fc-list | grep -q "DejaVu Sans Mono" - - name: Setup dotnet uses: actions/setup-dotnet@v5 with: - dotnet-version: | + dotnet-version: | + 8.0.x 10.0.x - - name: Build - env: - ANGLESHARP_SNAPSHOT_STRICT: 1 - run: ./build.sh -AngleSharpVersion 1.8.0 -AngleSharpCssVersion 1.1.0 + - name: Test + if: runner.os != 'Windows' + run: ./build.sh -Target RunUnitTests -AngleSharpVersion $ANGLESHARP_VERSION -AngleSharpCssVersion $ANGLESHARP_CSS_VERSION + + - name: Test + if: runner.os == 'Windows' + run: .\build.ps1 -Target RunUnitTests -AngleSharpVersion $env:ANGLESHARP_VERSION -AngleSharpCssVersion $env:ANGLESHARP_CSS_VERSION - name: Upload visual assets if: always() uses: actions/upload-artifact@v4 with: - name: linux-visual-assets + name: visual-assets-${{ matrix.name }} path: | src/AngleSharp.Renderer.Tests/verification-assets/** src/AngleSharp.Renderer.Tests/failure-assets/** if-no-files-found: ignore - windows: - runs-on: windows-latest + # Packaging and publishing stay on Windows, but only once every platform is green. + package: + needs: [test] + runs-on: windows-2022 steps: - uses: actions/checkout@v5 - + - name: Setup dotnet uses: actions/setup-dotnet@v5 with: - dotnet-version: | + dotnet-version: | + 8.0.x 10.0.x - name: Build run: | if ($env:GITHUB_REF -eq "refs/heads/main") { - .\build.ps1 -Target Publish -AngleSharpVersion 1.8.0 -AngleSharpCssVersion 1.1.0 + .\build.ps1 -Target Publish -AngleSharpVersion $env:ANGLESHARP_VERSION -AngleSharpCssVersion $env:ANGLESHARP_CSS_VERSION } elseif ($env:GITHUB_REF -eq "refs/heads/devel") { - .\build.ps1 -Target PrePublish -AngleSharpVersion 1.8.0 -AngleSharpCssVersion 1.1.0 + .\build.ps1 -Target PrePublish -AngleSharpVersion $env:ANGLESHARP_VERSION -AngleSharpCssVersion $env:ANGLESHARP_CSS_VERSION } else { - .\build.ps1 -AngleSharpVersion 1.8.0 -AngleSharpCssVersion 1.1.0 + .\build.ps1 -AngleSharpVersion $env:ANGLESHARP_VERSION -AngleSharpCssVersion $env:ANGLESHARP_CSS_VERSION } - - - name: Upload visual assets - if: always() - uses: actions/upload-artifact@v4 - with: - name: windows-visual-assets - path: | - src/AngleSharp.Renderer.Tests/verification-assets/** - src/AngleSharp.Renderer.Tests/failure-assets/** - if-no-files-found: ignore diff --git a/.github/workflows/update-snapshots.yml b/.github/workflows/update-snapshots.yml new file mode 100644 index 0000000..5bce9e1 --- /dev/null +++ b/.github/workflows/update-snapshots.yml @@ -0,0 +1,137 @@ +name: Update Snapshots + +# Visual baselines are platform specific, so a contributor can only ever regenerate the one +# for the machine they are sitting at. This workflow renders them on all supported platforms +# and pushes the complete set back, which is the only way to keep the matrix in sync. +on: + workflow_dispatch: + inputs: + commit: + description: "Commit the regenerated baselines back to the branch" + type: boolean + default: true + +permissions: + contents: write + +env: + ANGLESHARP_VERSION: 1.8.0 + ANGLESHARP_CSS_VERSION: 1.1.0 + +jobs: + # Keep this matrix identical to the `test` matrix in ci.yml, images included - a baseline + # recorded on one image is not valid for another. + render: + name: Render (${{ matrix.name }}) + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + include: + - name: linux + os: ubuntu-22.04 + - name: windows + os: windows-2022 + - name: macos + os: macos-14 + + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.ref }} + + - name: Setup dotnet + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Render baselines + env: + ANGLESHARP_SNAPSHOT_UPDATE: 1 + run: > + dotnet test src/AngleSharp.Renderer.Tests/AngleSharp.Renderer.Tests.csproj + --framework net8.0 + --filter "Category=Visual" + -p:AngleSharpVersion=${{ env.ANGLESHARP_VERSION }} + -p:AngleSharpCssVersion=${{ env.ANGLESHARP_CSS_VERSION }} + + - name: Upload baselines + uses: actions/upload-artifact@v4 + with: + name: baselines-${{ matrix.name }} + path: src/AngleSharp.Renderer.Tests/verification-assets/*.${{ matrix.name }}.png + if-no-files-found: error + + collect: + name: Collect and commit + needs: [render] + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.ref }} + + - name: Setup dotnet + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Download baselines + uses: actions/download-artifact@v4 + with: + pattern: baselines-* + merge-multiple: true + path: src/AngleSharp.Renderer.Tests/verification-assets + + - name: Verify the platform matrix is complete + run: > + dotnet test src/AngleSharp.Renderer.Tests/AngleSharp.Renderer.Tests.csproj + --framework net8.0 + --filter "Category=SnapshotCoverage" + -p:AngleSharpVersion=${{ env.ANGLESHARP_VERSION }} + -p:AngleSharpCssVersion=${{ env.ANGLESHARP_CSS_VERSION }} + + - name: Summarize changes + run: | + { + echo "### Regenerated baselines" + echo + if git diff --quiet --exit-code -- src/AngleSharp.Renderer.Tests/verification-assets; then + echo "No baseline changed." + else + echo '```' + git diff --stat -- src/AngleSharp.Renderer.Tests/verification-assets + echo '```' + fi + echo + echo "Untracked:" + echo '```' + git ls-files --others --exclude-standard -- src/AngleSharp.Renderer.Tests/verification-assets + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Commit baselines + if: inputs.commit + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -- src/AngleSharp.Renderer.Tests/verification-assets + if git diff --cached --quiet --exit-code; then + echo "Baselines are already up to date." + exit 0 + fi + git commit -m "Regenerate visual snapshot baselines for all platforms" + git push origin HEAD:${{ github.ref_name }} + + - name: Upload combined baselines + if: ${{ !inputs.commit }} + uses: actions/upload-artifact@v4 + with: + name: baselines-all + path: src/AngleSharp.Renderer.Tests/verification-assets/*.png diff --git a/AGENTS.md b/AGENTS.md index 428c9a0..2bc49fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ The repo also includes build scripts that drive the Fallout bootstrapper: ./build.cmd ``` -The main renderer project targets `net8.0` and `net10.0`. The test project targets `net8.0`. +The main renderer project targets `net8.0` and `net10.0`; the test project targets both as well, so both runtimes are exercised. ## Architecture @@ -60,15 +60,40 @@ Tests are split between structural assertions and visual conformance checks. - Structural tests live in `src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs` and verify the display list directly. - Visual tests live in `src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs` and compare PNG output against baselines in `verification-assets/`. - Failed visual comparisons write the actual image and a diff image into `failure-assets/`. -- Missing baselines are auto-created unless `ANGLESHARP_SNAPSHOT_STRICT=1` or `true` is set. -- CI runs visual tests on both Linux and Windows. To reduce drift, the renderer uses bundled deterministic font files for generic font-family resolution. +- Missing baselines are auto-created unless `ANGLESHARP_SNAPSHOT_STRICT=1` or `true` is set. CI always sets it. +- `ANGLESHARP_SNAPSHOT_UPDATE=1` overwrites the baselines for the current platform instead of comparing. When changing renderer behavior, update or add tests first, then run the focused test file or the full test project. +### The Platform Matrix + +Baselines are per-platform (`.linux.png`, `.windows.png`, `.macos.png`). +They cannot be shared: Skia rasterizes glyphs through FreeType on Linux, DirectWrite on Windows and +CoreText on macOS, so the very same bundled font file yields different anti-aliasing. The bundled +fonts remove *font selection* as a variable, not *font rasterization*. + +Because of that, a snapshot can only ever be checked by the platform it was recorded on, and the +usual failure mode is a baseline that silently rots on the platforms the author does not have: + +- `SnapshotBaselineCoverageTests` fails on *every* platform as soon as a snapshot is missing a + platform variant, so an incomplete matrix surfaces locally instead of on a foreign CI leg. +- `ci.yml` runs the whole test suite on Linux, Windows and macOS with strict mode on, and gates + packaging on all three. The runner images are pinned (`ubuntu-22.04`, `windows-2022`, `macos-14`); + bumping one is a rasterization change and requires regenerating the baselines. +- `update-snapshots.yml` (`workflow_dispatch`) re-renders the baselines on all three platforms, + verifies the matrix is complete, and commits them back. This is the only supported way to + regenerate baselines you cannot produce locally. Note that the bot push does not retrigger CI. + +The regular flow for a renderer change is: change the code, run the tests locally to refresh your +own platform's baselines, push, then dispatch **Update Snapshots** on the branch to fill in the +other two. + ## Repository Notes - The docs live under `docs/general/` and `docs/tutorials/`; they are the best place to document user-facing renderer behavior. - `AGENTS.md` should remain the primary agent note for this repo. - The repository already carries a snapshot-based workflow, so visual changes usually require updating the baseline PNGs together with the code change. -- The Linux Skia setup uses native assets from the main project package references. +- The Linux Skia setup uses `SkiaSharp.NativeAssets.Linux.NoDependencies`, which is built without + fontconfig. Installing system fonts on a Linux runner therefore has no effect on rendering; only + the bundled fonts and Skia's own fallback matter. - Keep an eye on `failure-assets/` after test runs; they are useful diagnostics, not source of truth. diff --git a/src/AngleSharp.Renderer.Tests/AngleSharp.Renderer.Tests.csproj b/src/AngleSharp.Renderer.Tests/AngleSharp.Renderer.Tests.csproj index 825e156..324a961 100644 --- a/src/AngleSharp.Renderer.Tests/AngleSharp.Renderer.Tests.csproj +++ b/src/AngleSharp.Renderer.Tests/AngleSharp.Renderer.Tests.csproj @@ -1,7 +1,7 @@ - net8.0 + net8.0;net10.0 enable enable diff --git a/src/AngleSharp.Renderer.Tests/SnapshotBaselineCoverageTests.cs b/src/AngleSharp.Renderer.Tests/SnapshotBaselineCoverageTests.cs new file mode 100644 index 0000000..ab07433 --- /dev/null +++ b/src/AngleSharp.Renderer.Tests/SnapshotBaselineCoverageTests.cs @@ -0,0 +1,97 @@ +namespace AngleSharp.Renderer.Tests; + +/// +/// Guards the snapshot baselines themselves. Visual tests can only compare against the +/// baseline of the platform they happen to run on, so a missing baseline for another +/// platform stays invisible until that platform's CI leg runs - or, when a whole platform +/// has no CI leg, forever. These tests make the gap fail on every platform instead. +/// +[Trait("Category", "SnapshotCoverage")] +public sealed class SnapshotBaselineCoverageTests +{ + [Fact] + public void Baselines_ExistForEverySupportedPlatform() + { + var missing = new List(); + + foreach (var (snapshot, platforms) in EnumerateBaselines()) + { + foreach (var platform in VisualSnapshotVerifier.SupportedPlatformSuffixes) + { + if (!platforms.Contains(platform)) + { + missing.Add($"{snapshot}.{platform}.png"); + } + } + } + + Assert.True(missing.Count == 0, + $"Missing baseline snapshots for {missing.Count} platform variant(s):{Environment.NewLine}" + + string.Join(Environment.NewLine, missing.Order(StringComparer.Ordinal).Select(name => $" - {name}")) + + $"{Environment.NewLine}Run the 'Update Snapshots' GitHub workflow to regenerate the baselines " + + "for every platform, then commit the updated verification-assets."); + } + + [Fact] + public void Baselines_UseAKnownPlatformSuffix() + { + var unknown = Directory + .EnumerateFiles(VisualSnapshotVerifier.VerificationAssetsPath, "*.png") + .Select(path => Path.GetFileName(path)) + .Where(name => !IsKnownBaselineName(name)) + .Order(StringComparer.Ordinal) + .ToList(); + + Assert.True(unknown.Count == 0, + $"Baseline snapshots without a known platform suffix ({string.Join(", ", VisualSnapshotVerifier.SupportedPlatformSuffixes)}):" + + $"{Environment.NewLine}" + string.Join(Environment.NewLine, unknown.Select(name => $" - {name}"))); + } + + private static bool IsKnownBaselineName(string fileName) => + TrySplit(fileName, out _, out var platform) && + VisualSnapshotVerifier.SupportedPlatformSuffixes.Contains(platform); + + private static IEnumerable<(string Snapshot, IReadOnlyCollection Platforms)> EnumerateBaselines() + { + var baselines = new Dictionary>(StringComparer.Ordinal); + + foreach (var path in Directory.EnumerateFiles(VisualSnapshotVerifier.VerificationAssetsPath, "*.png")) + { + var fileName = Path.GetFileName(path); + + if (!IsKnownBaselineName(fileName)) + { + continue; + } + + TrySplit(fileName, out var snapshot, out var platform); + + if (!baselines.TryGetValue(snapshot, out var platforms)) + { + platforms = new HashSet(StringComparer.Ordinal); + baselines[snapshot] = platforms; + } + + platforms.Add(platform); + } + + return baselines.Select(entry => (entry.Key, (IReadOnlyCollection)entry.Value)); + } + + private static bool TrySplit(string fileName, out string snapshot, out string platform) + { + var withoutExtension = Path.GetFileNameWithoutExtension(fileName); + var separator = withoutExtension.LastIndexOf('.'); + + if (separator <= 0 || separator == withoutExtension.Length - 1) + { + snapshot = withoutExtension; + platform = string.Empty; + return false; + } + + snapshot = withoutExtension[..separator]; + platform = withoutExtension[(separator + 1)..]; + return true; + } +} diff --git a/src/AngleSharp.Renderer.Tests/VisualSnapshotVerifier.cs b/src/AngleSharp.Renderer.Tests/VisualSnapshotVerifier.cs index cb2f85b..5129c42 100644 --- a/src/AngleSharp.Renderer.Tests/VisualSnapshotVerifier.cs +++ b/src/AngleSharp.Renderer.Tests/VisualSnapshotVerifier.cs @@ -7,6 +7,19 @@ namespace AngleSharp.Renderer.Tests; internal static class VisualSnapshotVerifier { private const string StrictModeEnvironmentVariable = "ANGLESHARP_SNAPSHOT_STRICT"; + private const string UpdateModeEnvironmentVariable = "ANGLESHARP_SNAPSHOT_UPDATE"; + + /// + /// The platforms a baseline has to exist for. Skia rasterizes glyphs through a different + /// scaler per platform (FreeType on Linux, DirectWrite on Windows, CoreText on macOS), so + /// the very same font file produces different anti-aliasing and the baselines cannot be shared. + /// + public static readonly string[] SupportedPlatformSuffixes = ["linux", "macos", "windows"]; + + /// + /// Gets the directory holding the committed baseline images. + /// + public static string VerificationAssetsPath => Path.Combine(GetProjectRoot(), "verification-assets"); public static void VerifyOrCreate( string snapshotName, @@ -26,15 +39,24 @@ public static void VerifyOrCreate( var failurePath = Path.Combine(failureAssetsPath, platformSnapshotName); var diffPath = Path.Combine(failureAssetsPath, Path.GetFileNameWithoutExtension(platformSnapshotName) + ".diff.png"); + if (IsEnabled(UpdateModeEnvironmentVariable)) + { + File.WriteAllBytes(baselinePath, actualPng); + DeleteIfExists(failurePath); + DeleteIfExists(diffPath); + return; + } + if (!File.Exists(baselinePath)) { - if (IsStrictModeEnabled()) + if (IsEnabled(StrictModeEnvironmentVariable)) { File.WriteAllBytes(failurePath, actualPng); throw new XunitException( $"Missing baseline snapshot '{platformSnapshotName}' while strict mode is enabled ({StrictModeEnvironmentVariable}=1). " + - $"Create baseline at: {baselinePath}. Actual output written to: {failurePath}."); + $"Create baseline at: {baselinePath}. Actual output written to: {failurePath}. " + + "Baselines for all platforms are produced by the 'Update Snapshots' GitHub workflow."); } File.WriteAllBytes(baselinePath, actualPng); @@ -46,16 +68,8 @@ public static void VerifyOrCreate( if (comparison.IsMatch(maxDifferentPixels)) { - if (File.Exists(failurePath)) - { - File.Delete(failurePath); - } - - if (File.Exists(diffPath)) - { - File.Delete(diffPath); - } - + DeleteIfExists(failurePath); + DeleteIfExists(diffPath); return; } @@ -66,7 +80,7 @@ public static void VerifyOrCreate( $"Visual snapshot mismatch for '{platformSnapshotName}'. " + $"Expected size {comparison.ExpectedWidth}x{comparison.ExpectedHeight}, " + $"actual size {comparison.ActualWidth}x{comparison.ActualHeight}, " + - $"different pixels: {comparison.DifferentPixels} (allowed: {maxDifferentPixels}), " + + $"different pixels: {comparison.DifferentPixels} ({comparison.DifferentPixelRatio:P2}, allowed: {maxDifferentPixels}), " + $"channel tolerance: {perChannelTolerance}. " + $"Baseline: {baselinePath}. Failure output: {failurePath}. Diff output: {diffPath}."); } @@ -106,12 +120,20 @@ private static string GetPlatformSuffix() return Environment.OSVersion.Platform.ToString().ToLowerInvariant(); } - private static bool IsStrictModeEnabled() + private static bool IsEnabled(string environmentVariable) { - var strictModeValue = Environment.GetEnvironmentVariable(StrictModeEnvironmentVariable); + var value = Environment.GetEnvironmentVariable(environmentVariable); + + return string.Equals(value, "1", StringComparison.OrdinalIgnoreCase) || + string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); + } - return string.Equals(strictModeValue, "1", StringComparison.OrdinalIgnoreCase) || - string.Equals(strictModeValue, "true", StringComparison.OrdinalIgnoreCase); + private static void DeleteIfExists(string path) + { + if (File.Exists(path)) + { + File.Delete(path); + } } private static string GetProjectRoot() @@ -228,5 +250,14 @@ private readonly record struct ImageComparison( byte[] DiffPng) { public bool IsMatch(int maxDifferentPixels) => DifferentPixels <= maxDifferentPixels; + + public double DifferentPixelRatio + { + get + { + var total = Math.Max(ExpectedWidth * ExpectedHeight, ActualWidth * ActualHeight); + return total > 0 ? (double)DifferentPixels / total : 0d; + } + } } } \ No newline at end of file From f206a7bb11cb2190c503f618fec184caf45c1850 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:52:24 +0000 Subject: [PATCH 16/24] Regenerate visual snapshot baselines for all platforms --- ...ed-wrapped-text-with-line-height.macos.png | Bin 3642 -> 3642 bytes ...-wrapped-text-with-line-height.windows.png | Bin 2476 -> 2479 bytes ...ed-text-sizes-styles-decorations.macos.png | Bin 3729 -> 3727 bytes ...-text-sizes-styles-decorations.windows.png | Bin 2289 -> 2292 bytes .../renders-canvas-path-and-text.macos.png | Bin 1556 -> 650 bytes .../renders-canvas-path-and-text.windows.png | Bin 0 -> 650 bytes ...nvas-rectangles-and-clear-rect.windows.png | Bin 0 -> 301 bytes ...s-canvas-translation-and-state.windows.png | Bin 0 -> 311 bytes .../renders-simple-table-layout.macos.png | Bin 1745 -> 1745 bytes .../renders-simple-table-layout.windows.png | Bin 0 -> 1218 bytes ...-table-cell-borders-and-widths.windows.png | Bin 0 -> 1394 bytes ...s-table-with-colspan-and-rowspan.macos.png | Bin 3920 -> 3920 bytes ...table-with-colspan-and-rowspan.windows.png | Bin 0 -> 2300 bytes .../text-indent-and-vertical-align.macos.png | Bin 5375 -> 5375 bytes ...text-indent-and-vertical-align.windows.png | Bin 3113 -> 3113 bytes .../web-safe-font-families.macos.png | Bin 9919 -> 9919 bytes .../web-safe-font-families.windows.png | Bin 6140 -> 6145 bytes 17 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-canvas-path-and-text.windows.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-canvas-rectangles-and-clear-rect.windows.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-canvas-translation-and-state.windows.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-simple-table-layout.windows.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-table-cell-borders-and-widths.windows.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-table-with-colspan-and-rowspan.windows.png diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/aligned-wrapped-text-with-line-height.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/aligned-wrapped-text-with-line-height.macos.png index 6a4e80ba4e3744fd31846cc4cc30e0bb28739582..271dac6b2a3289f309136d6da88a01ef532de254 100644 GIT binary patch delta 3190 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 R=rsTU002ovPDHLkV1jg`ickOm diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/aligned-wrapped-text-with-line-height.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/aligned-wrapped-text-with-line-height.windows.png index 6ca34518e7f3bab4e010190abc08e1af1a4e71c5..3445f8f9ae2426c1c2bf628e0c91eadc0eddbe42 100644 GIT binary patch delta 2061 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 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/mixed-text-sizes-styles-decorations.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/mixed-text-sizes-styles-decorations.macos.png index f254598d8762c2b49356736920c1a75d25f09a35..bef354c2337f559f32e94d53ff3fabef93e9d817 100644 GIT binary patch delta 3417 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 ZuNc!Gm^m2SzYGC~u9m)Lsk&A0{{j6G*}wn* delta 3416 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 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/mixed-text-sizes-styles-decorations.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/mixed-text-sizes-styles-decorations.windows.png index cbf7a90aac03e381ceae40ad6e08abfdc2edf551..24872822f59a4ccf03d42e68ee1b1bd5b5db4105 100644 GIT binary patch delta 2002 zcma)+`B&138pb6xr^#+QrjnR4G?SKPR}`&C3$IP9&`Af=v?;d{5f_rOONXyHV_M-t zT8Imdla}ZOa)CrsXL8rewN%8Nj8P0kBv2r?>-+=FyuZBXyw5rB`+3fJ&!e`~TIPCy zs%eNYyKe=Usr$FMcNP}%LOa0`CPsddWR4WORP-k5jYYF5j?$oP%hDE zG_@@)oaT&~nJAW6L321Ijc8Kh4jw*yo)!4${{3w|k?`7@nljz!7$@++qn=1ub84ah zO8{jW021MJfb*^k?%C3UP*+>V2Cves6D%gN`{;d9(j(|!*+fCHetfxDy<*?x#+2=j zpsJ4_9^lJvhXlCN-8l+=-1d_L-Q0`0LZQFHi4uduo!wwE7&PCqUo?4nc^}o%u6DRo zDuuzkk2+XVK4o|_79}r~qoct|VL@;PK%G}Ad*t$1asReey!Myyn#)5{vvgn7tV|Yw z&`!6RSz3;cjAWYp=>${ivs6BEi8WNKEb>ZtklEeadk(|gsTW_yAg?yv^gmZ|N#1$j z@q@Vd`0xIKK9)U5qJ7TO}L+J**aj-q#BBD9}rrKeMe6r|v*z}4&t{oLE9 z%(!kO61m6pp?|})YJPr>pin4itzDg+ktEXhp<@46`;1OQj*{g02M--O)%bq<_U*Jf zbETc5Bb~w6P56;aZjxRL4h}wByxZC4A>7OBxe|j$Kj;RU_Pgpz0iruZ9AcRaME8P3yP*OtTYg{?ZtOTEd% zsz-%(w&>r5ghlp(;u+RF#}7J!rQRI% zoWln2Lb47fTnWCy&YdA16=~8Txk|*#l@H*1fyh6};_K8_uWXk_(nf8vuU||Kak+Os z9G}Al^D!TzH%)RBb`qVHOO^^)x&Qchi_81Q`0$w<_NSZl(;O5V>yEPcW~^_9Xb0zo z9C|C{6OL~VJefQmg5dN;T00qzK!}#f;p8y(mWrUIdMgf}0~Ch1-GR5ROYiP2LEb`7 zHIv0E_#$L2Av$^=v29_2WAES~U)_9$ai1BjMustbTB^3Ux6dP(ZUY=8Bg;9CC;s>_ z_}pKWbE01%=H~oIGyXE+`%(%*^cLtgxtE z84(ddoc%9jh{yCMdy>My|IC_9kI!~YVbWt;y{2xydI&j@_oiF7#&>`uxKmc4Q|P8V zY%EChYA8CRtFpzo%n*3=k-E60W z-)xxpaR}Q+5Jx_4^qz2nvU&s#tgjmQ%qGUU3K*bMNid>lpHfU{{FLWpBdJ<>tnqeO z$*|QuR9$KdSr^}r@5qgrcTGOFelmP=ptH_Q6pL_svww=OOpdC)vBn?#aT;2(==_SH z`*!M?GNxH}o7QEDy4E{WJ32N7$K&y={@=UsF}yp6E#F=6m85bI zaJB6#8(QI%48`K>u+$NzauC> z9-I{Qjd69&j*{XPepYZy=xr$ifp-`F{-$2KlZx|B9>oScU-H=g(~DczAxmHtsGM!x zcKW3p3*ixBCo0=O|H?9seOBd=fjYGC-mG(0~Dm>L<$O;0y1$>W(hfgJKC%}_wIcagBS zz5VA47);$mqvUH)W6*CZx5OrMBZ}i+C)nrG?Qdy;e;JaWTT{7V%uOB3ZtofFFxsrM Mr_g>J-)m|A2i9BU?EnA( delta 1854 zcmV-E2f_IC5%Cd_Gy$lQHC-89a-VxA-o5gD7Sz%+l< z9}k52`FW{SDv7@V^q&SoCXjjw7?Pvylgi#iIT_KR++)>+9NkSYBS1dc7X$Q8Yfc+n&bq^0JglC7GC*kW3~c z8yg$?n62$fr6QS3CejNC=WEp#fS7+@_jWXeLcuiSIF8BXa*^?0vl?TJab4Fsf4N*X zj^iZSDwoTNzirEmKjt`&@jTBMV~po{k+wb0)0%R*oN-;ZWm^D}Mt+5YY;JB^Kk-9L zOH2Cq%*>1w3I!dXoSc-8A3rAM86O`P5$VbCi15=t?8$`xhHcNz&Wh{0lF5H$WN&Xz zR##WMUMBFb(SwS`Vo{GD6bc2IpPx_F(1#BnL_}m`VgefrdsVoRk`%eHUc zyeT3gD=RBKSsw7Ov7UCkcI}#!N~P3|RVo$P+}w;DoOB$g>moyr<3!pv#%M1@L}YPs zQC!!Rxw*NXEfe^cjMVFOSzdo$j!b;?=uuBLdBHW@bj)+1}pnI9OIzRuX3hip8SL z&CO~1larGo^51UC?c2BI-o1MwBC@)=Dtmi-8W^^=03>CMF{a-|0Qi49WJX6v|G7Z; z{Q0vSKYqO5WdMJj%;DkTKNkq2qoe&U0r)Gi%K$*}^=ANzKmec!1OSRa0H6p20E$2W zpa=v2ia-FM2m}C%Kmec!1OTdOAZ%}MM}8xL9~=ROU(-N%@!~~f{L-aMzrC1Y7ypU+ zXV0GXyQHQDsaEt(({Fz}Ubt{Uc6WDmyj(6ve!mdu)a!LQefo4{S`Y+s;>3wQRn*)d z)r#J-_7L{>_ak8-B7{Mg-oAYs8P8_3f9(b~NVTH3tbq_;A#%CgA1P*-y?XU3GJfUC zl|EH+v_YyBJ!ZRm%i7wS5fL3)-PI{B2m<4Io^f5*D`xX!n;C+ zgYWyMTCEz_b@j3M-JpUXFoiMv>*r)%MOAd5n#3S?yYw|cCb zS}{BlfnZxP+GX3ITMgA}H8Q_#Wo@-WmdoW-%L_Lk+iPz3(=iOGc5H2JwcTdpCwMRF zu?4d&9K)dKv{nq4#MT~PF>IgY&G^1=T-PggYuZi_1c~=#YHF(O_r&(PS_Mk4>S-rj zZC(E~wPN^We*MP-M>==zoV(@ut`R&^`dH(!)WZLxfv>ZEjtZ6&# ziob8o*j`s-4UKwT&%wcgym|8`F@0=oOwOD+le*TkXV0dt3j?OHyU@+?1JCnP?f1sp zj(2h6XA&CuzTeh!<88)|AH>^fcd#9YX!X9dJ8!S+vHgF4w%XlEQ!9p3W-goV!R$H63S}pZ)kNC9{ z-P9ScsoQ#NVUW#ct#!nYyQHlZ!%4TyG#94CH)>lCpmD{EpWBXK+tA6mZ`(BsjVo`w zre7#Gz40iPb*Z|x$vVE_OC07*qoM6N<$g58V7X#fBK diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-canvas-path-and-text.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-canvas-path-and-text.macos.png index c361fcea0b92d958c7f373d88cab0fce76d66ef6..fe392e38a5cd7ee9b803cc25f0d737b4f9042edb 100644 GIT binary patch delta 610 zcmbQj)5SW$xSq+;)5S5QV$Rz;xBa9Y1zay0o%;XZsPbSJtMFsV?@L!zuYK;a@saP@ ziHv#9g+1=m7@BKI~u06-{ z-TQ+-{{QFhUvgYL^ZJ`-kJ<#!uDd6(chSwj#pw%Tjl)**79XkKdeimr`9zn^&Bx^L zbf%Tc&TlO9wSQ`UM(}!?>)id*Rc2k~`SEOVMwrnL$&Bk;Ha=;yd~_?s{NeLNm*AUA zZZ*ShMJ+*FQ;-rGyI^`Mi^)8=#68Jiw)eG4#-+W>RP{)y& z+3ro1of+>~J{w(hxnUrF_MWMkkKyk)>4P!b(oLEwPuE?%VQ{wR4$z@Y-*%WaWoG@& zV7Ywf7Emn)(@qHDX&nqKCu&0sM;Vg(dEy!8f9)GaGs`k+ssxzs_QU}Gt1wbso* zhu@5jnVYRV!z9Z^c}B=5_vFJ%zUExNVen_d=UW_VTWch&$!;^L|*|%wJb=~)78&qol`;+08Qm3_y7O^ delta 1523 zcmV z-Mu?|=g&Da-_IhrXU@zVXFmJ;efJN75JCtcgb+dqA%qYjIDb0BPc)A7pgXGp}@~!BuyJA_=7u1({xJgfyy=I5q*Z~g{n;xDfzo3Nz*hc z`0`QGG>M9Ppnq~rF*Gv6^g`7pc53)NjHHQ~ntnhfX<{XS9HCs3pVnlUUZ~o{L;%8o zq=|<>gaJtt2Z3e=<(k2?I>Yor)h1K|6$T_tm;@{gNSg47C?6=-q$tA-fvQaiL?jGI zn)-=M7?3pe5_uMAYpzj-84*>Rs)<|}kTliO0AWDVRDVYU`+gg8jWSGusM?fC1BC%e zQyvW!1|&^6G_=QOX;T)-Hw zd#S@?mVf>Uk&xFm7shv<7@uG~;v`yu@tUhP=BiC3l?K9=Ce(G!h4G{(%d>svyyDGy zyy6uWK#1amww_@zesv<88!%_g3y#~WXV@ixXejA@J?G2X@4*y9+VVSJCV4C5fi zd5miqXEFA9^G>*V+j|^iyW4gw_Zr6jHTEAcCV$7xRm>?jcSo-4RT%LK(``4qbMCjw z-JtFb_$!lnCNlFcyYWkXGSV|{-bwfTWRG*6cH55RUW0LHW^`(iIa#}p7xG@V`4gis z#y#FeTc2~^<8HriF>Z2WcY2lRXlDLp?_6)XsUW2iAcGWSX^Ph2Ohhcn7h!fefq zubQbS>DbYk%8`zBE75`6Ye=e@-@3lW*nh{}ztu(( z?f2*>%!bVPiXOsj^lt7Vk~Y(QEc50KzlP+Pv-8vW<;D}A)^PJ;6b8Wf(_OlQL(coi zJ@3A|xkEu~e}Ag@ z2F5GQVei$vw9lLg?=`s|?k+c2ku%@Q!yVvu*e_7)w3rf5CVi<1NybcI^h~Dv>5;VE~K`-UZA)z9jp$+t{7CIfn5w z#{Et>!?@(l?PEN|vTRVT%3yz<*c?JOnHU zP64Or@K2w)i{7?JfeUn88!L-EYcLiAj{y$=e*6imWio zFk&Df|0hQxVCrv8LI@#*5JCtcgb+dqA%qY@2qAy0?#VY(*^833r#I!gZVNUqL!jrkc#)xcFp>1&9pJkiTL2|Av8nL9UU8h)+w# zGi@;tskM)_?5?TV8P|1ZGmbWQ3Y?ALW)W7LRVY^T?)`@BoEdE~v*(HNs5zPZ)sX_K z{}u5?^8eke-N)Kqp4pn2IvHr^y&56yL|3R;Ol%=FJ!-b^NAfm4M$>TyEj#KX1rthY;@7(hJpCm zd!}YShQH&a55{auH)*OoU3c+@!P%ZWKzA~I+hNv}ne{h=)*C=kx2$;SgFb=gQU`s4jisd3S~mmTeKTfmw(<;%Bx;TbZ z%z1m;o2$h@gyCSyjNkD$Ef4GnI1(ScRgA~1@xv8EpfZLByw9$2PdA)qEdD91__*<& z!_VKb7U(rTX6X~Q@OxkZW9)b@YF_zkOS*4rQ9Ao^P8s!rIR^@v5R6!(lU0j%*7kj^ ze_C_y$vHk$-Ed=|jCYf+x4PRY-T!#2PqYkX()OPdN*~KN^+mGZIEZXC#4?auQNe+y YbqagAa?-ML?t>&eUHx3vIVCg!0PtpR*Z=?k literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-canvas-translation-and-state.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-canvas-translation-and-state.windows.png new file mode 100644 index 0000000000000000000000000000000000000000..759a6e978f8d988bc62c76f0815d0a0d5d2545d6 GIT binary patch literal 311 zcmeAS@N?(olHy`uVBq!ia0vp^JwRN+!3HE14oevVDVAa<&kznEsNqQI0P^2?x;TbZ z%z1mqo2x;A!NKwMzw&L&TRt`lTs;%@=VMt@GEfZz!-u@%uMSuqyR-YlwU?((olbxD zuBM84Pj`X`i?fo1(7_2DZ7v2XO^Sj%oumnY4L`8H=K1+sKQA2Lf4u!&LF0B63Jiq0 fgb{2oIuTLAc;g0Zd6MqIgCHqSS3j3^P6zopr0OQFFj{pDw delta 34 ocmcb}dy#iS1d~YM#;9aA5rv-nXWY0CS}*{Cr>mdKI;Vst0L%;vCjbBd diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-simple-table-layout.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-simple-table-layout.windows.png new file mode 100644 index 0000000000000000000000000000000000000000..643de2ef035726db601926d9ef7d89460fa5bcb4 GIT binary patch literal 1218 zcmeAS@N?(olHy`uVBq!ia0vp^TY$KNgAGVVKfm!8NU;<Eakt zG3V{wjagci5^W#NyTuJ!k4SIen0Ad(QLK4t>jZC(V$MvJR^ML^0=t)p=&VWMVpUw? zy~;(Wh?!IDp5wNN5_9R}CpMisQa(?jFINz+cJ zUMpUr#?f>qCSQOhvVX!Z`%Q8TKkNlf)(8+nZ$!SFJ=@!)5U~IL{qHt2BV!*gTYofZ z;@65B7yTT!-_G^ClwfeD5|OzQfnTUmQ$WpwShgBc|$kG$}ApB-2D{sZq==v%Q*ocI^rhV0ryr=*L~5=6Pq+ zx*kXLx;;L9-&~5fbobpmGO3|jqF<|Q@7}wYX3JGJq2lz$s?XPR%*^fVX8M%W)$Nw%>kLVs-x?XPn-2Amdy5rT<_4{hRldA|+{yG(C@`jJol1-#Ed7MMFruNAW2|GDhiEW9AZ>bYzG{_|CRAy3~{eO{vA z>F@6!d4A%mJ+b-o3Qi^V1oJ&OKrnKFF}qB3b6$7&wzZd!KD;6IrNk;!faTTKs`zUX zJnhb=vwe53Fwgaq;%nz>a+p4SdSrjQtcu>OnP=0o%w|{B)cpDO@_p3WW!tx#@7=fW zUG0H2Yu9a!(OVt0_F3A_a|^v{TIKqWcP-K=oa(qS;>=n1-`z)(-c5Tgv+BqKc{#aj z*(D#p7tP)C=8JVmr{nR$H9;$dA)6u;1;KvUOppa|Q zh2_haSJl<|6;F%LwUN{2IsElg`5(Vd$Lp_4fw6aF^RuH#iRFBAQ#MAdxSO~7Y8Ei0 z>n2E)EDehM&+zpA{(@qwxvOII-q-H9pHR9sP^6HpmSNk!OACNrcv`elfup3b@Z(d% z*wdUX`Zs_=btv_*RAKD u-z=wC>OQ}+TAC;Oj`LwI$H9(H%@;bAuaXmZ_yw>~V(@hJb6Mw<&;$Tm<~88} literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-cell-borders-and-widths.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-cell-borders-and-widths.windows.png new file mode 100644 index 0000000000000000000000000000000000000000..075c9d779c16727136facd103486cbbe8f19bd17 GIT binary patch literal 1394 zcmeAS@N?(olHy`uVBq!ia0vp^cYwHpgAGW|ygsKANU;<Lu%mJGVa3KGJ<@gGprAqnDFYp8s#Y zH)oDY{=Fyni|;K_74)=Z44rhk_4U*13ZAEL7TCT}*y^HuCcZUl(ZnqNgYR}`@iTA; zH!=5c@EIss9O!t!piBfOBlw_i>ai(rUUR+s;Y|$>-?DqFC(zbc@)DC& z?(Qq}y*pQSSJCTzQMYehwKrI@eb+tB8)|F!a=g2y7CHiz z&e~ZKTK%oeGm87w(dlt|JL@;CcfX%@PUDuuX?y+K^Xnb|GCTi#uGaH8mYd1?(BFbz z*{VS={|IY&0ySk;q=YVH`x|8bb z-y{Ck+TMJ1a{RoYErx2IU1rBhsvf1^-C1e7VdLVWMpkoM!*${D_hYyGo!WQZYoEcP zXCH%i0OP3sNY(qp{qw5ss-MkOnyPne+xr6t|I9qRE8_CDMZw`A?hCSa9K14dk!j4I z3w55^%jYC+`xtxb;l#kB{BQLm9#7k3m%njY?*D3@#CpCg$AoMPPu6UnDc6C4!v6oy z+KcPkPOn>f&7U`1?(b_UsciuX?mJwx)@JYjA9k@c`RwgxDYw{{z3Qewhu=0|H}$Um zM6cKP=9hPG3$I!wckSmo)<)LvFCRRgHBaJtNZOi&-@k5Of7(*od-2w_bwFc`?d!tJ z_TQ@rx-9&dHF2Iz-Iga(dB%sV?r%-Fd@wiWOa7l8^;;5`i_Df=o39s-`FHcc@n4T- zzdwEN+q}K8z_45RCnn>sM)*5@KVUqTKD|~8^watCf%!I(A8Xqx_pR1d(mXyrFl61V z_-7N}yuK)%zc2PfppR{4r<>FFx0ZjlJIuXN5oI@3^75ZMYwo@G4#{*`;j-X;P}1u! z$0ob}k1Q;I8Tc>O=L&n%YT>oHZ)^DP8aD)cfwI$LHcu<_zrkCN2VS3HpA&YtZ|?3d z*Z115vhCY-^iOEs@pG|P<(gtu#JBM~HE1RAvP%fcB)Akb2p(i~V;QlWzw8=E;zL@d Sn>hjtQ3g*}KbLh*2~7ZpByo-a literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-with-colspan-and-rowspan.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-with-colspan-and-rowspan.macos.png index 2cac1c1968e4be0fa8effba96651725c73799bac..592bce95f0fd0e21b02621651ee5ba83f4f8b2a8 100644 GIT binary patch delta 3621 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 delta 3625 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 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-with-colspan-and-rowspan.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-with-colspan-and-rowspan.windows.png new file mode 100644 index 0000000000000000000000000000000000000000..a21b8ce0843695d7154febe3e58d18cbfe19921f GIT binary patch literal 2300 zcmcJRc{E#TAIDSO)Y?^5t0*R@WlUQgB2AZQ8@03uN-U#VrHI->Vl3^HYE@cwn6{}@ zTT5vusYF81#WE-*gpyc>2w5z#L}+5Jch391|IYizd(XMg_qpeu`<(N8exL96x#<_3 z&Z#PCDS<#BRm6F?3!rS^&QRO}jC*^UV*%L|bsFKO2wVw@fp&am?)wo|92S9IF1CUql{V@H1A07R)R1p;~dd%@g=S9@Jp2n-*abPd~B!K&nNi&{Et&TN!jmcLJi zrEKk>l?kRq%#Q&m<@+~%Xt6#IR2R#@YbiOm)0>}{mnV%qGtRGp9U(QPVu82NiXqw~ z@;-E5S2sJ`vz$RbJjnsYiT6~e&sAjWpNkKMTZEU`UzYUMuiSYVrd8R!L&TUoT9*(Gy2rpP` znVB{%j*N!3(kn(!g@rALw~AMCsjq(vy#8YNNCTOS_t3T&U_F|PHGES@G5_MMJnrOF zHu{p3=8~DZDw)oDAI%4waab_UuK|r9h`=3*Rtc@??zY&jt71sgTxhKll)0X?0M-U7 z<5Y!)e|zMZWnyyJ5JAT4Y^M0=I;7}~0#&TE^%H+u8Jc>7%Nyf7|9+!YJkITRQ zW*gD$oaYi6_aoPR^*fs&c&^t)QIkzm>k;2&kMS_E3Tu?5e@|7NKJ=xV6JyHN>X?a5 z`Em2X>U*vBKy5LC3Dv$TH{5$9LpB3uMahM>Ct_w?wPEolS8JaR*l_X)-Ev0FPQk%& zXx1F?rJX_Jl!hmSzFF)H;55@_8+T*xzgn*Ncjo~qhG)r_(}vTa_*?V%P8c(3ja(cg zu8-ao{~De(&3>YSJGeP0G12KJqBGn`6=!0kTdS6vPo!%1wgD55IBm6L<&=#oJAQ81 z{?Ftp(wdMy?pwWRLM#3(r60xWiB_Hnc`w%1bg2&bi210fdz6EbF$@w9><}sVD*D6N z&Re}e)yJH~lcCN#Y~*FfxFM0=;a2FL9@d*8s&g)^`ggG>E-3dbtXAveTYuxP4J0=S z1i&9)5aDv=45RgeK6Y$TXEIu{&0};7yJm)B%x!kez!#WGY!nPV%plNhQ}mr)PoM1+ znEM4?e^EcV6ji5W5Ru4F9E8+F<09>|)`xZAYrh9AE-cZU7_3#av>7iKl@dEjLuk-} zr?E1?ZK{oDcS3P(_ZlrJ3Vzr##;3f5+ofKPrO<5l zv^o)e?XDIC=<|T)K4_T8^xw3zx{4*!xIZ2q&AS`(f;&<&ETD#5_M%%qM&+ZBjIQ~f zJ)=rOtqwE?ZHcJ6@QpcGB*S57WnqlBRUzLxJzLaMkZP`y8v0U)xlBOx<8!FPUUOO@ zBxIY>fs_NIm2Xx_L(8YPyBcYxdE>7KB((QV@*B~;m41yB;yh9}rLAz9+Fy1oBt+oC z&tWW+yp~G)feis%JAQv{yblX3z^S`Z1dwjGxbiS6+j~kIoLHJ8Gij0$@XN*XDt8ty z@yxzBV!6G2D2osN+jL?w-x?4z)m0a61SB|^Z5>t#KHK8%k1^g+`-ybUMQ+!4JB^%P z^ShDlCUbmm4=bH1TfF0H|8Jr$!d(&sEYdHXuSOE)X7gKHTixUdcKxm&TqP$M&mh{r*!({h!`G8#6()!)APb%GEV6OQN~OH(Zd-h)@)JH*>pqLJ-NBlug57Zd4AaYI65x2t-yOaNXT6WRObXPc;S}Tetrx9B=2m;4>=f&wkxKu z0(sx-;XQqnT6~q7k9_2%hQ?SK{;-5UUl%E&J7I_|D_h#Unqui6wVjTMYtZ#vr9O#7 zb`i+3aCu;I1NUK{<6|IBi-3cmRPv>?vxYKli0E!#7$!gcSSze;D7Wg_!cHMt!C)}POg zqt$@K<8{3{hmHNT1u9{C03FQ3&Ps!@;&DVj)AQ1AeVafK)Ta%p{|(XgQ}qA)G@10F X1aw~WTHzA#I|D-4Il;?L```Ktm9&J# literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/text-indent-and-vertical-align.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/text-indent-and-vertical-align.macos.png index 1990c773afa0e060db26a09586167de9a93ae496..c1309a5d3d014ff0dcd8a11815f2b1724afee359 100644 GIT binary patch literal 5375 zcmcIoXE>W}-w%qqOV!@=R;4YqVzg!lRkf=|ZK_ctcI=V%rj*jsVNKY2Vj)2!T+Y z0mq-t(t-C?66-ziamMG4_M@}l6>`=w8UkUj($={BC?I`(Dmm*fQ!6cAw~t?` zG`w24kZIfXa9G#a$oRearRiOv)P8>bMB?8pTiZ1yR!L;l8RF8>KL1jKH%WD=!5}$> zM_3q%Y7)CBtjJ9JBKpQ(XIQ-0&kFy!azUfb=wftq?yUD^*~vD01Oiv%!w(fz24s zCOGbKIc&H)gLH5B{FlDC2Gvz^)k#CW^ReRwD`)nZ7+fey@v}?tG{WfQ9$8jQb<{(D zoAYcrtXKHv&6Fd`?ohsgP>7mPLPCOWmRd$kObjyA3}vGibzWd&KO}^lHZw03zOOSN z!z`fGhNPm2e*L;_iJXv}+=)ju3wlpE59O7*jmbh16BF<2>V6L1CHy_i@N_zf7SSyR_r+3D1h$pKA%EHGheru^8ut4xQloJL? z)%N3ujK{Rfg9i_maVQ~(b)B!*T;tKsgzwrY3sOrc^nGXN{f`ol@9Yw$#u@^lQ*{Wk zRkd-xe#jBtR!m(z>-dPmCMc*M5D;)5(a=YEFg(0t(QJ^V_F70iGdY=CR4=vd6`PpV zI+smAi!s>zqk_DX?(3FSh87ms(b6Z*lQlB6wY3s94axq~fl-HtAt7qE_sNM$c8ZvH z25Qd8g{7r>_}!@U@)%iJ*~zNJL{=IJdHG>Ds1FpQcCdV@8veUAlFmFy(t5J$^Jfv7 zVl;XHPQf)FKSf>^5s9$!pYKedGY7T6STu}mDiQ4p3)d^dG&Li?eEGs-2d@1wHPr

&nxoPuaP+ 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 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/text-indent-and-vertical-align.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/text-indent-and-vertical-align.windows.png index b79e191b2a40233c7e4a73af95c050e12a69595a..cd27229a394411b09e5c1fb0d16bca79b007b5b2 100644 GIT binary patch delta 2411 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 delta 2516 zcmchY`Ck&~7RIftT*jrW)Z8b`EVs%sG}jpyEM>BCC9Ek^+(;1-5h=@>JGqRw;F6Gv zGNecQVLnh|}v*$wE$> znUQz5Q=AL+4*%hHpv#7x5;v2VIf`>A^TW6hdZ`~`C2=j74l;6RCCrAvY;LY8FE_%t z$PHqGT|>Idt3@|tExd&{3tdYEzSYCBTl)F->)VMKOncfL5J<ZuO2^m z=5Y4SRcD{^F$ToO+h6~vg@ri?OmcBIy&Wu^R7wcP)DN6-xmK78@tOLzr4W$ z%q%Y2jckrYpJ{ADx^^{t-S{qn^Q^qRkHhhuH4KS`!%00UgNj+71=i+880u#c8Z?!u z;<7jtswEP=wfKGwi(9)zJB9bdSjNQHGhrS(6CScWIN!Gjb*>-PS=e@zx$e)C*nNvbZYNx9g`WH+uHBD zuEApbW&efOEnOJB#%c;bIVx=4dvG>O*EJi5cuukx9|rUp4jzE}MW2A`nfDojpY>aF z=&D@tk9-iH$IB=8j0I?EY62{m`&xf#2xd&;3>9SX}DP_gB82&+SnQx$x!+Wn5}aHMu%)`S}6xYL}9 zlmvgSxGw7UG3wpO&aP6~o5wKCrxf;zG23U0g#&N3QALGqQdEE z{4u$lZB)-*9Sw8#)+Gwejge&Qnh$V{WoV4;`3aGYO{s*{8P+*TfyI~_Q+%RIfmxL) za|r@H_#9dN?_XILr^IM9kpgFLcwsP@Pza;|(8uM*pipD=wzyl=_hrq+-=3ZdA=rlJ zY)#0Hu3*DsVq)?hKY^`aE|Wjb>gF2vN6BAF! zoPF-q0Ruvzut8*QYOXs}FpOY2Lg2p?PWAQqp@|N6ER3!I&Op0uSy6|Lts@5D(ARGS z|D`wz#BmIBFWVgwWFfo-S-+PLX*r6Fl2H{xL7T{M7_0G0%<2I~rO2MPcLvsuAc&<& zWsDc==*6dTg_A`$VS+-1-8MgNTCw9gSMkv98wkT>4Kr2_;t6l?b6Ot4+bN_7y#L|H z_!B?m-|@~0A69G8B0S0^FRG$?gx;lKhb~WtT5?bDmxXH8`wa1{#e!iQFE_V6j73|w z0KmQ#pVh{X$JifM?%JJn+cU8DYvb%{+qKwTcO>gW5lRo;l&-(9-KF$;Uw(qh0au*> zxO;$F!A{$wIoe9aZ`^{fUHj^^=cW3wliGNH{l+WPg@Zv_d+c0JkxuUrMbdy;Wvk| zTsiqWRCRE}v1iReeYa0HvUn0n@>DCtrklFM&O^uXT>l&&N3Rr6r_s?Zp^jTqI?PaO zne?p=cH=9dd0ESK>nU!2ff`DC2c%?X?w%PqQh47@O>}&FhSHqg5L|%p=KyK-^(PoMsm*%TVeMGyrr#qKxGFMsXDlPTQ4k0AYlZQ$>Tgc;qj zn06E}6AoupR0@!qQW2jZ{FGfc{J(JWsN|X0T*Cv5jE$2ISy}navR--9t{jKO#aSo5 z>yKu7ebQR2EbYftd8f+SMf*ux{22NSm@6;m3RYXm{B+rwMh^VM2|QU5p)*yv~lq6e9x+^BfG9A9Ziz8)o*Pz84KqPu>p%q-FUJIoIi>MZM+3p5{AH;8FF#2ZQh>r zRIwirbRp9t@>NqDxK}QhQzd&Lw-okh@%TKAD=^z)xDCRbYJ~-*w>CCYzp}ts!jpW=DrJjZP=|@?SnS6+p(!+yh0dBUpKB(!#jPBjtbU3~{#F sN|0k|gdKOyw3YOB{@;ti_j`-CcQL|h0nvBGA|=HEdHVlc?{PijKS!zs5dZ)H diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/web-safe-font-families.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/web-safe-font-families.macos.png index 75f12a3fe1dadc9a0da60ece109db6bc8485d4a9..32d6f10b3c246d6a183bb66b7136f96d2bde913f 100644 GIT binary patch literal 9919 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 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/web-safe-font-families.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/web-safe-font-families.windows.png index 4074e5ef4ed5eccfef79ff1fd938c5769cfff1a6..fc443a9fda4178582ecd48b202972ff233e0d535 100644 GIT binary patch literal 6145 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 aKAX-f4laaiOgWh81J|yYLu!p(AO9C}gY3Bg literal 6140 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 PeCWnCOB00Ay+8j8@n+_< From a081338d4589d460e928c00b4331a7cf5acb09ee Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sun, 6 Sep 2026 01:54:23 +0200 Subject: [PATCH 17/24] Improved rendering --- AGENTS.md | 17 +- .../FontFaceTests.cs | 206 +++++++++++ .../FontFallbackTests.cs | 137 ++++++++ .../HtmlRendererTests.cs | 36 +- .../TableSpanTests.cs | 284 ++++++++++++++++ .../TextMeasurementTests.cs | 174 ++++++++++ ...ed-wrapped-text-with-line-height.linux.png | Bin 3257 -> 3245 bytes .../renders-simple-table-layout.linux.png | Bin 1668 -> 1707 bytes ...rs-table-cell-borders-and-widths.linux.png | Bin 1842 -> 1846 bytes ...s-table-with-colspan-and-rowspan.linux.png | Bin 3410 -> 3601 bytes src/AngleSharp.Renderer/FontFaceLoader.cs | 319 ++++++++++++++++++ src/AngleSharp.Renderer/HtmlRenderer.cs | 287 ++++++++++------ .../Rendering/DisplayList.cs | 5 + src/AngleSharp.Renderer/Rendering/FontFace.cs | 76 +++++ .../Rendering/FontFaceSet.cs | 72 ++++ .../Rendering/ITextMeasurer.cs | 19 ++ .../Rendering/RenderFont.cs | 18 + .../Skia/SkiaRenderBackend.cs | 147 ++------ .../Skia/SkiaTextMeasurer.cs | 55 +++ .../Skia/SkiaTextShaping.cs | 287 ++++++++++++++++ 20 files changed, 1910 insertions(+), 229 deletions(-) create mode 100644 src/AngleSharp.Renderer.Tests/FontFaceTests.cs create mode 100644 src/AngleSharp.Renderer.Tests/FontFallbackTests.cs create mode 100644 src/AngleSharp.Renderer.Tests/TableSpanTests.cs create mode 100644 src/AngleSharp.Renderer.Tests/TextMeasurementTests.cs create mode 100644 src/AngleSharp.Renderer/FontFaceLoader.cs create mode 100644 src/AngleSharp.Renderer/Rendering/FontFace.cs create mode 100644 src/AngleSharp.Renderer/Rendering/FontFaceSet.cs create mode 100644 src/AngleSharp.Renderer/Rendering/ITextMeasurer.cs create mode 100644 src/AngleSharp.Renderer/Rendering/RenderFont.cs create mode 100644 src/AngleSharp.Renderer/Skia/SkiaTextMeasurer.cs create mode 100644 src/AngleSharp.Renderer/Skia/SkiaTextShaping.cs diff --git a/AGENTS.md b/AGENTS.md index 2bc49fd..fb946aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,10 +36,21 @@ The renderer is intentionally split into a small number of layers: - `HtmlRenderer` builds a display list from the AngleSharp render tree and computed styles. - `DisplayList` is the backend-agnostic command model. -- `SkiaRenderBackend` turns the display list into a PNG using SkiaSharp. +- `SkiaRenderBackend` turns the display list into a PNG using SkiaSharp, and implements `ITextMeasurer` so layout measures with the very typefaces it paints with. Both paths resolve fonts through `SkiaTextShaping`, which exists to keep them from drifting apart. +- Font resolution lives in `SkiaTextShaping.CreateTypeface` and walks the CSS family list in order: generic families map to the bundled fonts, named families resolve only when actually installed, and an exhausted list falls back to the bundled sans-serif. Do not resolve named families with `SKTypeface.FromFamilyName` - it substitutes the host's default for an unknown family instead of returning null, which swallows the rest of the fallback list and makes output depend on the machine. Availability goes through a case-insensitive index of the installed families, because Skia's own lookup is case sensitive on Linux but not on Windows. +- `ITextMeasurer` is the seam between layout and rasterization. Line breaking, text alignment and table column widths all go through it; a renderer built with a custom measurer lays out against that measurer. Never reintroduce a font-independent width heuristic here - it silently decouples layout from what is drawn. - `HtmlRenderOptions` holds viewport and text defaults. - `AngleSharp.Css` provides the render tree and computed-style data used by the renderer. +Font handling resolves each entry of a `font-family` list in order, and the first usable one wins: + +1. Generic families (`serif`, `sans-serif`, `monospace`, plus `cursive`, `fantasy`, `system-ui` and the `ui-*` aliases) always come from the fonts bundled in `Resources/Fonts`. They are keywords, so an `@font-face` rule cannot take them over, and they are what keeps snapshots reproducible. +2. `@font-face` declarations, collected per document by `FontFaceLoader` and carried on `DisplayList.Fonts`. Sources are tried in declaration order; `local()` resolves against the installed fonts and `url()` against `data:` URIs, or the network when - and only when - the browsing context has an `IDocumentLoader` configured, mirroring how images are handled. WOFF and WOFF2 are rejected up front because Skia cannot decode them. +3. Installed families, which depend on the host and are therefore not safe to assert in snapshots. +4. The bundled sans-serif, as the last resort. + +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. ## Code Conventions @@ -93,7 +104,5 @@ other two. - The docs live under `docs/general/` and `docs/tutorials/`; they are the best place to document user-facing renderer behavior. - `AGENTS.md` should remain the primary agent note for this repo. - The repository already carries a snapshot-based workflow, so visual changes usually require updating the baseline PNGs together with the code change. -- The Linux Skia setup uses `SkiaSharp.NativeAssets.Linux.NoDependencies`, which is built without - fontconfig. Installing system fonts on a Linux runner therefore has no effect on rendering; only - the bundled fonts and Skia's own fallback matter. +- The Linux Skia setup uses `SkiaSharp.NativeAssets.Linux.NoDependencies`. It is built without fontconfig, but it is *not* fontless: it scans `/usr/share/fonts/` directly, so whatever that directory holds is what `SKTypeface.FromFamilyName` can resolve. Generic families never reach that path (they come from the bundled fonts), but named families do, which makes them depend on the runner image. CI deliberately does not install extra fonts - determinism for named families has to come from the renderer's fallback, not from curating the runner. - Keep an eye on `failure-assets/` after test runs; they are useful diagnostics, not source of truth. diff --git a/src/AngleSharp.Renderer.Tests/FontFaceTests.cs b/src/AngleSharp.Renderer.Tests/FontFaceTests.cs new file mode 100644 index 0000000..00efac0 --- /dev/null +++ b/src/AngleSharp.Renderer.Tests/FontFaceTests.cs @@ -0,0 +1,206 @@ +namespace AngleSharp.Renderer.Tests; + +using AngleSharp; +using AngleSharp.Css; +using AngleSharp.Renderer.Rendering; +using AngleSharp.Renderer.Skia; + +using SkiaSharp; + +///

+/// Covers @font-face handling. The declared faces are built from the fonts bundled with the +/// renderer, so the expected result can be stated as "this has to measure like the bundled serif", +/// which holds on every platform. +/// +public sealed class FontFaceTests +{ + private const string Sample = "Handgloves quick brown fox"; + + private static readonly string SerifDataUri = BuildDataUri("DejaVuSerif.ttf"); + private static readonly string MonoDataUri = BuildDataUri("DejaVuSansMono.ttf"); + + [Fact] + public async Task EmbeddedFace_IsUsedForTheDeclaredFamily() + { + var width = await MeasureAsync( + $"@font-face {{ font-family: 'Embedded'; src: url({SerifDataUri}) format('truetype'); }}", + "Embedded"); + + Assert.Equal(await MeasureAsync(string.Empty, "serif"), width, tolerance: 0.01f); + Assert.True(Math.Abs(await MeasureAsync(string.Empty, "sans-serif") - width) > 0.01f, + "The embedded face measured like the default font, so it was not used."); + } + + [Fact] + public async Task EmbeddedFace_TakesPrecedenceOverTheFallbackEntry() + { + var width = await MeasureAsync( + $"@font-face {{ font-family: 'Embedded'; src: url({SerifDataUri}) format('truetype'); }}", + "'Embedded', monospace"); + + Assert.Equal(await MeasureAsync(string.Empty, "serif"), width, tolerance: 0.01f); + } + + [Fact] + public async Task UnsupportedFormat_FallsThroughToTheNextSource() + { + // Skia cannot decode the compressed wrappers, so a woff2 source has to be skipped rather + // than claim the family and leave it unrenderable. + var width = await MeasureAsync( + "@font-face { font-family: 'Embedded'; " + + $"src: url(data:font/woff2;base64,{Convert.ToBase64String("wOF2padding"u8.ToArray())}) format('woff2'), " + + $"url({SerifDataUri}) format('truetype'); }}", + "Embedded"); + + Assert.Equal(await MeasureAsync(string.Empty, "serif"), width, tolerance: 0.01f); + } + + [Fact] + public async Task UnsupportedFormat_WithoutAlternative_FallsBackToTheNextFamily() + { + var width = await MeasureAsync( + "@font-face { font-family: 'Embedded'; " + + $"src: url(data:font/woff2;base64,{Convert.ToBase64String("wOF2padding"u8.ToArray())}) format('woff2'); }}", + "'Embedded', monospace"); + + Assert.Equal(await MeasureAsync(string.Empty, "monospace"), width, tolerance: 0.01f); + } + + [Fact] + public async Task MissingLocalSource_FallsThroughToTheUrlSource() + { + // local() is only usable when the family is installed, which is why the choice cannot be + // made while loading the rule. + var width = await MeasureAsync( + $"@font-face {{ font-family: 'Embedded'; src: local('DefinitelyMissing'), url({SerifDataUri}) format('truetype'); }}", + "Embedded"); + + Assert.Equal(await MeasureAsync(string.Empty, "serif"), width, tolerance: 0.01f); + } + + [Fact] + public async Task NetworkUrl_IsIgnoredWithoutALoader() + { + // Nothing is fetched unless the browsing context was configured to load resources, so the + // family stays unresolved and the declared fallback takes over. + var width = await MeasureAsync( + "@font-face { font-family: 'Web'; src: url(https://example.com/font.ttf) format('truetype'); }", + "'Web', monospace"); + + Assert.Equal(await MeasureAsync(string.Empty, "monospace"), width, tolerance: 0.01f); + } + + [Fact] + public async Task Weight_SelectsTheMatchingFace() + { + // Two faces under one family, deliberately different files so the choice is observable. + var css = + $"@font-face {{ font-family: 'Duo'; font-weight: 400; src: url({SerifDataUri}) format('truetype'); }}" + + $"@font-face {{ font-family: 'Duo'; font-weight: 700; src: url({MonoDataUri}) format('truetype'); }}"; + + Assert.Equal(await MeasureAsync(string.Empty, "serif"), await MeasureAsync(css, "Duo", weight: 400), tolerance: 0.01f); + Assert.Equal(await MeasureAsync(string.Empty, "monospace"), await MeasureAsync(css, "Duo", weight: 700), tolerance: 0.01f); + } + + [Fact] + public async Task GenericFamilies_CannotBeOverridden() + { + // serif is a keyword, not a family name, so a face may not take it over. + var width = await MeasureAsync( + $"@font-face {{ font-family: 'serif'; src: url({MonoDataUri}) format('truetype'); }}", + "serif"); + + Assert.Equal(await MeasureAsync(string.Empty, "serif"), width, tolerance: 0.01f); + } + + [Fact] + public async Task DeclaredFace_IsExposedOnTheDisplayList() + { + var document = await ParseAsync( + $"@font-face {{ font-family: 'Embedded'; src: local('DefinitelyMissing'), url({SerifDataUri}) format('truetype'); }}", + "Embedded", + 400f); + + var displayList = new HtmlRenderer().BuildDisplayList(document, Device()); + + var face = Assert.Single(displayList.Fonts.Faces); + Assert.Equal("Embedded", face.Family); + Assert.Equal(400f, face.Weight); + Assert.False(face.IsItalic); + Assert.Collection(face.Sources, + source => Assert.Equal("DefinitelyMissing", source.LocalFamily), + source => Assert.NotNull(source.Data)); + } + + [Fact] + public async Task EmbeddedFace_RendersTheSamePixelsAsTheEquivalentGeneric() + { + var embedded = await RenderAsync( + $"@font-face {{ font-family: 'Embedded'; src: url({SerifDataUri}) format('truetype'); }}", + "Embedded"); + var serif = await RenderAsync(string.Empty, "serif"); + + Assert.Equal(serif, embedded); + } + + [Fact] + public void FontFaceSet_Empty_MatchesNothing() + { + Assert.True(FontFaceSet.Empty.IsEmpty); + Assert.False(FontFaceSet.Empty.TryMatch("anything", 400f, false, out _)); + } + + [Fact] + public void FontFaceSet_PrefersAMatchingSlantOverACloserWeight() + { + var upright = new FontFace("F", 400f, false, [FontFaceSource.FromLocal("A")]); + var italic = new FontFace("F", 900f, true, [FontFaceSource.FromLocal("B")]); + var set = new FontFaceSet([upright, italic]); + + Assert.True(set.TryMatch("F", 400f, isItalic: true, out var matched)); + Assert.Same(italic, matched); + + Assert.True(set.TryMatch("f", 400f, isItalic: false, out var uprightMatch)); + Assert.Same(upright, uprightMatch); + } + + private static DefaultRenderDevice Device() => + new() { ViewPortWidth = 480, ViewPortHeight = 140, FontSize = 20f }; + + private static async Task MeasureAsync(string css, string fontFamily, float weight = 400f) + { + var document = await ParseAsync(css, fontFamily, weight); + var displayList = new HtmlRenderer().BuildDisplayList(document, Device()); + var command = displayList.Commands.OfType().First(); + + return new SkiaTextMeasurer().MeasureWidth( + command.Text, + new RenderFont(command.FontFamily, command.FontSize, command.FontWeight, command.IsItalic, command.LetterSpacing, displayList.Fonts)); + } + + private static async Task RenderAsync(string css, string fontFamily) => + new HtmlRenderer().RenderToPng(await ParseAsync(css, fontFamily, 400f), Device()).Data; + + private static async Task ParseAsync(string css, string fontFamily, float weight) + { + var context = BrowsingContext.New(Configuration.Default.WithCss()); + + return await context.OpenAsync(request => request.Content($$""" + + +

{{Sample}}

+ + """)); + } + + private static string BuildDataUri(string fontFileName) + { + var assembly = typeof(HtmlRenderer).Assembly; + using var stream = assembly.GetManifestResourceStream($"AngleSharp.Renderer.Resources.Fonts.{fontFileName}") + ?? throw new InvalidOperationException($"Bundled font not found: {fontFileName}"); + using var buffer = new MemoryStream(); + stream.CopyTo(buffer); + + return $"data:font/ttf;base64,{Convert.ToBase64String(buffer.ToArray())}"; + } +} diff --git a/src/AngleSharp.Renderer.Tests/FontFallbackTests.cs b/src/AngleSharp.Renderer.Tests/FontFallbackTests.cs new file mode 100644 index 0000000..19b1642 --- /dev/null +++ b/src/AngleSharp.Renderer.Tests/FontFallbackTests.cs @@ -0,0 +1,137 @@ +namespace AngleSharp.Renderer.Tests; + +using AngleSharp; +using AngleSharp.Css; +using AngleSharp.Renderer.Rendering; +using AngleSharp.Renderer.Skia; + +using SkiaSharp; + +/// +/// CSS resolves a font-family list left to right and skips entries that are not available. These +/// assertions are expressed through measured advance widths, which is observable on every +/// platform, rather than through the typeface identity, which is not. +/// +public sealed class FontFallbackTests +{ + private const string Sample = "Handgloves quick brown fox"; + + private static readonly SkiaTextMeasurer Measurer = new(); + + [Theory] + [InlineData("DefinitelyMissing, serif")] + [InlineData("'DefinitelyMissing', serif")] + [InlineData("\"DefinitelyMissing\", serif")] + [InlineData("DefinitelyMissing, AlsoMissing, serif")] + public void MissingFamilies_FallThroughToTheNextEntry(string fontFamily) + { + // The unavailable leading entries must be skipped, leaving the declared generic in charge. + Assert.Equal(Width("serif"), Width(fontFamily), tolerance: 0.01f); + Assert.True(Math.Abs(Width("sans-serif") - Width(fontFamily)) > 0.01f, + "Expected the serif fallback to differ from sans-serif."); + } + + [Fact] + public void MissingFamily_OnItsOwn_FallsBackToTheDefault() + { + // Nothing in the list is available, so the renderer's own default takes over. It has to be + // that default rather than whatever the host happens to prefer. + Assert.Equal(Width("sans-serif"), Width("DefinitelyMissing"), tolerance: 0.01f); + } + + [Fact] + public void GenericFamilies_AreCaseInsensitive() + { + Assert.Equal(Width("serif"), Width("SERIF"), tolerance: 0.01f); + Assert.Equal(Width("sans-serif"), Width("Sans-Serif"), tolerance: 0.01f); + Assert.Equal(Width("monospace"), Width("MonoSpace"), tolerance: 0.01f); + } + + [Fact] + public void InstalledFamilies_ResolveRegardlessOfCase() + { + var family = FindDistinguishableInstalledFamily(); + + if (family is null) + { + // No installed family renders differently from the bundled default, so there is + // nothing here that could tell a successful match from a fallback. + return; + } + + Assert.Equal(Width(family), Width(family.ToUpperInvariant()), tolerance: 0.01f); + Assert.Equal(Width(family), Width(family.ToLowerInvariant()), tolerance: 0.01f); + } + + [Fact] + public void FallbackOrder_PrefersTheFirstAvailableEntry() + { + Assert.Equal(Width("monospace"), Width("monospace, serif"), tolerance: 0.01f); + Assert.Equal(Width("serif"), Width("serif, monospace"), tolerance: 0.01f); + } + + [Fact] + public async Task MissingFamily_RendersIdenticallyToItsFallback() + { + // Measuring and painting resolve fonts through the same path, so the pixels have to agree + // as well - this is what a stale first entry would break. + var withMissing = await RenderAsync("'DefinitelyMissing', serif"); + var serifOnly = await RenderAsync("serif"); + var sansOnly = await RenderAsync("sans-serif"); + + Assert.Equal(serifOnly, withMissing); + Assert.NotEqual(sansOnly, withMissing); + } + + private static string? FindDistinguishableInstalledFamily() + { + var defaultWidth = Width("sans-serif"); + + // Whatever an unknown name resolves to is useless for this test: if the candidate happens + // to be the host's own substitute font, a failed match is indistinguishable from a hit. + var substituteWidth = Width("DefinitelyMissing"); + + foreach (var family in SKFontManager.Default.FontFamilies) + { + if (string.IsNullOrWhiteSpace(family) || family.Contains(',', StringComparison.Ordinal)) + { + continue; + } + + // Mixed case only proves something when the family name actually has letters to case. + if (!family.Any(char.IsLetter)) + { + continue; + } + + var width = Width(family); + + if (Math.Abs(width - defaultWidth) < 1f || Math.Abs(width - substituteWidth) < 1f) + { + continue; + } + + return family; + } + + return null; + } + + private static float Width(string fontFamily) => + Measurer.MeasureWidth(Sample, new RenderFont(fontFamily, 20f, 400f, false, 0f)); + + private static async Task RenderAsync(string fontFamily) + { + var context = BrowsingContext.New(Configuration.Default.WithCss()); + var document = await context.OpenAsync(request => request.Content($$""" + + +

{{Sample}}

+ + """)); + + return new HtmlRenderer() + .RenderToPng(document, new DefaultRenderDevice { ViewPortWidth = 400, ViewPortHeight = 120, FontSize = 20f }) + .Data; + } +} diff --git a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs index b225bc7..3518f65 100644 --- a/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs +++ b/src/AngleSharp.Renderer.Tests/HtmlRendererTests.cs @@ -279,9 +279,23 @@ public async Task BuildDisplayList_AppliesColspanToCellGeometry() [Fact] public async Task BuildDisplayList_CollapsesAdjacentCellBordersWhenRequested() { - var document = await ParseAsync(""" + var collapsed = await CountCellBorderCommandsAsync("collapse"); + var separate = await CountCellBorderCommandsAsync("separate"); + + Assert.True(collapsed.Total < separate.Total, + $"Expected collapsing to draw fewer borders than separate ones, got {collapsed.Total} against {separate.Total}."); + + // The point of collapsing is that neighbours share an edge, so the rule between the two + // columns has to be painted exactly once. + Assert.Equal(1, collapsed.InteriorVerticalRules); + Assert.Equal(2, separate.InteriorVerticalRules); + } + + private static async Task<(int Total, int InteriorVerticalRules)> CountCellBorderCommandsAsync(string borderCollapse) + { + var document = await ParseAsync($$""" - +
AB
CD
@@ -296,11 +310,23 @@ public async Task BuildDisplayList_CollapsesAdjacentCellBordersWhenRequested() FontSize = 16f, }); - var borderCommands = displayList.Commands + var borders = displayList.Commands .OfType() - .Count(command => command.Color == RenderColor.Black); + .Where(command => command.Color == RenderColor.Black) + .ToArray(); + + // Vertical rules that sit strictly inside the table, counted on the first row only. + var tableLeft = borders.Min(command => command.Rect.X); + var tableRight = borders.Max(command => command.Rect.X + command.Rect.Width); + var firstRowY = borders.Min(command => command.Rect.Y); + + var interior = borders + .Where(command => command.Rect.Width <= 2f) + .Where(command => command.Rect.X > tableLeft + 0.5f && command.Rect.X + command.Rect.Width < tableRight - 0.5f) + .Where(command => command.Rect.Y <= firstRowY + 1f) + .Count(); - Assert.True(borderCommands < 10, $"Expected collapsed borders to reduce border commands, but found {borderCommands}."); + return (borders.Length, interior); } [Fact] diff --git a/src/AngleSharp.Renderer.Tests/TableSpanTests.cs b/src/AngleSharp.Renderer.Tests/TableSpanTests.cs new file mode 100644 index 0000000..6770737 --- /dev/null +++ b/src/AngleSharp.Renderer.Tests/TableSpanTests.cs @@ -0,0 +1,284 @@ +namespace AngleSharp.Renderer.Tests; + +using AngleSharp; +using AngleSharp.Css; +using AngleSharp.Renderer.Rendering; + +/// +/// Geometry of colspan and rowspan, asserted on the display list so the checks do +/// not depend on how any particular platform rasterizes the result. +/// +public sealed class TableSpanTests +{ + private const string TableCss = + "html, body { margin: 0; padding: 0; } table { border-collapse: collapse; width: 180px; } " + + "td { padding: 6px; border: 1px solid #222; background-color: #eef7ff; }"; + + [Fact] + public async Task ColspanCell_CoversEveryColumnItSpans() + { + var cells = await LayoutCellsAsync(""" + + + +
Header
LeftRight
+ """); + + var header = cells["Header"]; + var left = cells["Left"]; + var right = cells["Right"]; + + Assert.Equal(left.Width + right.Width, header.Width, tolerance: 0.5f); + Assert.Equal(left.X, header.X, tolerance: 0.5f); + Assert.Equal(right.X + right.Width, header.X + header.Width, tolerance: 0.5f); + } + + [Fact] + public async Task RowspanCell_CoversEveryRowItSpans() + { + var cells = await LayoutCellsAsync(""" + + + +
LeftRight
Bottom
+ """); + + var left = cells["Left"]; + var right = cells["Right"]; + var bottom = cells["Bottom"]; + + Assert.Equal(right.Height + bottom.Height, left.Height, tolerance: 0.5f); + Assert.Equal(right.Y, left.Y, tolerance: 0.5f); + Assert.Equal(bottom.Y + bottom.Height, left.Y + left.Height, tolerance: 0.5f); + } + + [Fact] + public async Task TallRowspanCell_IsSharedAcrossItsRowsRatherThanRepeated() + { + // The cell is taller than either row needs on its own. Its height has to be satisfied by + // the spanned rows together; giving each row the full height would double the table. + const string Long = "one two three four five six seven eight nine ten eleven twelve"; + + // The text wraps, so it arrives as several draw commands; the cell is identified by being + // the tallest background rather than by its text. + var reference = await TallestCellAsync($""" + + +
{Long}a
+ """); + + var spanned = await TallestCellAsync($""" + + + +
{Long}a
b
+ """); + + Assert.True(spanned > 40f, "The sample text was expected to wrap to several lines."); + Assert.True(spanned <= reference + 0.5f, + $"The spanning cell grew to {spanned:F1}px against a required {reference:F1}px, " + + "so its height was applied to every spanned row instead of being shared between them."); + } + + [Fact] + public async Task SpannedSlot_IsNotPaintedAsItsOwnCell() + { + var document = await ParseAsync(""" + + + + +
Header
LeftRight
Bottom
+ """); + + // Four declared cells, so exactly four cell backgrounds. A phantom cell in a covered slot + // would show up as a fifth. + Assert.Equal(4, CellBackgrounds(new HtmlRenderer().BuildDisplayList(document, Device())).Count()); + } + + [Fact] + public async Task SpannedSlot_HoldsNoSeparateCell() + { + var cells = await LayoutCellsAsync(""" + + + + +
Header
LeftRight
Bottom
+ """); + + Assert.Equal(["Bottom", "Header", "Left", "Right"], cells.Keys.Order(StringComparer.Ordinal)); + + // Nothing is painted in the slot the rowspan covers. + var left = cells["Left"]; + var bottom = cells["Bottom"]; + + Assert.True(bottom.X >= left.X + left.Width - 0.5f, + "The cell after a rowspan must start beyond the spanning cell, not inside it."); + } + + [Fact] + public async Task EmptyRow_UnderARowspan_DoesNotThrow() + { + // The occupancy carried into the next row used to index past the end of a shorter row. + var cells = await LayoutCellsAsync(""" + + + + +
TallSide
Last
+ """); + + Assert.Contains("Tall", cells.Keys); + Assert.Contains("Last", cells.Keys); + } + + [Fact] + public async Task CollapsedBorders_DoNotCrossASpanningCell() + { + var document = await ParseAsync(""" + + + +
Header
LeftRight
+ """); + + var displayList = new HtmlRenderer().BuildDisplayList(document, Device()); + var cells = CellRects(displayList); + var header = cells["Header"]; + + // A vertical rule inside the header would be a border painted across the span. + var crossing = displayList.Commands + .OfType() + .Where(command => IsBorderPaint(command)) + .Where(command => command.Rect.Width <= 2f) + .Where(command => command.Rect.X > header.X + 0.5f && command.Rect.X < header.X + header.Width - 1.5f) + .Where(command => command.Rect.Y < header.Y + header.Height - 0.5f && command.Rect.Y + command.Rect.Height > header.Y + 0.5f) + .ToArray(); + + Assert.True(crossing.Length == 0, + $"{crossing.Length} vertical border(s) painted across the spanning header cell."); + } + + private static bool IsBorderPaint(FillRectCommand command) => + command.Paint is RenderColorPaint { Color: { R: 0, G: 0, B: 0, A: 255 } }; + + private static DefaultRenderDevice Device() => + new() { ViewPortWidth = 220, ViewPortHeight = 200 }; + + private static async Task TallestCellAsync(string tableHtml) => + CellBackgrounds(new HtmlRenderer().BuildDisplayList(await ParseAsync(tableHtml), Device())) + .Max(rect => rect.Height); + + private static async Task> LayoutCellsAsync(string tableHtml) => + CellRects(new HtmlRenderer().BuildDisplayList(await ParseAsync(tableHtml), Device())); + + [Fact] + public async Task CellContent_IsCenteredByDefault() + { + // The UA stylesheet gives cells vertical-align: middle, so the default has to match an + // explicit middle rather than pin the text to the top. + Assert.Equal( + await TextBaselineAsync(AlignedTable("middle"), "Left"), + await TextBaselineAsync(AlignedTable(null), "Left"), + tolerance: 0.01f); + } + + [Fact] + public async Task VerticalAlign_PutsMiddleHalfwayBetweenTopAndBottom() + { + // Stated as a relation between the three keywords, so it holds whatever the line height + // works out to. The reported Y is a baseline, which is not the centre of the line box. + var top = await TextBaselineAsync(AlignedTable("top"), "Left"); + var middle = await TextBaselineAsync(AlignedTable("middle"), "Left"); + var bottom = await TextBaselineAsync(AlignedTable("bottom"), "Left"); + + Assert.True(top < middle && middle < bottom, + $"Expected top ({top:F1}) above middle ({middle:F1}) above bottom ({bottom:F1})."); + Assert.Equal(bottom - middle, middle - top, tolerance: 0.5f); + } + + [Fact] + public async Task VerticalAlign_Top_MatchesANonSpanningCell() + { + // Aligned to the top, a cell spanning two rows has to offset its text exactly like any + // other top-aligned cell: by its border and padding alone. + var cells = await LayoutCellsAsync(AlignedTable("top", alignEveryCell: true)); + var left = await TextBaselineAsync(AlignedTable("top", alignEveryCell: true), "Left"); + var right = await TextBaselineAsync(AlignedTable("top", alignEveryCell: true), "Right"); + + Assert.Equal(right - cells["Right"].Y, left - cells["Left"].Y, tolerance: 0.01f); + } + + [Fact] + public async Task VerticalAlign_Bottom_MatchesANonSpanningCell() + { + var cells = await LayoutCellsAsync(AlignedTable("bottom", alignEveryCell: true)); + var left = await TextBaselineAsync(AlignedTable("bottom", alignEveryCell: true), "Left"); + var right = await TextBaselineAsync(AlignedTable("bottom", alignEveryCell: true), "Right"); + + var leftCell = cells["Left"]; + var rightCell = cells["Right"]; + + Assert.Equal(rightCell.Y + rightCell.Height - right, leftCell.Y + leftCell.Height - left, tolerance: 0.01f); + } + + private static string AlignedTable(string? verticalAlign, bool alignEveryCell = false) + { + var style = verticalAlign is null ? string.Empty : $" style=\"vertical-align:{verticalAlign}\""; + var otherStyle = alignEveryCell ? style : string.Empty; + + return $""" + + Right + Bottom +
Left
+ """; + } + + private static async Task TextBaselineAsync(string tableHtml, string text) + { + var displayList = new HtmlRenderer().BuildDisplayList(await ParseAsync(tableHtml), Device()); + + return displayList.Commands.OfType().First(command => command.Text == text).Y; + } + + private static IEnumerable CellBackgrounds(DisplayList displayList) => + displayList.Commands + .OfType() + .Where(command => command.Paint is RenderColorPaint { Color: { R: 238, G: 247, B: 255, A: 255 } }) + .Select(command => command.Rect); + + private static Dictionary CellRects(DisplayList displayList) + { + var backgrounds = CellBackgrounds(displayList).ToList(); + var cells = new Dictionary(StringComparer.Ordinal); + + foreach (var text in displayList.Commands.OfType()) + { + // The page background contains every cell, so the enclosing rectangle of least area is + // the one that actually belongs to this text. + var match = backgrounds + .Where(rect => text.X >= rect.X && text.X <= rect.X + rect.Width && + text.Y >= rect.Y && text.Y <= rect.Y + rect.Height) + .OrderBy(rect => rect.Width * rect.Height) + .ToArray(); + + if (match.Length > 0) + { + cells[text.Text] = match[0]; + } + } + + return cells; + } + + private static async Task ParseAsync(string tableHtml) + { + var context = BrowsingContext.New(Configuration.Default.WithCss()); + + return await context.OpenAsync(request => request.Content($$""" + {{tableHtml}} + """)); + } +} diff --git a/src/AngleSharp.Renderer.Tests/TextMeasurementTests.cs b/src/AngleSharp.Renderer.Tests/TextMeasurementTests.cs new file mode 100644 index 0000000..984ef77 --- /dev/null +++ b/src/AngleSharp.Renderer.Tests/TextMeasurementTests.cs @@ -0,0 +1,174 @@ +namespace AngleSharp.Renderer.Tests; + +using AngleSharp; +using AngleSharp.Css; +using AngleSharp.Renderer.Rendering; +using AngleSharp.Renderer.Skia; + +/// +/// Layout has to break lines against the advance widths the backend will actually paint with. +/// These assertions are structural on purpose - they hold on every platform, unlike the +/// snapshots, which can only ever check the platform they were recorded on. +/// +public sealed class TextMeasurementTests +{ + private const string Sentence = "The quick brown fox jumps over the lazy dog and keeps running"; + + [Theory] + [InlineData("serif")] + [InlineData("sans-serif")] + [InlineData("monospace")] + public async Task WrappedLines_StayWithinTheContainer(string fontFamily) + { + var lines = await LayoutLinesAsync(fontFamily, containerWidth: 180, fontSize: 14f); + var measurer = new SkiaTextMeasurer(); + + Assert.NotEmpty(lines); + + foreach (var line in lines) + { + var width = measurer.MeasureWidth(line.Text, ToFont(line)); + + Assert.True(width <= 180f, + $"Line \"{line.Text}\" measures {width:F2}px in {fontFamily}, which overflows the 180px container."); + } + } + + [Fact] + public async Task WrappedLines_DependOnTheFontFamily() + { + // Monospace is markedly wider than the proportional families at the same size, so it has + // to break earlier. Identical break points would mean layout is ignoring the font. + var sans = await LayoutLinesAsync("sans-serif", containerWidth: 180, fontSize: 14f); + var mono = await LayoutLinesAsync("monospace", containerWidth: 180, fontSize: 14f); + + var sansTexts = sans.Select(line => line.Text).ToArray(); + var monoTexts = mono.Select(line => line.Text).ToArray(); + + Assert.NotEqual(sansTexts, monoTexts); + Assert.True(mono.Count >= sans.Count, + $"Expected monospace to need at least as many lines as sans-serif, got {mono.Count} vs {sans.Count}."); + + // The break points differ because the advance widths do. + var measurer = new SkiaTextMeasurer(); + var sansWidth = measurer.MeasureWidth(Sentence, new RenderFont("sans-serif", 14f, 400f, false, 0f)); + var monoWidth = measurer.MeasureWidth(Sentence, new RenderFont("monospace", 14f, 400f, false, 0f)); + + Assert.True(monoWidth > sansWidth, + $"Expected monospace to measure wider than sans-serif, got {monoWidth:F2} vs {sansWidth:F2}."); + } + + [Fact] + public async Task CenteredLines_AreOffsetByTheMeasuredWidth() + { + const float containerWidth = 180f; + var lines = await LayoutLinesAsync("serif", containerWidth, fontSize: 14f, textAlign: "center"); + var measurer = new SkiaTextMeasurer(); + + Assert.NotEmpty(lines); + + foreach (var line in lines) + { + var width = measurer.MeasureWidth(line.Text, ToFont(line)); + + Assert.Equal((containerWidth - width) / 2f, line.X, tolerance: 0.5f); + } + } + + [Fact] + public async Task Layout_UsesTheInjectedMeasurer() + { + // A deliberately wrong measurer must still drive the line breaking - that is the proof + // layout goes through the measurer rather than a built-in heuristic. + var measurer = new FixedWidthTextMeasurer(widthPerCharacter: 20f); + var document = await ParseAsync("sans-serif", containerWidth: 100, fontSize: 14f, textAlign: "left"); + + var renderer = new HtmlRenderer(new SkiaRenderBackend(), measurer); + var lines = renderer + .BuildDisplayList(document, new DefaultRenderDevice { ViewPortWidth = 400, ViewPortHeight = 600, FontSize = 14f }) + .Commands.OfType() + .ToArray(); + + Assert.True(measurer.CallCount > 0, "Layout never consulted the injected measurer."); + Assert.NotEmpty(lines); + + // At 20px per character not even the two shortest words fit together into 100px, so every + // line has to come out as a single word. A word wider than the container still overflows, + // because nothing here opts into breaking inside a word. + foreach (var line in lines) + { + Assert.DoesNotContain(' ', line.Text); + } + + // The same document measured with the real font puts several words on a line, so the + // difference can only come from the injected measurer. + var realLines = await LayoutLinesAsync("sans-serif", containerWidth: 100, fontSize: 14f); + + Assert.Contains(realLines, line => line.Text.Contains(' ')); + } + + [Fact] + public void Measurer_AccountsForLetterSpacing() + { + var measurer = new SkiaTextMeasurer(); + var plain = new RenderFont("sans-serif", 16f, 400f, false, 0f); + var spaced = plain with { LetterSpacing = 4f }; + + // "Handgloves" has ten characters, so nine gaps of four pixels each. + Assert.Equal(measurer.MeasureWidth("Handgloves", plain) + 36f, measurer.MeasureWidth("Handgloves", spaced), tolerance: 0.01f); + } + + [Fact] + public void Measurer_ReturnsZeroForEmptyText() + { + Assert.Equal(0f, new SkiaTextMeasurer().MeasureWidth(string.Empty, new RenderFont("serif", 16f, 400f, false, 0f))); + } + + private static RenderFont ToFont(DrawTextCommand command) => + new(command.FontFamily, command.FontSize, command.FontWeight, command.IsItalic, command.LetterSpacing); + + private static async Task> LayoutLinesAsync( + string fontFamily, + float containerWidth, + float fontSize, + string textAlign = "left") + { + var document = await ParseAsync(fontFamily, containerWidth, fontSize, textAlign); + + return new HtmlRenderer() + .BuildDisplayList(document, new DefaultRenderDevice { ViewPortWidth = 400, ViewPortHeight = 600, FontSize = fontSize }) + .Commands.OfType() + .ToArray(); + } + + private static async Task ParseAsync( + string fontFamily, + float containerWidth, + float fontSize, + string textAlign) + { + var context = BrowsingContext.New(Configuration.Default.WithCss()); + + return await context.OpenAsync(request => request.Content($$""" + + + +
+ {{Sentence}} +
+ + + """)); + } + + private sealed class FixedWidthTextMeasurer(float widthPerCharacter) : ITextMeasurer + { + public int CallCount { get; private set; } + + public float MeasureWidth(string text, RenderFont font) + { + CallCount++; + return text.Length * widthPerCharacter; + } + } +} diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/aligned-wrapped-text-with-line-height.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/aligned-wrapped-text-with-line-height.linux.png index 7fecebe6cc89578c6639afa6bdb2710ef3f96c2d..58322b1a01d5adddc76833553a4aaea181cf3f1d 100644 GIT binary patch delta 2749 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 D^m(Gv delta 2772 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 delta 1402 zcmV-=1%>*n4TKGlG=DouL_t(|obBCDNYj5D$MM(qrz7a}ZBo!-uv%Z7-;xxgKcp0m z4qF3j5p@xw^kQJqrCYZ;d5D4?iqs;A7Mca2qX|i52dP0)3GQnWl0>9~D638PyHr2> z{kFNo&+kXy-`4{?WC*WsJ^AAIJNH!skOPU8%Q@@6w;TY{=@zu=9k$VR-7&|j>$>U* zw);!<0HXgBlWzkTlV1iAf0Pt-G#Z7j>*(t0TH-nC#N%=3y8ipakyPb|BMT73| z??*TsMko|QeSJM1J$huvu4Tt*@BCP)iUyrdr_t5bg>X0wP1A7b&>`HqbqimcmM!~VIU9);NioEwv1EZ z*G1vX%nU5c!meGraQygjOioU!o<=1o81(q~IC^?|5DW%?e@@dhG&VM(v$GRZQ&YAK zR7KM?R8&-;wY3$qv$ME!=Z@-0RDyy*4-5GXd-PMW-k_7o zB!-8F5eNkGe$oE@`;kZ_@aD~%B^{?yE|6fR_07P`B;@$~6aSeAv#%1ZeCejGS(06TWOZUr$aN8XCf-OP64pCeEBWgY)OleP-re^)(+iYOR#Utb>rfdHSn`BPn89VR9w zfAIG0Th&vjh`d36{rVLnBO_S1Zk^qGmMyp24FH&#nNhriiYa0|3^nS)+If6;tpG9SjCBFfhPdRX@FW@d9427hbPd^%N?i zV9-a79D!w7=O`)D#{(cz~v+CLB9<3{6c-UyZAM2& z9do>+qob-P*iOqi?J}4|=EG=J?$L_t(|obBCfaFo>@$MN3+iDU^9BLyd>)F}xekVwI{R2mS1 z*i|m+M6lU%Nx+aWD1l+dDr%Wt)V3CSd>!6 zkMhZL2C_^0cj-1ZagY|yN!8A6n{@Hw#a3fq*}g@tn}>C1og#{%T8>@rl=2^Mzp0#gcPV+)U_G=XOJ&Eu&|?oT)sw${ z&}(d6ZLR+JyXA7-JWT1SlT^IBu;sqZFRTjM{SE{^3$FacG0mIdQdpQ%YqsoC;r@T> zhd;hWbEms>;@Ibsr1^{P)2na3FG-WMvZ_Gsm*4SM34bsB!-NR@w9dsJHO(Yo!g z`)XMMt)lFhZWtV^FsHN4ai4y4RI^i(HSzWo?R?`sE&9cBZQJ;qYHQAVjcsmj*2cAY znv&|)OT~xe&RC%ApHI{0pM4@pa%W@*?S2OWp9QyJO`e9tCn{_4{pvqpp!$S|YwDeD z-FiogUflG&uco;ooNIq*(7xAS)pO50rR>G`t4EKve+-*nSf#sWXDVgJOojLDtLvho zH9PZOz4r2-y~e(C@PLvg+@_egc!fEga!tEa$v>H*{l&Yx>gAvvd=gw!QM)y(X&n9d6?{hVM=DdG4ALH?Ov}bprelTWS ztKU!1IoYju4>y@ATxJD<9SKP3~%RZ*Qu1*zY$8~kT2(SAhul<(S zvDl%*z5eKhIs1R8viwBnUjRDIC+~2*!<>q^CQ?tW-qdnk@YUOgRp)&%%@w2X)%`Rp z^ByHln4~9vvrHE*T)6DmKH=fI{@dSC&DsBW-B*3)wAZn(J3WPe`Q)b)5)z_Rce+aU z7OAE)e&+P44qulA z<9-JUpZz(Ula-?*hu_h&D<4x;Ww{y~8};SMN)^AhQ(FsGxB1wp=s`O6ug~=9M@NI| z|C*0a7$J8?whHod)zs8<*>UcSY;9eaufi8MtFE?IRh1Rmur^P(r_AWkyurHPfuLQ% zUApN1>TQ2XrdyH}J?I8KzwIxQq{Vlq$@QanJ^IiRo%#1Exu#8T^LabYM?WoByyhBtAg}JBn5G%63goN>uJ2ex+1*hC)I@6&iX~yXOq% z{q{G9BoALk(Q*FzGLOdd=an{jtY+PPk5b*~=JS91LQJG1=-y$u!tMsnH8f~#-U?Ne z9am#xqpB*)mAB#%)z#Mg-;V|mxP0=l0J>I~)2U&JBlYy-zt-WuzooD+r^eiJtMWG$ zs{eq2{$3v1fm6y(;``w`j#EkiU5elUL%k$v%es7jucKRI|L{%q>K75AkB=Pk_d2?* z*tis->|Y1P#trrN63jU~!FJUO_z~BWaR#fCum)upq5lHy_wk%EFSLIE0000l4zdoAG=J$yL_t(|obBCfaFo>@$MN3+iR=;-BLyd>)F}xeA(4V@sWc!2 zv1_@c6TxQ7B>_Xipag~)tEgpqQQN9uCr<1TKqcH;L?og#Hh>VNR*Dw!o+^+F5lW`y z#t=fbub^hOM3Zd(yJ5ednSJx@**#}Z^36UEJHt98c?ATB*C88Pzkai5rC%mJuy3~(%udtjgeaws z8|jzl3}zSi@8WGt(jYCEou(aIH|oNL3$4b!ylu1Gw{itj4S2w?0yOMz$WfcaFAiS!cd~j!@a} zU}PTL7bmK8`qW9q4ZBG_d-k-RJ}5X{E{&bw)~tC8RalVM>b}Wo>DqPRg8;1zUCpn- zRaG3Ti4TTZ7-t2ggd(Y}A` z`#-!@bEdg<{OD(rqZ z`D6ZX6sdr_0smxKBPjqFJdans7&|cD(+s7XEyhwr+SaWX@O4&!+0LPd}C)ZW)#(X-DyrJP0gt4EKve+-+RU#YuhWhr&~3`O+qtLvkp zH7o00z53Fhea60hV84>b->#UrcsX4zxu@Q#lpjsjzLH&C^>WY-ehIF*xmi25ZqS&N z+kLLfzH{>^z4`iI{V`3qmz)@_m`I0`qWdW8r<1hocX^sNV{V&&kMVlF+P$kt-y1!) z)$#rV25RgCx6k-~*G6h+;_z0-hQueTx~ih{F94m^%O6j2wy{Act50ZYZkCqjX8GJ7 z9v<$GX}Z1ABNe(n>IT)-p3(L#>r{~UxJD(9Q(QvQWgpW}U$4sYW4ij9em?g_UiS^3 zW3fYr`TWsyy85Vps^WO(UjRDIFYj=@oi6peHd0Tm+IZ=@;LEoUs?Ygjnkz=%tFO__ ztb3F^exjcI^-`TbfBv##`$Rhu(W<(HpMSXh|SJQ*t8 zQ>>PjmUch>s@}cT&`|%?1Fs+sheNZobG4^d6<#XUs_|5n=S_~}z8JA7Rh ziu)Zb{PyQ?c6P1~A9`DVT=AHyt18sg)TA#?RH@|E9oka3s?EnnMGw-^e|@S?K0Fdq z|JQtc;&6E~b5vN6r{?D7%Z~G8=4i{>0u{ZmN%eJgs;;Wk`Zf8wBXxR*<_*>T4hHQC z?&3xNmv4)IvpkZd=s`E?xvhVZBrUo-UG5*m>(Pf6>-4`*$~|>jo6kG^=23d^k>z?| z;atUD*CV7p7xK)Rul>7=+x&;6A@PYS++L#Mm$zwBa+30X|4XHLG8GmUuJG`y+C66| z?{}a%BzgHVijE7^mw7auJE!zXV>I*bdz9wMFrVLlA7UaMA@>f;6?Qjpwy{xb@|UZ! z{Fs`Wnp9m?q5S2KsJ^cD|9&)p;N_Q(1<U|h0s+J|lQ9PFlg(mYp<3Lb7L%NkVBN6e3IZ zG02v6tdZ?|y58&ioC4jA{@2&rp*KYNuDWIbS;&qTw15{aMUoWYe43WNRo;wCT^s~T|xKP>gH{snAJP> ztWV$R+@DqE0*i~QyrA}~sdXHNsr;IBoDKHb>{>srq9+I-t6AzAgHF`y z83{9;^Aw}}cJt5T>1&9nTQ?iQNGvB~ilNB=fESEloYD?6w<3$*JwN|l9zGsjQR0+S zXBf%9O521lJgzrGmSa-R!pb)q#u*m@1xly#vMwFoQkw7dQHpW(b;Q^^HMvHJB*Fa;s5P<_Py z3crnvV*-w`NkscnyFZheH9L_>Zp(u4)KQ2iv8zUMj=2Zb_`tc@%TE~sQ7NXM~@ZIfKj_%FDF4adYJ`oU9&6rGi#!7SeAUpW!(IFSM1PR@+NMEXvBtAVO`eqT6k4MC@6?fTdd8q9-ux z)chJ9#m%P2M28|$--TTQ#MV9nt>>s~u7XVSgca?tk z)RdL$=MU<6uAaAp5=$fF4v{EToIWNv%t6RhP{-Z337PJ`NqWp-(f8JGKe*!N4tjRp zt)Q%oHK?J%P%DHPh%@NrnMdJH=K?QW5Cf8^v#aZ_%xbPW3<`>Vpyy)XqPe>|8QfX+@cuzFPQWPNuV=U=VW_N3>&;#`J(0ZywEv?ofj1PN$w;cwFA| z)^qC0AYw<5=#Ob|cjiHaX{c$Boa)oSQ>C%&Gn9J!jk$$fXZ#(=SREE?4frZf_hNqR z)V4aniLYS_><(jk;^)8y{{eFhXrv~9RCLfM_L43ncp9on4I1vZ&*Lpq2BDe{?VGE7EQ>IcWlYaQD*IOyo7oh{bSu1Sr`M5^AAD)JHTriFFaco zstUzBxj)o7yK-&l)&n+>D<^AHP2RTnId+GkM;_pm}IzoL4yZ zCG8yQ!|$khSk=jW>=sl+Il*W+7Isvt2>(`&6aGB6iSME`2XGNX3|y~F*c{GPv-~A% z;bIp8HGXXDUo+gCkzibeK%h<^me9LSuZN{>V=Wbw$?R17rYP;u15qVS&P$5?E-F>6 zrHQuPLH7p@q0`+J`T3_h`j6wUEf4=G0DWcGkFfi1qE)+}@}!Voc)TK!TJ0!M4((rH z=+)6`tKFG>5@c>JDh%LKM%T2;erS@Q3SCEPaptn`%Xrn6%y<*5LFIyDl|w2}B9=pw zZ&m7#rB&C2v>8{f?w&nM+uZp6H`OAKkT`z1(%Te_dYDD@cB}p}0hk(5ZgwUeqHi>~ zgD+JavDRZL6~B}NOl0b@R9&=VNsqn%n;o3r(9HqV%a}&9K8r=PjY@AdC2(xFfbQh}qG!jP* zZ0qFd;?RU~TVgOb>OEY2v!Y^8xA95ax_+D(T@=T*QKw~+>eW>UMLq$?Gi_{1!f>c_ z+7^eV7V^%xEDJWkl(GEf%gf!4HZbWej7(%oTuXGJumtO zz@Of`=XbS#D$`2xBK)&3IBbVkQosMa!J-k7vxxH^BC;m%hr9UN7`wEzG(d(ym*Uiy z-#tf0Mw|vUqGTyNo_W!Xa`N(gW~{7JD@(=q`n*j<(hO;b`}Zm+Z$|-++d{NfyiIU< zx)N-tKpTlSl3wi_t{(RRrPRjyG8+nEKiCHpq=oxP951V+=4)BX z{c+%muj0{P)>2PCRxc#E;fYUY3ypw8twJ3OVQh~08`MT$mX#qKeB>y)fu?oFV67K_F*%Bn^X;4Eg;JBv?_GN@d{<54V~*rd1XXZr<|#zQq13jUj1`G%14%dTIfnA z54eI_-07#+a>hY~a$7kV@PUkH7Go|5%eM^7xWJp?SVmw;YbhSTTGA^m(X(6<*B%f_ ziSE?O^TX2%8mv8XK88BMEC=kx)B9Ts%sIcc6Ij{kKzY+?)!97N*kIS?qo5PcAFc`k z^D>3G2SQgK;9ZWh&rJYs^7Z7j+b+92#LsBfk`y_b?&iEzSyOH!1Ldi%Bxn5A$2v21 zZf?!?gzFg5C&tRRXMVU$X$3;vzh)-x3aXRyFt#!djnGbXFYOmNIN4|XAs#prB`RpI z=h?1n-x+@+TO8Bf{&jhnHpiDMDkdc$$iu4YznZ|GHyuZLdzOcXNB>tMM3q#k;bCc= z*YdtXk_Np5B^5J>Noufx%xzQ&!CUbC?BESRy0i3O@q3-QPqq6#M(iZ2c}z~^Q6WTm8}0C8g?jAd>X z6%{Rh%PZ)?sN;S{Ms~CxMLXSjf0UCQkS@3O%_71yM;+shadicQ{*sqhDRZ<=jNn2_ zJ;^tthCL%a-hAWjj7FEBwbl_M8ve>)Nm*PFgYiYb(ZmI;F;4Fdv{*N*)8>{l=u z*^i8iXt|t8aLH866QGo|*1U#6mej72u2C!YY`s+q9XL}X;PARz$@wja!+_RXns39| ziQuq;Q9fRd7_23`>pybGtr?bdUHX_g6IFHHlv;5=bo{)Xe0;~BDBbKd-LzrO5gBn;VS*-tReI4!YEyba@lX)%2ak5s9stgN|5)%n(7!gozAlr*#BjrDpjE`$y_ zPk=XPsr2kgh{>LYrd|FwDkH|qTS}d!uCIRKY<6^$F)FvI9P|NGWGSh)&2WA7 zSRE5wjKg$E6Ykkj5t=jLo=&Rqw!kz_RV)JXf5b80ta2QUwH4zD{NsU)^h}Xe2&c$@ E0mdnwo&W#< literal 3410 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 diff --git a/src/AngleSharp.Renderer/FontFaceLoader.cs b/src/AngleSharp.Renderer/FontFaceLoader.cs new file mode 100644 index 0000000..72155e0 --- /dev/null +++ b/src/AngleSharp.Renderer/FontFaceLoader.cs @@ -0,0 +1,319 @@ +namespace AngleSharp.Renderer; + +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; + +using AngleSharp.Css.Dom; +using AngleSharp.Dom; +using AngleSharp.Io; +using AngleSharp.Renderer.Rendering; + +/// +/// Turns the @font-face rules of a document into a . +/// +internal static class FontFaceLoader +{ + // Skia reads raw TrueType/OpenType only. The compressed web wrappers would need a decoder + // this library does not carry, so those sources are skipped and the next one is tried. + private static readonly HashSet UnsupportedFormats = + new(StringComparer.OrdinalIgnoreCase) { "woff", "woff2", "svg", "embedded-opentype" }; + + public static FontFaceSet Load(IDocument document) + { + var faces = new List(); + + foreach (var sheet in document.StyleSheets.OfType()) + { + foreach (var rule in sheet.Rules.OfType()) + { + if (TryCreateFace(document, rule, out var face)) + { + faces.Add(face); + } + } + } + + return faces.Count == 0 ? FontFaceSet.Empty : new FontFaceSet(faces); + } + + private static bool TryCreateFace(IDocument document, ICssFontFaceRule rule, out FontFace face) + { + face = null!; + + var family = Unquote(rule.Family); + + if (string.IsNullOrWhiteSpace(family) || string.IsNullOrWhiteSpace(rule.Source)) + { + return false; + } + + var weight = ParseWeight(rule.Weight); + var isItalic = IsItalic(rule.Style); + var sources = new List(); + + // The declaration order is preserved. Whether a local() source exists is a property of the + // machine, so that decision belongs to the backend rather than to this loader. + foreach (var source in SplitSources(rule.Source)) + { + if (TryParseLocal(source, out var localFamily)) + { + sources.Add(FontFaceSource.FromLocal(localFamily)); + } + else if (TryParseUrl(source, out var url, out var format)) + { + if (format is not null && UnsupportedFormats.Contains(format)) + { + continue; + } + + if (TryLoadFontData(document, url, out var data)) + { + sources.Add(FontFaceSource.FromData(data)); + } + } + } + + if (sources.Count == 0) + { + return false; + } + + face = new FontFace(family, weight, isItalic, sources); + return true; + } + + private static bool TryLoadFontData(IDocument document, string url, out byte[] data) + { + data = []; + + if (TryDecodeDataUri(url, out var inlineData)) + { + data = inlineData; + return IsSupportedFontData(data); + } + + // Matching how images are handled, nothing is fetched unless the browsing context was + // configured with a loader. A renderer should not silently reach out to the network. + var loader = document.Context.GetService(); + + if (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); + data = buffer.ToArray(); + + return IsSupportedFontData(data); + } + catch + { + return false; + } + } + + /// + /// Rejects the compressed wrappers up front, so a face that could never be rasterized does not + /// shadow the next source or the next family in the fallback list. + /// + private static bool IsSupportedFontData(byte[] data) + { + if (data.Length < 4) + { + return false; + } + + var tag = Encoding.ASCII.GetString(data, 0, 4); + + return tag is not ("wOFF" or "wOF2"); + } + + private static IEnumerable SplitSources(string source) + { + var depth = 0; + var quote = '\0'; + var start = 0; + + for (var index = 0; index < source.Length; index++) + { + var current = source[index]; + + if (quote != '\0') + { + if (current == quote) + { + quote = '\0'; + } + } + else if (current is '\'' or '"') + { + quote = current; + } + else if (current == '(') + { + depth++; + } + else if (current == ')') + { + depth--; + } + else if (current == ',' && depth == 0) + { + yield return source[start..index]; + start = index + 1; + } + } + + if (start < source.Length) + { + yield return source[start..]; + } + } + + private static bool TryParseLocal(string source, out string localFamily) + { + localFamily = string.Empty; + + var value = ExtractFunctionArgument(source.Trim(), "local"); + + if (value is null) + { + return false; + } + + localFamily = Unquote(value); + return localFamily.Length > 0; + } + + private static bool TryParseUrl(string source, out string url, out string? format) + { + url = string.Empty; + format = null; + + var trimmed = source.Trim(); + var value = ExtractFunctionArgument(trimmed, "url"); + + if (value is null) + { + return false; + } + + url = Unquote(value); + + var formatIndex = trimmed.IndexOf("format", StringComparison.OrdinalIgnoreCase); + + if (formatIndex >= 0) + { + var formatValue = ExtractFunctionArgument(trimmed[formatIndex..], "format"); + + if (formatValue is not null) + { + format = Unquote(formatValue); + } + } + + return url.Length > 0; + } + + private static string? ExtractFunctionArgument(string source, string functionName) + { + if (!source.StartsWith(functionName, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var open = source.IndexOf('(', functionName.Length); + + if (open < 0 || source[functionName.Length..open].Trim().Length > 0) + { + return null; + } + + var close = source.IndexOf(')', open); + + return close < 0 ? null : source[(open + 1)..close].Trim(); + } + + private static bool TryDecodeDataUri(string url, out byte[] data) + { + data = []; + + if (!url.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var separator = url.IndexOf(','); + + if (separator < 0) + { + return false; + } + + var metadata = url[5..separator]; + var payload = url[(separator + 1)..]; + + try + { + data = metadata.Contains("base64", StringComparison.OrdinalIgnoreCase) + ? Convert.FromBase64String(payload.Trim()) + : Encoding.ASCII.GetBytes(Uri.UnescapeDataString(payload)); + + return data.Length > 0; + } + catch (FormatException) + { + return false; + } + } + + private static float ParseWeight(string? weight) + { + if (string.IsNullOrWhiteSpace(weight)) + { + return 400f; + } + + var trimmed = weight.Trim(); + + if (string.Equals(trimmed, "bold", StringComparison.OrdinalIgnoreCase)) + { + return 700f; + } + + if (string.Equals(trimmed, "normal", StringComparison.OrdinalIgnoreCase)) + { + return 400f; + } + + // A weight range such as "400 700" contributes its lower bound. + var first = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(); + + return float.TryParse(first, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) + ? parsed + : 400f; + } + + private static bool IsItalic(string? style) => + style is not null && + (style.Contains("italic", StringComparison.OrdinalIgnoreCase) || + style.Contains("oblique", StringComparison.OrdinalIgnoreCase)); + + private static string Unquote(string? value) => + value is null ? string.Empty : value.Trim().Trim('\'', '"').Trim(); +} diff --git a/src/AngleSharp.Renderer/HtmlRenderer.cs b/src/AngleSharp.Renderer/HtmlRenderer.cs index 5fe926c..5de2b4a 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.cs @@ -25,8 +25,12 @@ public sealed class HtmlRenderer // Per-document cache keeps image payloads stable across repeated renders of the same DOM instance. private static readonly ConditionalWeakTable s_imageCacheByDocument = new(); private static readonly AsyncLocal s_layoutCapture = new(); + private static readonly ITextMeasurer s_defaultTextMeasurer = new SkiaTextMeasurer(); + + private const float CollapsedBorderWidth = 1f; private readonly IRenderBackend _backend; + private readonly ITextMeasurer _textMeasurer; private sealed class DocumentImageCache { @@ -59,7 +63,8 @@ private readonly record struct LayoutContext( float FontSize, float LineHeightMultiplier, float ParagraphSpacing, - float AverageCharacterWidthFactor); + ITextMeasurer TextMeasurer, + FontFaceSet Fonts); private sealed class LayoutCapture { @@ -81,7 +86,7 @@ internal static IReadOnlyDictionary CaptureLayou ArgumentNullException.ThrowIfNull(document); ArgumentNullException.ThrowIfNull(renderDevice); - var context = CreateLayoutContext(document, renderDevice); + var context = CreateLayoutContext(document, renderDevice, s_defaultTextMeasurer); var viewport = new RenderViewport(context.Width, context.Height); var capture = new LayoutCapture(); var previous = s_layoutCapture.Value; @@ -143,16 +148,29 @@ public HtmlRenderer() } /// - /// Creates a new renderer with a specific backend. + /// Creates a new renderer with a specific backend. A backend that measures text itself is + /// used for layout as well, so that layout and rasterization agree on advance widths. /// /// The backend used for rasterization. public HtmlRenderer(IRenderBackend backend) + : this(backend, (backend as ITextMeasurer) ?? s_defaultTextMeasurer) + { + } + + /// + /// Creates a new renderer with a specific backend and text measurer. + /// + /// The backend used for rasterization. + /// The measurer used to compute advance widths during layout. + public HtmlRenderer(IRenderBackend backend, ITextMeasurer textMeasurer) { ArgumentNullException.ThrowIfNull(backend); + ArgumentNullException.ThrowIfNull(textMeasurer); _backend = backend; + _textMeasurer = textMeasurer; } - private static LayoutContext CreateLayoutContext(IDocument document, IRenderDevice renderDevice) + private static LayoutContext CreateLayoutContext(IDocument document, IRenderDevice renderDevice, ITextMeasurer textMeasurer) { var width = Math.Max(1, (int)Math.Round(Convert.ToDouble(renderDevice.ViewPortWidth))); var height = Math.Max(1, (int)Math.Round(Convert.ToDouble(renderDevice.ViewPortHeight))); @@ -185,7 +203,8 @@ private static LayoutContext CreateLayoutContext(IDocument document, IRenderDevi FontSize: defaultFontSize, LineHeightMultiplier: defaultLineHeight, ParagraphSpacing: 0f, - AverageCharacterWidthFactor: 0.55f); + TextMeasurer: textMeasurer, + Fonts: FontFaceLoader.Load(document)); } /// @@ -211,7 +230,7 @@ public RenderedImage RenderToPng(IDocument document, IRenderDevice renderDevice) ArgumentNullException.ThrowIfNull(document); ArgumentNullException.ThrowIfNull(renderDevice); - var context = CreateLayoutContext(document, renderDevice); + var context = CreateLayoutContext(document, renderDevice, _textMeasurer); var viewport = new RenderViewport(context.Width, context.Height); var displayList = BuildDisplayList(document, viewport, context, renderDevice); @@ -241,14 +260,14 @@ public DisplayList BuildDisplayList(IDocument document, IRenderDevice renderDevi ArgumentNullException.ThrowIfNull(document); ArgumentNullException.ThrowIfNull(renderDevice); - var context = CreateLayoutContext(document, renderDevice); + var context = CreateLayoutContext(document, renderDevice, _textMeasurer); var viewport = new RenderViewport(context.Width, context.Height); return BuildDisplayList(document, viewport, context, renderDevice); } private static DisplayList BuildDisplayList(IDocument document, RenderViewport viewport, LayoutContext context, IRenderDevice renderDevice) { - var displayList = new DisplayList(); + var displayList = new DisplayList { Fonts = context.Fonts }; displayList.FillRect(new RenderRect(0f, 0f, viewport.Width, viewport.Height), context.BackgroundColor); var window = document.DefaultView; @@ -708,7 +727,7 @@ private static void LayoutElement( currentTextStyle, flowContainingX, flowContainingWidth, - context.AverageCharacterWidthFactor, + context, ref inlineCursorX, ref inlineLineTop, ref inlineLineHeight, @@ -739,7 +758,7 @@ private static void LayoutElement( childTextStyle, flowContainingX, flowContainingWidth, - context.AverageCharacterWidthFactor, + context, ref inlineCursorX, ref inlineLineTop, ref inlineLineHeight, @@ -1280,7 +1299,7 @@ private static void LayoutTable( .ToList()) .ToList(); - var tableCells = new List<(int RowIndex, int ColumnIndex, int ColumnSpan, int RowSpan, ElementRenderNode CellNode, Dictionary CellStyle, RenderTextStyle CellTextStyle, string Text, float PaddingLeft, float PaddingRight, float PaddingTop, float PaddingBottom, float BorderLeftWidth, float BorderRightWidth, float BorderTopWidth, float BorderBottomWidth, RenderColor BackgroundColor)>(); + var tableCells = new List(); var rowSpanOccupancy = new List(); var columnCount = 0; @@ -1349,7 +1368,7 @@ private static void LayoutTable( nextRowSpanOccupancy[currentColumnIndex + spanOffset] = Math.Max(nextRowSpanOccupancy[currentColumnIndex + spanOffset], Math.Max(0, rowspan - 1)); } - tableCells.Add((rowIndex, currentColumnIndex, colspan, rowspan, cellNode, cellStyle, cellTextStyle, text, + tableCells.Add(new TableCellPlacement(rowIndex, currentColumnIndex, colspan, rowspan, cellNode, cellStyle, cellTextStyle, text, ParseLength(cellStyle, "padding-left", containingWidth, 4f, allowAuto: false), ParseLength(cellStyle, "padding-right", containingWidth, 4f, allowAuto: false), ParseLength(cellStyle, "padding-top", containingWidth, 4f, allowAuto: false), @@ -1358,7 +1377,8 @@ private static void LayoutTable( ParseLength(cellStyle, "border-right-width", containingWidth, 1f, allowAuto: false), ParseLength(cellStyle, "border-top-width", containingWidth, 1f, allowAuto: false), ParseLength(cellStyle, "border-bottom-width", containingWidth, 1f, allowAuto: false), - ParseColor(cellStyle.TryGetValue("background-color", out var backgroundColor) ? backgroundColor : null, RenderColor.Transparent))); + ParseColor(cellStyle.TryGetValue("background-color", out var backgroundColor) ? backgroundColor : null, RenderColor.Transparent), + ParseCellVerticalAlign(cellStyle))); columnCount = Math.Max(columnCount, currentColumnIndex + colspan); currentColumnIndex += colspan; @@ -1368,6 +1388,12 @@ private static void LayoutTable( { if (rowSpanOccupancy[index] > 0) { + // A row with fewer cells than the span reaches over leaves the new list short. + while (index >= nextRowSpanOccupancy.Count) + { + nextRowSpanOccupancy.Add(0); + } + nextRowSpanOccupancy[index] = Math.Max(nextRowSpanOccupancy[index], rowSpanOccupancy[index] - 1); } } @@ -1389,7 +1415,7 @@ private static void LayoutTable( var paddingRight = placement.PaddingRight; var borderLeftWidth = placement.BorderLeftWidth; var borderRightWidth = placement.BorderRightWidth; - var textWidth = placement.Text.Length > 0 ? EstimateTextWidth(placement.Text, placement.CellTextStyle.FontSize, context.AverageCharacterWidthFactor, placement.CellTextStyle.LetterSpacing) : 0f; + var textWidth = placement.Text.Length > 0 ? MeasureTextWidth(context, placement.Text, placement.CellTextStyle) : 0f; var minCellWidth = textWidth + paddingLeft + paddingRight + borderLeftWidth + borderRightWidth + 8f; var widthPerColumn = float.IsNaN(specifiedCellWidth) ? minCellWidth / Math.Max(1, placement.ColumnSpan) : specifiedCellWidth / Math.Max(1, placement.ColumnSpan); @@ -1439,23 +1465,33 @@ private static void LayoutTable( var rowTopOffsets = new float[rowCellLists.Count]; var rowHeights = new float[rowCellLists.Count]; - foreach (var placement in tableCells) + // Cells confined to a single row establish the row heights on their own. + foreach (var placement in tableCells.Where(placement => placement.RowSpan <= 1)) { - var contentWidth = Math.Max(0f, columnWidths.Skip(placement.ColumnIndex).Take(placement.ColumnSpan).Sum() - placement.PaddingLeft - placement.PaddingRight - placement.BorderLeftWidth - placement.BorderRightWidth); - var contentHeight = 0f; - var text = placement.Text; + rowHeights[placement.RowIndex] = Math.Max(rowHeights[placement.RowIndex], MeasureCellHeight(context, placement, columnWidths)); + } - if (contentWidth > 0f && text.Length > 0) - { - var wrappedLines = WrapText(text, contentWidth, placement.CellTextStyle.FontSize, context.AverageCharacterWidthFactor, placement.CellTextStyle.LetterSpacing); - var lineHeight = placement.CellTextStyle.FontSize * placement.CellTextStyle.LineHeightMultiplier; - contentHeight = wrappedLines.Count * lineHeight; - } + for (var rowIndex = 0; rowIndex < rowHeights.Length; rowIndex++) + { + rowHeights[rowIndex] = Math.Max(rowHeights[rowIndex], 20f); + } - var effectiveHeight = Math.Max(20f, contentHeight + placement.PaddingTop + placement.PaddingBottom + placement.BorderTopWidth + placement.BorderBottomWidth); - for (var rowIndex = placement.RowIndex; rowIndex < placement.RowIndex + placement.RowSpan; rowIndex++) + // A spanning cell only has to fit across the rows it covers taken together, so it grows + // them by whatever is still missing rather than imposing its full height on each one. + foreach (var placement in tableCells.Where(placement => placement.RowSpan > 1)) + { + var spannedRows = Enumerable.Range(placement.RowIndex, placement.RowSpan).ToArray(); + var available = spannedRows.Sum(rowIndex => rowHeights[rowIndex]); + var required = MeasureCellHeight(context, placement, columnWidths); + + if (required > available) { - rowHeights[rowIndex] = Math.Max(rowHeights[rowIndex], effectiveHeight); + var deficitPerRow = (required - available) / placement.RowSpan; + + foreach (var rowIndex in spannedRows) + { + rowHeights[rowIndex] += deficitPerRow; + } } } @@ -1487,21 +1523,12 @@ private static void LayoutTable( cellHeight += rowHeights[rowIndex]; } - var contentHeight = 0f; - if (contentWidth > 0f && placement.Text.Length > 0) - { - var wrappedLines = WrapText(placement.Text, contentWidth, placement.CellTextStyle.FontSize, context.AverageCharacterWidthFactor, placement.CellTextStyle.LetterSpacing); - var lineHeight = placement.CellTextStyle.FontSize * placement.CellTextStyle.LineHeightMultiplier; - contentHeight = wrappedLines.Count * lineHeight; - } - - var effectiveHeight = Math.Max(20f, contentHeight + placement.PaddingTop + placement.PaddingBottom + placement.BorderTopWidth + placement.BorderBottomWidth); RecordLayoutMetrics( placement.CellNode.Ref, cellX, cellY, cellWidth, - effectiveHeight, + cellHeight, placement.BorderLeftWidth, placement.BorderRightWidth, placement.BorderTopWidth, @@ -1511,22 +1538,44 @@ private static void LayoutTable( placement.PaddingTop, placement.PaddingBottom); - displayList.FillRect(new RenderRect(cellX, cellY, cellWidth, effectiveHeight), placement.BackgroundColor); + displayList.FillRect(new RenderRect(cellX, cellY, cellWidth, cellHeight), placement.BackgroundColor); - if (!borderCollapse) + if (borderCollapse) + { + // Collapsed borders are shared, so each cell contributes only its top and left + // edge and the table frame closes the far sides. Drawing full outlines instead + // would lay two lines over every shared edge, and a grid spanning the whole table + // would cut straight through the cells that span rows or columns. + displayList.FillRect(new RenderRect(cellX, cellY, cellWidth, CollapsedBorderWidth), RenderColor.Black); + displayList.FillRect(new RenderRect(cellX, cellY, CollapsedBorderWidth, cellHeight), RenderColor.Black); + } + else { displayList.FillRect(new RenderRect(cellX, cellY, cellWidth, placement.BorderTopWidth), RenderColor.Black); - displayList.FillRect(new RenderRect(cellX + cellWidth - placement.BorderRightWidth, cellY, placement.BorderRightWidth, effectiveHeight), RenderColor.Black); - displayList.FillRect(new RenderRect(cellX, cellY + effectiveHeight - placement.BorderBottomWidth, cellWidth, placement.BorderBottomWidth), RenderColor.Black); - displayList.FillRect(new RenderRect(cellX, cellY, placement.BorderLeftWidth, effectiveHeight), RenderColor.Black); + displayList.FillRect(new RenderRect(cellX + cellWidth - placement.BorderRightWidth, cellY, placement.BorderRightWidth, cellHeight), RenderColor.Black); + displayList.FillRect(new RenderRect(cellX, cellY + cellHeight - placement.BorderBottomWidth, cellWidth, placement.BorderBottomWidth), RenderColor.Black); + displayList.FillRect(new RenderRect(cellX, cellY, placement.BorderLeftWidth, cellHeight), RenderColor.Black); } if (contentWidth > 0f && placement.Text.Length > 0) { - var wrappedLines = WrapText(placement.Text, contentWidth, placement.CellTextStyle.FontSize, context.AverageCharacterWidthFactor, placement.CellTextStyle.LetterSpacing); + var wrappedLines = WrapText(context, placement.Text, contentWidth, placement.CellTextStyle); var lineHeight = placement.CellTextStyle.FontSize * placement.CellTextStyle.LineHeightMultiplier; var lineX = cellX + placement.PaddingLeft + placement.BorderLeftWidth; - var lineY = cellY + placement.PaddingTop + placement.BorderTopWidth + lineHeight; + + // The content box can be taller than the text, most visibly in a cell that spans + // rows, so the block of lines is placed according to the cell's vertical-align. + var contentBoxHeight = cellHeight - placement.PaddingTop - placement.PaddingBottom + - placement.BorderTopWidth - placement.BorderBottomWidth; + var slack = Math.Max(0f, contentBoxHeight - (wrappedLines.Count * lineHeight)); + var verticalOffset = placement.VerticalAlign switch + { + CellVerticalAlign.Middle => slack / 2f, + CellVerticalAlign.Bottom => slack, + _ => 0f, + }; + + var lineY = cellY + placement.PaddingTop + placement.BorderTopWidth + verticalOffset + lineHeight; for (var lineIndex = 0; lineIndex < wrappedLines.Count; lineIndex++) { @@ -1538,26 +1587,12 @@ private static void LayoutTable( if (borderCollapse) { - var collapsedBorderWidth = 1f; - - displayList.FillRect(new RenderRect(tableX, tableY, tableWidth, collapsedBorderWidth), RenderColor.Black); - displayList.FillRect(new RenderRect(tableX, tableY + tableHeight - collapsedBorderWidth, tableWidth, collapsedBorderWidth), RenderColor.Black); - displayList.FillRect(new RenderRect(tableX, tableY, collapsedBorderWidth, tableHeight), RenderColor.Black); - displayList.FillRect(new RenderRect(tableX + tableWidth - collapsedBorderWidth, tableY, collapsedBorderWidth, tableHeight), RenderColor.Black); - - var currentVerticalX = tableX; - for (var columnIndex = 1; columnIndex < columnCount; columnIndex++) - { - currentVerticalX += columnWidths[columnIndex - 1]; - displayList.FillRect(new RenderRect(currentVerticalX, tableY, collapsedBorderWidth, tableHeight), RenderColor.Black); - } - - var currentHorizontalY = tableY; - for (var rowIndex = 1; rowIndex < rowCellLists.Count; rowIndex++) - { - currentHorizontalY += rowHeights[rowIndex - 1]; - displayList.FillRect(new RenderRect(tableX, currentHorizontalY, tableWidth, collapsedBorderWidth), RenderColor.Black); - } + // The interior lines come from the cell outlines above; only the frame is left, which + // also closes the edge of rows that hold fewer cells than the table has columns. + displayList.FillRect(new RenderRect(tableX, tableY, tableWidth, CollapsedBorderWidth), RenderColor.Black); + displayList.FillRect(new RenderRect(tableX, tableY + tableHeight - CollapsedBorderWidth, tableWidth, CollapsedBorderWidth), RenderColor.Black); + displayList.FillRect(new RenderRect(tableX, tableY, CollapsedBorderWidth, tableHeight), RenderColor.Black); + displayList.FillRect(new RenderRect(tableX + tableWidth - CollapsedBorderWidth, tableY, CollapsedBorderWidth, tableHeight), RenderColor.Black); } RecordLayoutMetrics( @@ -1979,7 +2014,7 @@ private static void LayoutWrappedText( float firstLineIndent) { var lineHeight = textStyle.FontSize * textStyle.LineHeightMultiplier; - var lines = WrapText(text, maxWidth, textStyle.FontSize, context.AverageCharacterWidthFactor, textStyle.LetterSpacing); + var lines = WrapText(context, text, maxWidth, textStyle); for (var index = 0; index < lines.Count; index++) { @@ -1991,7 +2026,7 @@ private static void LayoutWrappedText( return; } - var lineWidth = EstimateTextWidth(line, textStyle.FontSize, context.AverageCharacterWidthFactor, textStyle.LetterSpacing); + var lineWidth = MeasureTextWidth(context, line, textStyle); var lineMaxWidth = index == 0 ? Math.Max(0f, maxWidth - firstLineIndent) : maxWidth; var lineX = x + (index == 0 ? firstLineIndent : 0f) + ResolveTextAlignmentOffset(textStyle.TextAlign, lineMaxWidth, lineWidth); var baselineY = cursorY + textStyle.VerticalAlignOffset; @@ -2019,7 +2054,7 @@ private static void LayoutInlineTextRun( RenderTextStyle textStyle, float flowX, float flowWidth, - float averageCharacterWidthFactor, + LayoutContext context, ref float inlineCursorX, ref float inlineLineTop, ref float inlineLineHeight, @@ -2027,11 +2062,11 @@ private static void LayoutInlineTextRun( { var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries); var rightEdge = flowX + flowWidth; - var spaceWidth = EstimateTextWidth(" ", textStyle.FontSize, averageCharacterWidthFactor, textStyle.LetterSpacing); + var spaceWidth = MeasureTextWidth(context, " ", textStyle); foreach (var word in words) { - var wordWidth = EstimateTextWidth(word, textStyle.FontSize, averageCharacterWidthFactor, textStyle.LetterSpacing); + var wordWidth = MeasureTextWidth(context, word, textStyle); if (inlineCursorX > flowX && inlineCursorX + spaceWidth + wordWidth > rightEdge) { @@ -2251,6 +2286,27 @@ private static RenderTextStyle ResolveTextStyle(Dictionary style return new RenderTextStyle(fontSize, color, fontFamily, lineHeight, fontWeight, isItalic, underline, strikeThrough, decorationColor, decorationStyle, textAlign, letterSpacing, textIndent, verticalAlignOffset); } + /// + /// Reads the box-level meaning of vertical-align, which is what the property means on a + /// table cell. On inline content the same property shifts the text instead, which is what + /// handles. + /// + private static CellVerticalAlign ParseCellVerticalAlign(Dictionary styleMap) + { + if (!styleMap.TryGetValue("vertical-align", out var value) || string.IsNullOrWhiteSpace(value)) + { + return CellVerticalAlign.Middle; + } + + return value.Trim().ToLowerInvariant() switch + { + "top" or "text-top" => CellVerticalAlign.Top, + "bottom" or "text-bottom" => CellVerticalAlign.Bottom, + "middle" => CellVerticalAlign.Middle, + _ => CellVerticalAlign.Top, + }; + } + private static float ParseVerticalAlign(Dictionary styleMap, float fontSize) { if (!styleMap.TryGetValue("vertical-align", out var value) || string.IsNullOrWhiteSpace(value)) @@ -3769,7 +3825,28 @@ private static RenderColor ParseHexColor(string color, RenderColor fallback) return fallback; } - private static IReadOnlyList WrapText(string text, float maxWidth, float fontSize, float averageCharacterWidthFactor, float letterSpacing) + /// + /// The height a cell needs for its own content, independent of the rows it spans. + /// + private static float MeasureCellHeight( + LayoutContext context, + TableCellPlacement placement, + float[] columnWidths) + { + var contentWidth = Math.Max(0f, columnWidths.Skip(placement.ColumnIndex).Take(placement.ColumnSpan).Sum() + - placement.PaddingLeft - placement.PaddingRight - placement.BorderLeftWidth - placement.BorderRightWidth); + var contentHeight = 0f; + + if (contentWidth > 0f && placement.Text.Length > 0) + { + var wrappedLines = WrapText(context, placement.Text, contentWidth, placement.CellTextStyle); + contentHeight = wrappedLines.Count * placement.CellTextStyle.FontSize * placement.CellTextStyle.LineHeightMultiplier; + } + + return Math.Max(20f, contentHeight + placement.PaddingTop + placement.PaddingBottom + placement.BorderTopWidth + placement.BorderBottomWidth); + } + + private static IReadOnlyList WrapText(LayoutContext context, string text, float maxWidth, RenderTextStyle textStyle) { var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries); @@ -3784,8 +3861,8 @@ private static IReadOnlyList WrapText(string text, float maxWidth, float foreach (var word in words) { - var wordWidth = EstimateTextWidth(word, fontSize, averageCharacterWidthFactor, letterSpacing); - var separatorWidth = current.Length == 0 ? 0f : EstimateTextWidth(" ", fontSize, averageCharacterWidthFactor, letterSpacing); + var wordWidth = MeasureTextWidth(context, word, textStyle); + var separatorWidth = current.Length == 0 ? 0f : MeasureTextWidth(context, " ", textStyle); if (current.Length > 0 && currentWidth + separatorWidth + wordWidth > maxWidth) { @@ -3812,30 +3889,16 @@ private static IReadOnlyList WrapText(string text, float maxWidth, float return lines; } - private static float EstimateTextWidth(string text, float fontSize, float averageCharacterWidthFactor, float letterSpacing) - { - var width = 0f; - var characterCount = 0; - - foreach (var c in text) - { - characterCount++; - width += c switch - { - 'i' or 'l' or '!' or '|' => fontSize * 0.35f, - 'm' or 'w' or 'M' or 'W' => fontSize * 0.9f, - ' ' => fontSize * 0.33f, - _ => fontSize * averageCharacterWidthFactor, - }; - } - - if (characterCount > 1) - { - width += (characterCount - 1) * letterSpacing; - } + private static RenderFont ToRenderFont(RenderTextStyle textStyle, FontFaceSet fonts) => new( + textStyle.FontFamily, + textStyle.FontSize, + textStyle.FontWeight, + textStyle.IsItalic, + textStyle.LetterSpacing, + fonts); - return width; - } + private static float MeasureTextWidth(LayoutContext context, string text, RenderTextStyle textStyle) => + context.TextMeasurer.MeasureWidth(text, ToRenderFont(textStyle, context.Fonts)); private static float ResolveTextAlignmentOffset(TextAlign align, float availableWidth, float textWidth) { @@ -3882,6 +3945,40 @@ private static string NormalizeWhitespace(string value) return sb.ToString().Trim(); } + /// + /// Where a cell's content sits within the box the cell occupies. + /// + /// + /// baseline is treated as top: aligning the first line boxes of every cell in a + /// row against a shared baseline is not implemented. + /// + private enum CellVerticalAlign + { + Top, + Middle, + Bottom, + } + + private readonly record struct TableCellPlacement( + int RowIndex, + int ColumnIndex, + int ColumnSpan, + int RowSpan, + ElementRenderNode CellNode, + Dictionary CellStyle, + RenderTextStyle CellTextStyle, + string Text, + float PaddingLeft, + float PaddingRight, + float PaddingTop, + float PaddingBottom, + float BorderLeftWidth, + float BorderRightWidth, + float BorderTopWidth, + float BorderBottomWidth, + RenderColor BackgroundColor, + CellVerticalAlign VerticalAlign); + private readonly record struct RenderTextStyle( float FontSize, RenderColor Color, diff --git a/src/AngleSharp.Renderer/Rendering/DisplayList.cs b/src/AngleSharp.Renderer/Rendering/DisplayList.cs index c605fc8..d81d059 100644 --- a/src/AngleSharp.Renderer/Rendering/DisplayList.cs +++ b/src/AngleSharp.Renderer/Rendering/DisplayList.cs @@ -14,6 +14,11 @@ public sealed class DisplayList /// public ReadOnlyCollection Commands => _commands.AsReadOnly(); + /// + /// Gets or sets the @font-face declarations the text commands resolve against. + /// + public FontFaceSet Fonts { get; set; } = FontFaceSet.Empty; + /// /// Adds a command to the list. /// diff --git a/src/AngleSharp.Renderer/Rendering/FontFace.cs b/src/AngleSharp.Renderer/Rendering/FontFace.cs new file mode 100644 index 0000000..53e24f6 --- /dev/null +++ b/src/AngleSharp.Renderer/Rendering/FontFace.cs @@ -0,0 +1,76 @@ +namespace AngleSharp.Renderer.Rendering; + +/// +/// One source listed in the src descriptor of an @font-face rule. +/// +/// The raw font file, when the source was a url(). +/// The installed family, when the source was a local(). +public readonly record struct FontFaceSource(byte[]? Data, string? LocalFamily) +{ + /// + /// Creates a source backed by an embedded font file. + /// + public static FontFaceSource FromData(byte[] data) + { + ArgumentNullException.ThrowIfNull(data); + return new FontFaceSource(data, null); + } + + /// + /// Creates a source that refers to an installed family. + /// + public static FontFaceSource FromLocal(string localFamily) + { + ArgumentNullException.ThrowIfNull(localFamily); + return new FontFaceSource(null, localFamily); + } +} + +/// +/// Represents a single @font-face declaration. +/// +public sealed class FontFace +{ + /// + /// Creates a face. + /// + /// The family name the face is registered under. + /// The numeric weight the face provides. + /// Whether the face is italic or oblique. + /// The sources to try, in declaration order. + public FontFace(string family, float weight, bool isItalic, IEnumerable sources) + { + ArgumentNullException.ThrowIfNull(family); + ArgumentNullException.ThrowIfNull(sources); + + Family = family; + Weight = weight; + IsItalic = isItalic; + Sources = [.. sources]; + } + + /// + /// Gets the family name the face is registered under. + /// + public string Family { get; } + + /// + /// Gets the numeric weight the face provides. + /// + public float Weight { get; } + + /// + /// Gets whether the face is italic or oblique. + /// + public bool IsItalic { get; } + + /// + /// Gets the sources to try, in declaration order. + /// + /// + /// The order matters and resolution is deliberately deferred: whether a local() source + /// is usable depends on the fonts installed on the machine, which only the backend knows. A + /// source that cannot be used falls through to the next one. + /// + public IReadOnlyList Sources { get; } +} diff --git a/src/AngleSharp.Renderer/Rendering/FontFaceSet.cs b/src/AngleSharp.Renderer/Rendering/FontFaceSet.cs new file mode 100644 index 0000000..ead0bd7 --- /dev/null +++ b/src/AngleSharp.Renderer/Rendering/FontFaceSet.cs @@ -0,0 +1,72 @@ +namespace AngleSharp.Renderer.Rendering; + +using System.Linq; + +/// +/// The @font-face declarations that apply to a document. +/// +public sealed class FontFaceSet +{ + /// + /// An empty set, used for documents that declare no custom fonts. + /// + public static readonly FontFaceSet Empty = new([]); + + private readonly Dictionary> _facesByFamily; + + /// + /// Creates a set from the given faces. + /// + /// The faces to include. + public FontFaceSet(IEnumerable faces) + { + ArgumentNullException.ThrowIfNull(faces); + + Faces = faces.ToArray(); + _facesByFamily = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var face in Faces) + { + if (!_facesByFamily.TryGetValue(face.Family, out var group)) + { + group = []; + _facesByFamily[face.Family] = group; + } + + group.Add(face); + } + } + + /// + /// Gets the declared faces. + /// + public IReadOnlyList Faces { get; } + + /// + /// Gets whether the set declares no faces at all. + /// + public bool IsEmpty => Faces.Count == 0; + + /// + /// Finds the face that best matches the requested family, weight and style. + /// + /// + /// This is a reduced form of the CSS font matching algorithm: a matching slant wins first, + /// then the nearest weight. Stretch and unicode ranges are not considered. + /// + public bool TryMatch(string family, float weight, bool isItalic, out FontFace face) + { + if (!_facesByFamily.TryGetValue(family, out var candidates)) + { + face = null!; + return false; + } + + face = candidates + .OrderBy(candidate => candidate.IsItalic == isItalic ? 0 : 1) + .ThenBy(candidate => Math.Abs(candidate.Weight - weight)) + .First(); + + return true; + } +} diff --git a/src/AngleSharp.Renderer/Rendering/ITextMeasurer.cs b/src/AngleSharp.Renderer/Rendering/ITextMeasurer.cs new file mode 100644 index 0000000..d08c43a --- /dev/null +++ b/src/AngleSharp.Renderer/Rendering/ITextMeasurer.cs @@ -0,0 +1,19 @@ +namespace AngleSharp.Renderer.Rendering; + +/// +/// Measures text the way the backend will eventually paint it. +/// +/// +/// Layout and rasterization have to agree on advance widths. If they do not, line breaking, +/// text alignment and table column widths are computed against a font that is never drawn. +/// +public interface ITextMeasurer +{ + /// + /// Measures the advance width of the given text in pixels. + /// + /// The text to measure. + /// The font the text is rendered with. + /// The advance width in pixels. + float MeasureWidth(string text, RenderFont font); +} diff --git a/src/AngleSharp.Renderer/Rendering/RenderFont.cs b/src/AngleSharp.Renderer/Rendering/RenderFont.cs new file mode 100644 index 0000000..45085dd --- /dev/null +++ b/src/AngleSharp.Renderer/Rendering/RenderFont.cs @@ -0,0 +1,18 @@ +namespace AngleSharp.Renderer.Rendering; + +/// +/// Describes the font a text run is laid out and painted with. +/// +/// The CSS font-family list, in declaration order. +/// The font size in pixels. +/// The numeric CSS font weight. +/// Whether the run is italic or oblique. +/// The additional spacing between characters in pixels. +/// The @font-face declarations in scope, if any. +public readonly record struct RenderFont( + string FontFamily, + float FontSize, + float FontWeight, + bool IsItalic, + float LetterSpacing, + FontFaceSet? Faces = null); diff --git a/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs b/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs index b666df1..0dd9c43 100644 --- a/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs +++ b/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs @@ -1,7 +1,5 @@ using AngleSharp.Renderer.Rendering; using System.Linq; -using System.Reflection; -using System.Threading; using SkiaSharp; @@ -10,25 +8,16 @@ namespace AngleSharp.Renderer.Skia; /// /// Uses SkiaSharp to render a display list. /// -public sealed class SkiaRenderBackend : IRenderBackend +/// +/// The backend also measures text, so a renderer using it lays out against the same advance +/// widths it paints with. +/// +public sealed class SkiaRenderBackend : IRenderBackend, ITextMeasurer { - private const string FontResourcePrefix = "AngleSharp.Renderer.Resources.Fonts."; + private readonly SkiaTextMeasurer _textMeasurer = new(); - private static readonly Lazy> BundledFonts = - new(CreateBundledFonts, LazyThreadSafetyMode.ExecutionAndPublication); - - private static readonly IReadOnlyDictionary GenericFontMappings = - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["serif"] = "serif", - ["sans-serif"] = "sans-serif", - ["monospace"] = "monospace", - ["cursive"] = "sans-serif", - ["fantasy"] = "serif", - ["dejavu serif"] = "serif", - ["dejavu sans"] = "sans-serif", - ["dejavu sans mono"] = "monospace", - }; + /// + public float MeasureWidth(string text, RenderFont font) => _textMeasurer.MeasureWidth(text, font); /// public RenderedImage RenderToPng(DisplayList displayList, RenderViewport viewport) @@ -49,7 +38,7 @@ public RenderedImage RenderToPng(DisplayList displayList, RenderViewport viewpor foreach (var command in displayList.Commands) { - DrawCommand(canvas, command); + DrawCommand(canvas, command, displayList.Fonts); } using var image = surface.Snapshot(); @@ -59,7 +48,7 @@ public RenderedImage RenderToPng(DisplayList displayList, RenderViewport viewpor return new RenderedImage(data.ToArray(), viewport.Width, viewport.Height, "image/png"); } - private static void DrawCommand(SKCanvas canvas, RenderCommand command) + private static void DrawCommand(SKCanvas canvas, RenderCommand command, FontFaceSet fonts) { switch (command) { @@ -70,7 +59,7 @@ private static void DrawCommand(SKCanvas canvas, RenderCommand command) DrawImage(canvas, image); break; case DrawTextCommand text: - DrawText(canvas, text); + DrawText(canvas, text, fonts); break; } } @@ -188,24 +177,18 @@ private static void DrawImage(SKCanvas canvas, DrawImageCommand command) canvas.DrawImage(image, rect, paint); } - private static void DrawText(SKCanvas canvas, DrawTextCommand command) + private static void DrawText(SKCanvas canvas, DrawTextCommand command, FontFaceSet fonts) { - var fontStyle = new SKFontStyle( - command.FontWeight >= 600f ? SKFontStyleWeight.Bold : SKFontStyleWeight.Normal, - SKFontStyleWidth.Normal, - command.IsItalic ? SKFontStyleSlant.Italic : SKFontStyleSlant.Upright); + var font = new RenderFont( + command.FontFamily, + command.FontSize, + command.FontWeight, + command.IsItalic, + command.LetterSpacing, + fonts); - using var paint = new SKPaint - { - Color = ToSkColor(command.Color), - IsAntialias = true, - SubpixelText = false, - LcdRenderText = false, - HintingLevel = SKPaintHinting.Normal, - TextSize = command.FontSize, - Typeface = CreateTypeface(command.FontFamily, fontStyle), - TextSkewX = command.IsItalic ? -0.25f : 0f, - }; + using var paint = SkiaTextShaping.CreateTextPaint(font); + paint.Color = ToSkColor(command.Color); DrawTextWithLetterSpacing(canvas, paint, command.Text, command.X, command.Y, command.LetterSpacing); @@ -219,7 +202,7 @@ private static void DrawText(SKCanvas canvas, DrawTextCommand command) StrokeWidth = Math.Max(1f, command.FontSize / 14f), }; - var textWidth = MeasureTextWidth(paint, command.Text, command.LetterSpacing); + var textWidth = SkiaTextShaping.MeasureTextWidth(paint, command.Text, command.LetterSpacing); if (command.Underline) { @@ -264,74 +247,6 @@ private static void DrawPatternedLine(SKCanvas canvas, SKPaint paint, float x, f } } - private static SKTypeface CreateTypeface(string fontFamily, SKFontStyle fontStyle) - { - var isBold = fontStyle.Weight >= (int)SKFontStyleWeight.Bold; - - var families = fontFamily.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - - foreach (var family in families) - { - var normalized = family.Trim('\'', '"', ' '); - - if (string.IsNullOrWhiteSpace(normalized)) - { - continue; - } - - if (GenericFontMappings.TryGetValue(normalized, out var bundledFamilyKey) && - BundledFonts.Value.TryGetValue(bundledFamilyKey, out var bundledFamily)) - { - return isBold ? bundledFamily.Bold : bundledFamily.Regular; - } - - var typeface = SKTypeface.FromFamilyName(normalized, fontStyle); - - if (typeface is not null) - { - return typeface; - } - } - - if (BundledFonts.Value.TryGetValue("sans-serif", out var defaultFamily)) - { - return isBold ? defaultFamily.Bold : defaultFamily.Regular; - } - - return SKTypeface.FromFamilyName(fontFamily, fontStyle) ?? SKTypeface.Default; - } - - private static IReadOnlyDictionary CreateBundledFonts() - { - var sansRegular = LoadBundledTypeface("DejaVuSans.ttf"); - var sansBold = LoadBundledTypeface("DejaVuSans-Bold.ttf"); - var serifRegular = LoadBundledTypeface("DejaVuSerif.ttf"); - var serifBold = LoadBundledTypeface("DejaVuSerif-Bold.ttf"); - var monoRegular = LoadBundledTypeface("DejaVuSansMono.ttf"); - var monoBold = LoadBundledTypeface("DejaVuSansMono-Bold.ttf"); - - return new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["sans-serif"] = new BundledFontFamily(sansRegular, sansBold), - ["serif"] = new BundledFontFamily(serifRegular, serifBold), - ["monospace"] = new BundledFontFamily(monoRegular, monoBold), - }; - } - - private static SKTypeface LoadBundledTypeface(string fileName) - { - var assembly = typeof(SkiaRenderBackend).Assembly; - var resourceName = string.Concat(FontResourcePrefix, fileName); - - using var stream = assembly.GetManifestResourceStream(resourceName) - ?? throw new InvalidOperationException($"Bundled font resource not found: {resourceName}"); - using var data = SKData.Create(stream) - ?? throw new InvalidOperationException($"Unable to read bundled font resource: {resourceName}"); - - return SKTypeface.FromData(data) - ?? throw new InvalidOperationException($"Unable to load bundled font resource: {resourceName}"); - } - private static void DrawTextWithLetterSpacing(SKCanvas canvas, SKPaint paint, string text, float x, float y, float letterSpacing) { if (letterSpacing <= 0f) @@ -350,24 +265,6 @@ private static void DrawTextWithLetterSpacing(SKCanvas canvas, SKPaint paint, st } } - private static float MeasureTextWidth(SKPaint paint, string text, float letterSpacing) - { - if (letterSpacing <= 0f) - { - return paint.MeasureText(text); - } - - var width = 0f; - - foreach (var character in text) - { - width += paint.MeasureText(character.ToString()) + letterSpacing; - } - - return width > 0f ? width - letterSpacing : 0f; - } - private static SKColor ToSkColor(RenderColor color) => new(color.R, color.G, color.B, color.A); - private readonly record struct BundledFontFamily(SKTypeface Regular, SKTypeface Bold); } \ No newline at end of file diff --git a/src/AngleSharp.Renderer/Skia/SkiaTextMeasurer.cs b/src/AngleSharp.Renderer/Skia/SkiaTextMeasurer.cs new file mode 100644 index 0000000..8688368 --- /dev/null +++ b/src/AngleSharp.Renderer/Skia/SkiaTextMeasurer.cs @@ -0,0 +1,55 @@ +namespace AngleSharp.Renderer.Skia; + +using AngleSharp.Renderer.Rendering; + +using SkiaSharp; + +/// +/// Measures text with the same typefaces and paint settings paints with. +/// +public sealed class SkiaTextMeasurer : ITextMeasurer +{ + // Layout measures once per word, so the paints are cached. They are kept per thread because + // SKPaint is not safe to share across threads, and typeface resolution behind them is already + // process wide. + // A document only ever uses a handful of distinct fonts; the cap is there so a pathological + // document cannot grow the cache without bound. + private const int MaxCachedPaints = 256; + + [ThreadStatic] + private static Dictionary? t_paints; + + /// + public float MeasureWidth(string text, RenderFont font) + { + if (string.IsNullOrEmpty(text)) + { + return 0f; + } + + return SkiaTextShaping.MeasureTextWidth(GetPaint(font), text, font.LetterSpacing); + } + + private static SKPaint GetPaint(RenderFont font) + { + var paints = t_paints ??= []; + + if (!paints.TryGetValue(font, out var paint)) + { + if (paints.Count >= MaxCachedPaints) + { + foreach (var cached in paints.Values) + { + cached.Dispose(); + } + + paints.Clear(); + } + + paint = SkiaTextShaping.CreateTextPaint(font); + paints[font] = paint; + } + + return paint; + } +} diff --git a/src/AngleSharp.Renderer/Skia/SkiaTextShaping.cs b/src/AngleSharp.Renderer/Skia/SkiaTextShaping.cs new file mode 100644 index 0000000..e5f368c --- /dev/null +++ b/src/AngleSharp.Renderer/Skia/SkiaTextShaping.cs @@ -0,0 +1,287 @@ +namespace AngleSharp.Renderer.Skia; + +using System.Collections.Concurrent; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; + +using AngleSharp.Renderer.Rendering; + +using SkiaSharp; + +/// +/// Shared text configuration for the Skia backend. +/// +/// +/// Measuring and painting have to resolve the same typeface and apply the same paint settings, +/// otherwise layout is computed against a font that never reaches the canvas. Both paths go +/// through this type so they cannot drift apart. +/// +internal static class SkiaTextShaping +{ + private const string FontResourcePrefix = "AngleSharp.Renderer.Resources.Fonts."; + + private static readonly Lazy> BundledFonts = + new(CreateBundledFonts, LazyThreadSafetyMode.ExecutionAndPublication); + + private static readonly IReadOnlyDictionary GenericFontMappings = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["serif"] = "serif", + ["sans-serif"] = "sans-serif", + ["monospace"] = "monospace", + ["cursive"] = "sans-serif", + ["fantasy"] = "serif", + ["dejavu serif"] = "serif", + ["dejavu sans"] = "sans-serif", + ["dejavu sans mono"] = "monospace", + ["system-ui"] = "sans-serif", + ["ui-sans-serif"] = "sans-serif", + ["ui-serif"] = "serif", + ["ui-monospace"] = "monospace", + }; + + // CSS family names are case insensitive, but Skia's family lookup is not on every platform: + // the Linux font manager matches case sensitively, so "arial" resolves on Windows and fails + // on Linux. Indexing the installed families once gives the same answer everywhere. + private static readonly Lazy> SystemFontFamilies = + new(CreateSystemFontFamilyIndex, LazyThreadSafetyMode.ExecutionAndPublication); + + private static readonly ConcurrentDictionary ResolvedSystemTypefaces = new(); + + // Decoding a font file is expensive and a face outlives a single render, so the typeface is + // kept alongside the face itself rather than rebuilt per paint. + private static readonly ConcurrentDictionary EmbeddedTypefaces = new(ReferenceEqualityComparer.Instance); + + public static SKFontStyle CreateFontStyle(float fontWeight, bool isItalic) => + new(fontWeight >= 600f ? SKFontStyleWeight.Bold : SKFontStyleWeight.Normal, + SKFontStyleWidth.Normal, + isItalic ? SKFontStyleSlant.Italic : SKFontStyleSlant.Upright); + + /// + /// Creates the paint used for both measuring and drawing a text run. The caller assigns the + /// color; everything that influences advance widths is set here. + /// + public static SKPaint CreateTextPaint(RenderFont font) + { + var typeface = CreateTypeface( + font.FontFamily, + CreateFontStyle(font.FontWeight, font.IsItalic), + font.Faces ?? FontFaceSet.Empty, + font.FontWeight); + + return new SKPaint + { + IsAntialias = true, + SubpixelText = false, + LcdRenderText = false, + HintingLevel = SKPaintHinting.Normal, + TextSize = font.FontSize, + Typeface = typeface, + // Only slant synthetically when the resolved face is upright. A real italic face is + // already slanted, and skewing it again doubles the angle. + TextSkewX = font.IsItalic && typeface.FontSlant == SKFontStyleSlant.Upright ? -0.25f : 0f, + }; + } + + /// + /// Resolves a CSS font-family list to a typeface, honouring the declared fallback order. + /// + public static SKTypeface CreateTypeface(string fontFamily, SKFontStyle fontStyle) => + CreateTypeface(fontFamily, fontStyle, FontFaceSet.Empty, fontStyle.Weight); + + /// + /// Resolves a CSS font-family list to a typeface, honouring the declared fallback order and + /// any @font-face declarations in scope. + /// + public static SKTypeface CreateTypeface(string fontFamily, SKFontStyle fontStyle, FontFaceSet faces, float requestedWeight) + { + var isBold = fontStyle.Weight >= (int)SKFontStyleWeight.Bold; + var isItalic = fontStyle.Slant != SKFontStyleSlant.Upright; + + var families = fontFamily.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + foreach (var family in families) + { + var normalized = family.Trim('\'', '"', ' '); + + if (string.IsNullOrWhiteSpace(normalized)) + { + continue; + } + + // Generic names are keywords, so they always mean the bundled fonts and cannot be + // taken over by an @font-face declaration. + if (GenericFontMappings.TryGetValue(normalized, out var bundledFamilyKey) && + BundledFonts.Value.TryGetValue(bundledFamilyKey, out var bundledFamily)) + { + return isBold ? bundledFamily.Bold : bundledFamily.Regular; + } + + // A declared face takes precedence over an installed font of the same name. + if (!faces.IsEmpty && + faces.TryMatch(normalized, requestedWeight, isItalic, out var face) && + TryResolveFaceTypeface(face, fontStyle, out var faceTypeface)) + { + return faceTypeface; + } + + if (TryResolveSystemTypeface(normalized, fontStyle, out var typeface)) + { + return typeface; + } + } + + return GetDefaultTypeface(isBold); + } + + /// + /// Walks the sources of a face in declaration order and returns the first usable one. + /// + private static bool TryResolveFaceTypeface(FontFace face, SKFontStyle fontStyle, out SKTypeface typeface) + { + foreach (var source in face.Sources) + { + if (source.LocalFamily is not null) + { + if (TryResolveSystemTypeface(source.LocalFamily, fontStyle, out typeface)) + { + return true; + } + + continue; + } + + if (source.Data is not null && TryDecodeTypeface(source.Data) is { } decoded) + { + typeface = decoded; + return true; + } + } + + typeface = null!; + return false; + } + + private static SKTypeface? TryDecodeTypeface(byte[] fontData) => + EmbeddedTypefaces.GetOrAdd(fontData, static bytes => + { + using var data = SKData.CreateCopy(bytes); + return SKTypeface.FromData(data); + }); + + /// + /// Resolves an installed family, or reports that it is unavailable so the caller can move on + /// to the next entry of the fallback list. + /// + /// + /// cannot be used for this: it + /// substitutes the platform default for an unknown family instead of returning + /// , which silently swallows the rest of the fallback list and makes the + /// result depend on whichever font the host happens to default to. + /// + private static bool TryResolveSystemTypeface(string family, SKFontStyle fontStyle, out SKTypeface typeface) + { + if (!SystemFontFamilies.Value.TryGetValue(family, out var canonicalFamily)) + { + typeface = null!; + return false; + } + + var key = new SystemTypefaceKey(canonicalFamily, fontStyle.Weight, fontStyle.Width, fontStyle.Slant); + var resolved = ResolvedSystemTypefaces.GetOrAdd(key, static k => + { + var match = SKFontManager.Default.MatchFamily(k.Family, new SKFontStyle(k.Weight, k.Width, k.Slant)); + + // Some platforms substitute rather than return null, so confirm what came back really + // is the requested family before accepting it. + return match is not null && string.Equals(match.FamilyName, k.Family, StringComparison.OrdinalIgnoreCase) + ? match + : null; + }); + + typeface = resolved!; + return resolved is not null; + } + + private static SKTypeface GetDefaultTypeface(bool isBold) + { + if (BundledFonts.Value.TryGetValue("sans-serif", out var defaultFamily)) + { + return isBold ? defaultFamily.Bold : defaultFamily.Regular; + } + + return SKTypeface.Default; + } + + private static IReadOnlyDictionary CreateSystemFontFamilyIndex() + { + var index = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var family in SKFontManager.Default.FontFamilies) + { + if (!string.IsNullOrWhiteSpace(family)) + { + index[family] = family; + } + } + + return index; + } + + /// + /// Measures the advance width, mirroring how the backend lays glyphs out when letter + /// spacing is in play. + /// + public static float MeasureTextWidth(SKPaint paint, string text, float letterSpacing) + { + if (letterSpacing <= 0f) + { + return paint.MeasureText(text); + } + + var width = 0f; + + foreach (var character in text) + { + width += paint.MeasureText(character.ToString()) + letterSpacing; + } + + return width > 0f ? width - letterSpacing : 0f; + } + + private static IReadOnlyDictionary CreateBundledFonts() + { + var sansRegular = LoadBundledTypeface("DejaVuSans.ttf"); + var sansBold = LoadBundledTypeface("DejaVuSans-Bold.ttf"); + var serifRegular = LoadBundledTypeface("DejaVuSerif.ttf"); + var serifBold = LoadBundledTypeface("DejaVuSerif-Bold.ttf"); + var monoRegular = LoadBundledTypeface("DejaVuSansMono.ttf"); + var monoBold = LoadBundledTypeface("DejaVuSansMono-Bold.ttf"); + + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["sans-serif"] = new BundledFontFamily(sansRegular, sansBold), + ["serif"] = new BundledFontFamily(serifRegular, serifBold), + ["monospace"] = new BundledFontFamily(monoRegular, monoBold), + }; + } + + private static SKTypeface LoadBundledTypeface(string fileName) + { + var assembly = typeof(SkiaTextShaping).Assembly; + var resourceName = string.Concat(FontResourcePrefix, fileName); + + using var stream = assembly.GetManifestResourceStream(resourceName) + ?? throw new InvalidOperationException($"Bundled font resource not found: {resourceName}"); + using var data = SKData.Create(stream) + ?? throw new InvalidOperationException($"Unable to read bundled font resource: {resourceName}"); + + return SKTypeface.FromData(data) + ?? throw new InvalidOperationException($"Unable to load bundled font resource: {resourceName}"); + } + + private readonly record struct BundledFontFamily(SKTypeface Regular, SKTypeface Bold); + + private readonly record struct SystemTypefaceKey(string Family, int Weight, int Width, SKFontStyleSlant Slant); +} From 15a065c616f563bc00a46a2ef03a843779d066ab Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:58:24 +0000 Subject: [PATCH 18/24] Regenerate visual snapshot baselines for all platforms --- ...ed-wrapped-text-with-line-height.macos.png | Bin 3642 -> 3621 bytes ...-wrapped-text-with-line-height.windows.png | Bin 2479 -> 2504 bytes .../renders-simple-table-layout.macos.png | Bin 1745 -> 1772 bytes .../renders-simple-table-layout.windows.png | Bin 1218 -> 1291 bytes ...rs-table-cell-borders-and-widths.macos.png | Bin 2146 -> 2150 bytes ...-table-cell-borders-and-widths.windows.png | Bin 1394 -> 1398 bytes ...s-table-with-colspan-and-rowspan.macos.png | Bin 3920 -> 4116 bytes ...table-with-colspan-and-rowspan.windows.png | Bin 2300 -> 2369 bytes 8 files changed, 0 insertions(+), 0 deletions(-) diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/aligned-wrapped-text-with-line-height.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/aligned-wrapped-text-with-line-height.macos.png index 271dac6b2a3289f309136d6da88a01ef532de254..cf76e2a3f380f7e3ae7476dbdbc8aa1be787ad57 100644 GIT binary patch delta 3150 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 b6c+s-tI#d?;lS&z00000NkvXXu0mjf?p>MO diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/aligned-wrapped-text-with-line-height.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/aligned-wrapped-text-with-line-height.windows.png index 3445f8f9ae2426c1c2bf628e0c91eadc0eddbe42..70ba070e6cd09eae12b0f6ca17bfe5a1518a1c27 100644 GIT binary patch literal 2504 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 Y59def=7!G1d;kCd07*qoM6N<$f*y4c0{{R3 delta 1485 zcmV;;1v2{V4bcsdG=GRmL_t(|obBCTNYnow$ML6!T~st$n1rQdrGZdnU?F0b5EN8U zR2pg&7!gQB7m0&zx~TQXxJt5`( z<@5lN|0j{oD1S*P=tGAN;qiDlcI;TP_c-R#rAv4`p5MMoN=oqge1yYcr{_t6GJuJA zZ>Jm_93($KKl*)NUmtF_I|b8{Bi7qTFJ8RJnKNfNa^wihmMtR^iSYF4Q#w04al75T zd-sma%uELs|L;5=Pujg7dfHp)`}gkyP+eUOKwDc|a(@>&tg^C_`uchr8yk7?;sv{Q z?IIG15DJByo+b&HHfX=!PgYhIKA#VO+qZ8gebh`+T3QOg$jFGpOC%9-gZ}X016^HR zR8>{6V8H?o95}%1*RSd6=}FZ-$Nc#5gRZVF0)YSk8#iurc#$L`ZqUJC5P-dV_X5A} zjsaR)T7Qx}%Q?Gu?<>gwXwty^SgXFEMj5)d=!@$qp2fdKjW`4klu z0l)5kv$C=X1Ooj0`7>oBQ)%D6eVjOP0Fn&}&6_tS?}n?Zt0^unj(>jN*49Q%O$|40+~D--(+)0^M8pib zxw#pD_V#w#+uQ%R{O;YmCWHRF&6_s^@bKY71w9K)`H*8^V1Tx^HcCrN>F@9V<7@5O zwKO+3^ZonxRPRl>SFc_Huw=;+hnGkqro4r2ZEXd9-Tls)Gv|-XYinzH|NcFX9zBXa zKYwjSOG^u(P>AvIaYCUGPo6vh(5>?acXIONNt&CR%>mzPstU(fpW>z$e=|AlE^`nD$BPEOKG-y*Y^f)-O0w3y-oqVHEo z7wL9=(}@e1X9PJ!W;v10A%7*|pvT6>2#3Rb{rWYTdqn<` zX@kCZ?;gd)#bjq^vth#qR<2x0Zf-8;&!0E-CZDN}(=940;^@(%w6?aIJy^$@nCEf; z96x?MdRXJBQ>Q2_ETq4`pO-IR;`8~)%gdvxsw!1mlWOu^X_Lq44h{|y4u=T_g9L*? znwpwuZGUZL-n@AZFO_&=1|158@caGb=jZd_!2@z~a-x^j)zxwR`gL5cxc?qzrOL|6 z-%d{I>FJ@au8xk54lZB5OhZG1gX?5EF&}bVxNrf0bLYjg5_r zj(?8+ad{{dqNAfDdCO*q;o)H#8XEZWP)_<*AlRhw2{(hXU+wG>Hpnz@Lwoy}46Ftda zX10V6ni&}xp|7uxg$oz5c=6(B0JDt_nz1e6*RJQ!pHE?7VKTRf{9}4Qw3wox#S|C7 zrcIli-jP!2c74-{>*mdyY4zB+ySqDWu5WjDx6>0+E@L9yehn7W^cY%9QP5(Ff|I2N nF_WMMHj|(RHj|(R6c*(#2FKsYPVzn200000NkvXXu0mjfdK&7t diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-simple-table-layout.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-simple-table-layout.windows.png index 643de2ef035726db601926d9ef7d89460fa5bcb4..62863e33c844d7cf73a398a477c265efd9d812a3 100644 GIT binary patch literal 1291 zcmeAS@N?(olHy`uVBq!ia0vp^TY$KNgAGVVKfm!8NU;<#ziSmTvf|>Kc}P-; zrE7L;6EEMcF0ZyVmzrell;-F9`Pk3;JnMIiy}&}p3a53)CWn9Cq43gi!`BZ1Yud{L zC$$|;d|_F%arf@k=K1$Bs;jNPXzc7*r16Dg=Wq2d3glFmf9qa+ zG+3J6{O)Vjq$Op$*P8fpe|?abm#5X2Ja_*5b~)DIDzg`U%NMJ7hO7+}J-%_*-8{Ks z#{Z{}EMRYLoOO0tSa`Uqr?j+m|8#lRf|8OYCcalvzL6? zdHwausI})Rw$v=m{B=8en%niOGvtNmFYNE{pQ_U7wRDoo$`CD0PoYw)xiM|0FYgd< z{r&rQ*P<66OIhOfomuev@4ibWSAYHbHMu@MK0bW&O`Ri!VM~JoS6{uf+<(4Pf0Jfv zTv1QlpR2FGZrnb9&YX&#!>MXsJ!*4Es%?t1Iit^4x#clq7>_kp2p zd`Pq1(9g2L(w&G1e|+tXm2UjZEq>ykQ-O&btFNxo)7Ss{_V)If{1TU5)J;2`x^~?< zv80XX_MEP1dsnvGYm!RwR7YUk9J&45duh-@vDasIpIKmE|L@NZx9u<7x=Xt@oX@|P z$~Je|%4yT5f1dO7=zE_HaNzWr9K@A2`Y?<;m1HLtsxm1Q=2X8PIYg9l#8Se;HV2mr>`v14v80u5TvEYO~M z>M?&n-QVWfKtDb$s(g01)oj;%&q)Prw(4sCE-uL0y6R!U3XQHUTep6FVz%9MbL8ui z?oFGI96hqY{f_*VxpQUTzIoFk$6U{NybPL?HYvPJOifk&BC~VHzJ1p&T?$G(H+g^E z->4m_z1{Ec@4tTQRunKx78VyT4qqQ9`b_-0zMh^_|AgJG?Th}3x0$Y6nAo~tu%oBy Y4cF@C%S$b)1eR_Lp00i_>zopr0Qw=6Hvj+t literal 1218 zcmeAS@N?(olHy`uVBq!ia0vp^TY$KNgAGVVKfm!8NU;<Eakt zG3V{wjagci5^W#NyTuJ!k4SIen0Ad(QLK4t>jZC(V$MvJR^ML^0=t)p=&VWMVpUw? zy~;(Wh?!IDp5wNN5_9R}CpMisQa(?jFINz+cJ zUMpUr#?f>qCSQOhvVX!Z`%Q8TKkNlf)(8+nZ$!SFJ=@!)5U~IL{qHt2BV!*gTYofZ z;@65B7yTT!-_G^ClwfeD5|OzQfnTUmQ$WpwShgBc|$kG$}ApB-2D{sZq==v%Q*ocI^rhV0ryr=*L~5=6Pq+ zx*kXLx;;L9-&~5fbobpmGO3|jqF<|Q@7}wYX3JGJq2lz$s?XPR%*^fVX8M%W)$Nw%>kLVs-x?XPn-2Amdy5rT<_4{hRldA|+{yG(C@`jJol1-#Ed7MMFruNAW2|GDhiEW9AZ>bYzG{_|CRAy3~{eO{vA z>F@6!d4A%mJ+b-o3Qi^V1oJ&OKrnKFF}qB3b6$7&wzZd!KD;6IrNk;!faTTKs`zUX zJnhb=vwe53Fwgaq;%nz>a+p4SdSrjQtcu>OnP=0o%w|{B)cpDO@_p3WW!tx#@7=fW zUG0H2Yu9a!(OVt0_F3A_a|^v{TIKqWcP-K=oa(qS;>=n1-`z)(-c5Tgv+BqKc{#aj z*(D#p7tP)C=8JVmr{nR$H9;$dA)6u;1;KvUOppa|Q zh2_haSJl<|6;F%LwUN{2IsElg`5(Vd$Lp_4fw6aF^RuH#iRFBAQ#MAdxSO~7Y8Ei0 z>n2E)EDehM&+zpA{(@qwxvOII-q-H9pHR9sP^6HpmSNk!OACNrcv`elfup3b@Z(d% z*wdUX`Zs_=btv_*RAKD u-z=wC>OQ}+TAC;Oj`LwI$H9(H%@;bAuaXmZ_yw>~V(@hJb6Mw<&;$Tm<~88} diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-cell-borders-and-widths.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-cell-borders-and-widths.macos.png index 5af5c739c5d273b54f7e8c22ef46fa1610ce49bb..2bffbca6d2cc782b3d693e153dddf35843c94e22 100644 GIT binary patch delta 1786 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 T_&hM@00000NkvXXu0mjf%6zU2 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-cell-borders-and-widths.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-cell-borders-and-widths.windows.png index 075c9d779c16727136facd103486cbbe8f19bd17..224514dc8c5055ab439d6d0389275f8624d77379 100644 GIT binary patch delta 1046 zcmV+x1nK+o3ib++G=D8gL_t(|obBDwYg1d%F9r%Ucz z>0Af>JRx}YBV2Z6Sp%sS$MUG@?%rzNZt%j%aRnCV!o<{PvA)-m1CTnSyOkI{GXw%oogSc4b+| zj~>?XqlZi0cCxoSzwhJwOlC$yJKwdSA*dV5t}JV3OOu9%N7UCpC`r0`?TWfO+I8v1 zJ+-v9sjq)fZ+E_bTd+-f^dh)*{c7lbvoAlmuRZ(rKXt#wg?V*#v}^kOW$oJCDd|yA zFTMOq$^DbP-TE$bO3}tx{(0H@!i(zbAIv{DVVv(;5ZDp!$CKYEkxJ*EP)XXgyHi8M zBRV@h5!Bs0GZq)-b>i3&b$@v9sr#Oto=_r{&c`N66K5`eYHEC}Xy3Wn86ElRgrbcx zNm8^irjz5p=+^bCfsGxN3oN*)@iD#j#?GScueQHl@`VvLkDtOdMJp#bkVjw`}XJ099G4ewiKPZqB;Uy!wsYk6|Ldo2lh&ObjlfKYu}|D8Xi&nz}|v2 zZYitt7Hisn^1sIr_UE7$1h$5gRItWCd42tZy8qAL8u@yt>{@E|)NuLXzpkNS0|j=3 zOQh1e^2hHLT{pIQYf!_eD?Ra~=4NMf^V*fN`z2Co-Mw?WXnUon>S3MlS`b)ppACGW zsqryQW=0FPufBaxn0SH8nm~5a#ithfA*E)+4O*T?^J7 z+}cI|+Ru2RjWI1{bGmi?YQg27_kW@lMkNZIHH+h9Yy*b0@>JCV7y*d!VNq@`Pa6RZCwL~M#Uxiwt zk!laY?zw6Y00h~NMtf&+{Q4lp7(z=+@gBZ32r2o5kJIKYVD03(6} zj0g@eA~?WkQ^8GT#+2;q3G7ZUT{x?jXe9shteeV=6+GVcpGz0cmfW|}xeoeyLh$TI zxa{hR22)2gbAR!wma{o6XLF%=q3k*QX zsj;cqeb+UmwXI#lBcrn{b{`v-`+TIqE8OLt5YYAX?|`tf1R-W_Kh`f*ZkaU!L}zIeSek~7YgPzySk#2$B*jd@uMYg zJK5Kh-}mu-rZQupo$p%E5Y!E2S68&VwMoMxqv{_Rk|f=}aZTNw9lCPszFOPb)ju$# zx4Pab*e0#L2=3gx9=hM`D-R!N-+_Zq-EV1eLEW7lnz?vYd-iroS_|sMmtHQpf3mMf z-)7Dz+J6|!KQG&#e?k2NL;2?>jPqR!0z1O}aQbT{QtA8?DoK0xc4>HIROe?VgSwk% z#?s<~PMtWWo(~Q`b>H(dlS-u0`Pd|B^4w)jPmCAsJ3lw8V_%+9v@s@0iZ;e{dg5o@ zxp_UXv7>T<1vfo0u2)~*U9|m`j@L@QFv8~XQ-8RYXe58cdhf%Jo_gIU&21u;F50&5 zz`^{P!@5}0wxUy4R7ap|xWUwsqBUIn(0)m4r<`H7@m-YYddvKQN>R|NO1duZGL6rPfakS04TA8XC4xU`Mz_Dy?h3|5nj; zV}H%tgBnI%>5V5fKR2t}H?Ec4FOf>?-k*1iwpV(p9@hD;1%UEP#@o*36uW~^ZQ z`rFs#gmsP4+SaZ_Dy=g=ephzC-gr{e6XOM8o;-fE!aCozVAH{ET=Z}Jj3?R{ z({eVaJ2$TvT>ja>$NJ)%@5|oyp1oa4q<_+({9I`LC|B+;PFmaAb>-H5#SiVz&rxqY zS@C^_dA_SHA}Pn0QB(Lu;zFqK9Og(9dMnd=PIsCUko!EFz>%07*qoM6N<$ Ef(UCa&Hw-a diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-with-colspan-and-rowspan.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-with-colspan-and-rowspan.macos.png index 592bce95f0fd0e21b02621651ee5ba83f4f8b2a8..57c46c493b5795b3c3c8a6bd1c0e131c99fb209d 100644 GIT binary patch literal 4116 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 delta 3639 zcmY+HcQ~7E+rWdOs!ErZ7`2){HDbmVMb%a_R&CYVL~UZ$W7bw9)G9iJnjuzEn`*@t zBSb~)T8;6g$MJpN@xIqz_aEnR-q$$K-}SpP?tSMo;sT)98CF8oqXQ%f3 zO+eUiJ?Y+Ki(&&+z85RA(QP!mL#?;G-z2mqp|f|MHV~}CSytoH7n!-zXPMa%eJ(Ud z*z7^GT9+7fHji4If~1#s%}=kIM!So)73xt=#r*h$R;F#;z+V1@;FJ^Yq34~afdgx| zizT_(n@;t=vmR>B&?|zXAM~xnX7n)N3lZlp>uR)SU}5H$Gj(n>89|Z!3PKq{xPm6R zsT&-Nqy8=a#DfztCvMcymYyQQQ-6*_aK{(Pp2*Jr$j{U?Vi!`CP+qoBO9}r`^okMU zY2~?tfiK&y-<^sti8W$iWGc#9yP0@bp~DQ?%);#GOrkPk=6a{gXafj33gz@?*3S|? z2O}eAzJv`n9yhwsbQ~>g^2!UQ^YHO{`}*3iCA$lmH-+<6(oZ+Lf)Nc3f$fY9{BA>x zsrsSz&Y>NviM~OvHm)kyvVt=LaCH*p)9D#~+MB1lpA0jvI}}$EO@^mh9{sCwTi%P) zLKR5J|Z6 zu;WFWV=iPc&(lyx5`f5S*!=B!yKJ+xVZ^ke3K|9J@8u$ zIsz&u<>G?e+19g)eW6H&1G92KzBzAuN#G&GzBN8CGOca zQZ&!+rh>Mo7r|WI%RQF*=_;QVqyhG7w~}G*7%dOumFZ~z?k(AVp;tDLXOVbs+ zNFwPlUYHtJk%~9=$@Q*)(sCQ>-^`p;7+1x(+S=Mj`vliz7^Zzn@hWM*ht3})Z z8+h18(i&kaxh0nVVzw>ZnL` z)mTbKQvzjMLz--_XFh}xdXmBWt6zE~cU|?OoY}tw;)7b-Jpd30L@g<=&$8sTxTr#6 z5nr#DEML^GK0CT$!DzIiq}K_E^@@Dw(UY%b(9O%ygEQO>d)ag5Vg>O@RsF69y#kc;hO^h#l89~2nOKq3B$R- zRkb)#5K!6m!wfa-OD#G-m2w&)0j8D3{#l|n>OpcApR+z#TQhSAf7|YpPre`=N(@u(LBF=;F873`XT`6gEuZ z$z@mmh?^=r4EZcqG#8_v_Fc1G|EfRSdhH{e*zN7HO0XA%v8gdkQIQr3?YCZ;Aq8Py z#;D&+&*laH^ELkMZ#gS-R#~A)XJG#;8Et!}$)y#=|IMU94T@H|A;XjIMSC^<9Wy7w zoZ6pKuB>5lg>MKWgsu4&f>~tGiN}#%)M0P(re)4WCqk$0p`BaVLs=_B|E;+q)eY9V z7siGCVq+na{;UkZ331uyZZ!$w773?kei!)6ML!{%=6`|G9=CqA3)u0Ajit`Dg|?{e ze|lpxd|WZcyqR5X_bg{_bmG0SK&&?6;lU!6VcLB z-qGYc>DOXXkYwB$U3Ku|y~6!PTAUq~buv|a8^#b)L zHj|9xkjQ%bOXD6Yz}_nyTExS7?6b}buy&bDTLd8FXzQgW(uDL(z~E==pHD3UPL#;U zs*yCO2k}{-%YfKwdK^%7vlo}Et~HGBAM%U%)o4v{6Ad#$tU7K2;6T{{slF|j_FP7>UsgJfgnQKhq?78yHHG_^~S$9!{tun_{c)cVi% zuX@Bz|5xg4K=eUMhj|6NRI6-Z*;02+iB-!V;1*ZGc$Lu|5C{YZ>t;k=8i|yZl^yFw zs(_zBb~py?D>rQMj*&25NEYLSkQO@3qa>>LomZ+ea~n(yO4XBm=>@A5a7Lb$+jS#qEVF;Tw|&gLSGf$0B0V{0MbhC&fhHJLO?VUEk>5-n^sT+hNL~n6EXd2VN%HYTIf< zDGo<(+y9mJQp4{qY-d(-#Ag}d`n9XP5k@N%v{PaS(njw978i)9r=+R2%#_2=vhY97 z_w@_=Q&JtKnnVh|F%f@Vu_jEe(_LPli%A)IcKO=6m1D3b;v1cYJUmxXGY&I8QaG2- z@mIKvK>93PtOLgt8tP0*@6LZRtWW z6Z4L%Sij53%ex|x$olZDn&Uy#!Jmo!N)&BfB^3FyUicXs<+GK{^u7M^RkkYHx}4nE zpxHZEYeZU2mkoC*ZQU3iM4Xv$n|W^ux8EzG4h5g*8m)|aCdW!)vR1+Qaj%#*dMX-m z&q>k!b?(`)5^@Q;RwmDnE_?y?48OALpfKB)O_XvWHn_r3+qhD-s5OWqN$*)RvF*@( znO>P9cI#M)KB$n=amn1~=h79^wfhq#{!m2gY$nLf?8)||wcVI=q3BBsq3kUa{!m}3EhIDTQ?^AP`6x zVqta*h{M1c&wmiOihpkn0s1Y*Mv9B6*G=O7UOPl%bZO-KfZ;bY;%m1D zWyOdEIQw@oIxQC%>SDAx2g0Wo6+@vZ2#R}!%rM|)Znd(jMv*0TF21rG3}nQ#$71qm z)pW+_MvPoD-y_ja_I`VMKFPVvKgOly662Q3VBMx|4=uZY0bM%Ad+6jD->0(wb;$mR zk(^=MB5&tRhgtR%eCsgToa?hj%h+GSNqJBQ{rb_QdentRo*4e$Tnk(=Dr!Pi&O$3l zJl7{4VL5n?kP7;0zg&5Y7Rgwt#TQkZcc@%EU(%$hXPCE1 zgwxdauv5uPTpUerOrBNRSpq1+w)NGZ|1zOMW_miV|4kC7R@7JgKiMjS4 zV)=zv*Jk~vqvLV$FRfYiUrm#a8@=vfY&k=X_BObrTSLdrU(Fe+ExOgG@p#%|TOz?@GgZ{`OvH5;XLQQ-TR;Lcdq*R(h#`n7{w6Y-H;XvjNfPk?2?T8FMekM^oc9VKD(Qhg^gPx>46e3iT zPwPbljbzpC+2KebN_iv$s@A*Ax74MW#wBlSLPmQSP)wQg2@te7rV0$Im*G2n!R!af z^dEsP$0CqIjb{8(qTb{h?XmE6d&|4;VuP*7+oOAU-kh)gLmDU5a5(at9ZVC7SB zy^dWs5>Qiv)0YW8j~{-&;${JCZ(Picw8mK|;2_LvQkEI|gtw45K8*|vfUwhi&HJqF z)$)&YXDQ-ee7&lR$c(mF!sVh$E30@`7hx1j>!IJgncdc1IzLjyp%09yxmWV~9v(Z& zgoD-92TIzyeW79gp{ftoCa?RmCIOg~V)2U7oj||n)G|Ost&)em?uM&QO!vd@>891e z_S>#NN(lm36l*(EDj9G~aL%fz&RGBqPyc(Jw(V3)ynHIswtR(EV;I{2%wpY!mYd?R zp5%pPB9?IYN>LqCGB}O(G8thBo}L9%1tddC;Vl*mRMm8zjcTardsoivKW_BP8hPr(fGEB!14>Z9v< zV#*GZ7Y@X0U&B|WW`feOEJ$*mrwy<>ykzL1O_Zhkh6VUU-#UjLxX4^Muuo54{Z(-p zXp*u@5jdEg4pYo+eI>(YV7Sku1ut*EuT!0obz^${ND?`c@8LetVV*&4O|o&ZQ-N&W zHO<-83GY}NJZt_+Z08ZT!O&f2vNbLF+}|w?-~+1$k&w!|^~A{=4S~|D4&R`3LgB*W zfLv%kyT(oo13VMlEPct<04*@A7O{kkcBDHIq@;90EP!W3y@2LdP9VuhHB8*E>s~Gl zOC?Tum`i7vgFxw#jUXx2VSi=jSU#{O%X@1Ve=KrxA$ZCh=#qwudnLewq4btw38n0e zL^@UtqaU%9bmrO4DZpsT^+hgpMTxZ!T=y45j3F{l!@(;&k~hK&!A0(dOM z-yp;aU4A~Rmw+C|*ctdtCsK6zQhG~XSkp4SNTudK;=!F1rD1>Qeu{KEgM|szm2B4CQNddVl3^HYE@cwn6{}@ zTT5vusYF81#WE-*gpyc>2w5z#L}+5Jch391|IYizd(XMg_qpeu`<(N8exL96x#<_3 z&Z#PCDS<#BRm6F?3!rS^&QRO}jC*^UV*%L|bsFKO2wVw@fp&am?)wo|92S9IF1CUql{V@H1A07R)R1p;~dd%@g=S9@Jp2n-*abPd~B!K&nNi&{Et&TN!jmcLJi zrEKk>l?kRq%#Q&m<@+~%Xt6#IR2R#@YbiOm)0>}{mnV%qGtRGp9U(QPVu82NiXqw~ z@;-E5S2sJ`vz$RbJjnsYiT6~e&sAjWpNkKMTZEU`UzYUMuiSYVrd8R!L&TUoT9*(Gy2rpP` znVB{%j*N!3(kn(!g@rALw~AMCsjq(vy#8YNNCTOS_t3T&U_F|PHGES@G5_MMJnrOF zHu{p3=8~DZDw)oDAI%4waab_UuK|r9h`=3*Rtc@??zY&jt71sgTxhKll)0X?0M-U7 z<5Y!)e|zMZWnyyJ5JAT4Y^M0=I;7}~0#&TE^%H+u8Jc>7%Nyf7|9+!YJkITRQ zW*gD$oaYi6_aoPR^*fs&c&^t)QIkzm>k;2&kMS_E3Tu?5e@|7NKJ=xV6JyHN>X?a5 z`Em2X>U*vBKy5LC3Dv$TH{5$9LpB3uMahM>Ct_w?wPEolS8JaR*l_X)-Ev0FPQk%& zXx1F?rJX_Jl!hmSzFF)H;55@_8+T*xzgn*Ncjo~qhG)r_(}vTa_*?V%P8c(3ja(cg zu8-ao{~De(&3>YSJGeP0G12KJqBGn`6=!0kTdS6vPo!%1wgD55IBm6L<&=#oJAQ81 z{?Ftp(wdMy?pwWRLM#3(r60xWiB_Hnc`w%1bg2&bi210fdz6EbF$@w9><}sVD*D6N z&Re}e)yJH~lcCN#Y~*FfxFM0=;a2FL9@d*8s&g)^`ggG>E-3dbtXAveTYuxP4J0=S z1i&9)5aDv=45RgeK6Y$TXEIu{&0};7yJm)B%x!kez!#WGY!nPV%plNhQ}mr)PoM1+ znEM4?e^EcV6ji5W5Ru4F9E8+F<09>|)`xZAYrh9AE-cZU7_3#av>7iKl@dEjLuk-} zr?E1?ZK{oDcS3P(_ZlrJ3Vzr##;3f5+ofKPrO<5l zv^o)e?XDIC=<|T)K4_T8^xw3zx{4*!xIZ2q&AS`(f;&<&ETD#5_M%%qM&+ZBjIQ~f zJ)=rOtqwE?ZHcJ6@QpcGB*S57WnqlBRUzLxJzLaMkZP`y8v0U)xlBOx<8!FPUUOO@ zBxIY>fs_NIm2Xx_L(8YPyBcYxdE>7KB((QV@*B~;m41yB;yh9}rLAz9+Fy1oBt+oC z&tWW+yp~G)feis%JAQv{yblX3z^S`Z1dwjGxbiS6+j~kIoLHJ8Gij0$@XN*XDt8ty z@yxzBV!6G2D2osN+jL?w-x?4z)m0a61SB|^Z5>t#KHK8%k1^g+`-ybUMQ+!4JB^%P z^ShDlCUbmm4=bH1TfF0H|8Jr$!d(&sEYdHXuSOE)X7gKHTixUdcKxm&TqP$M&mh{r*!({h!`G8#6()!)APb%GEV6OQN~OH(Zd-h)@)JH*>pqLJ-NBlug57Zd4AaYI65x2t-yOaNXT6WRObXPc;S}Tetrx9B=2m;4>=f&wkxKu z0(sx-;XQqnT6~q7k9_2%hQ?SK{;-5UUl%E&J7I_|D_h#Unqui6wVjTMYtZ#vr9O#7 zb`i+3aCu;I1NUK{<6|IBi-3cmRPv>?vxYKli0E!#7$!gcSSze;D7Wg_!cHMt!C)}POg zqt$@K<8{3{hmHNT1u9{C03FQ3&Ps!@;&DVj)AQ1AeVafK)Ta%p{|(XgQ}qA)G@10F X1aw~WTHzA#I|D-4Il;?L```Ktm9&J# From f989f4c4907f02d6e9cbc46d2abcd22eb7a5f79b Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Sun, 6 Sep 2026 01:59:38 +0200 Subject: [PATCH 19/24] Updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2325b0f..646f6b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ Released on Monday, September 7 2026. - Improved public API with `GetDomHarness` +- Fixed HTML table rendering (vertical alignment, span, ...) - Added support for gradients - Added rendering images - Added flexbox layout mode From aa9aa93bc6dc7830b3668f6cef0d67d3c4f7315b Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Mon, 7 Sep 2026 12:40:25 +0200 Subject: [PATCH 20/24] Added support for SVG --- AGENTS.md | 7 + CHANGELOG.md | 1 + .../HtmlRendererTests.cs | 73 ++ .../VisualConformanceTests.cs | 640 ++++++++++++++++++ ...dia-rule-inside-inline-svg-style.macos.png | Bin 0 -> 355 bytes .../renders-inline-svg-clip-path.macos.png | Bin 0 -> 1709 bytes ...renders-inline-svg-current-color.macos.png | Bin 0 -> 355 bytes ...-inline-svg-explicit-mask-region.macos.png | Bin 0 -> 336 bytes ...-inline-svg-gaussian-blur-filter.macos.png | Bin 0 -> 2958 bytes ...s-inline-svg-internal-stylesheet.macos.png | Bin 0 -> 396 bytes ...nders-inline-svg-linear-gradient.macos.png | Bin 0 -> 350 bytes .../renders-inline-svg-mask.macos.png | Bin 0 -> 1725 bytes ...rs-inline-svg-path-and-transform.macos.png | Bin 0 -> 1246 bytes .../renders-inline-svg-pattern-fill.macos.png | Bin 0 -> 367 bytes ...rs-inline-svg-percentage-lengths.macos.png | Bin 0 -> 1685 bytes ...-svg-preserve-aspect-ratio-slice.macos.png | Bin 0 -> 334 bytes ...nders-inline-svg-radial-gradient.macos.png | Bin 0 -> 6002 bytes .../renders-inline-svg-shapes.macos.png | Bin 0 -> 1124 bytes .../renders-inline-svg-text.macos.png | Bin 0 -> 711 bytes .../renders-inline-svg-use-element.macos.png | Bin 0 -> 1126 bytes .../renders-nested-svg-viewport.macos.png | Bin 0 -> 1667 bytes .../renders-svg-image-source.macos.png | Bin 0 -> 339 bytes .../renders-symbol-via-use.macos.png | Bin 0 -> 345 bytes src/AngleSharp.Renderer/HtmlRenderer.cs | 95 ++- .../Skia/Svg/SvgColorParsing.cs | 217 ++++++ .../Skia/Svg/SvgElementRenderer.cs | 520 ++++++++++++++ .../Skia/Svg/SvgFilterBuilder.cs | 213 ++++++ .../Skia/Svg/SvgGeometry.cs | 97 +++ .../Skia/Svg/SvgGradientBuilder.cs | 255 +++++++ src/AngleSharp.Renderer/Skia/Svg/SvgLength.cs | 51 ++ .../Skia/Svg/SvgPaintState.cs | 386 +++++++++++ .../Skia/Svg/SvgPathDataParser.cs | 329 +++++++++ .../Skia/Svg/SvgPatternBuilder.cs | 184 +++++ .../Skia/Svg/SvgRenderContext.cs | 70 ++ .../Skia/Svg/SvgSelector.cs | 191 ++++++ .../Skia/Svg/SvgShapeBuilder.cs | 159 +++++ .../Skia/Svg/SvgStyleSheetParser.cs | 155 +++++ .../Skia/Svg/SvgTransformParser.cs | 77 +++ .../Skia/Svg/SvgUrlReference.cs | 46 ++ .../Skia/Svg/SvgViewBoxMapping.cs | 139 ++++ src/AngleSharp.Renderer/Skia/SvgRasterizer.cs | 218 ++++++ 41 files changed, 4117 insertions(+), 6 deletions(-) create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/ignores-media-rule-inside-inline-svg-style.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-clip-path.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-current-color.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-explicit-mask-region.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-gaussian-blur-filter.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-internal-stylesheet.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-linear-gradient.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-mask.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-path-and-transform.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-pattern-fill.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-percentage-lengths.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-preserve-aspect-ratio-slice.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-radial-gradient.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-shapes.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-text.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-use-element.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-nested-svg-viewport.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-svg-image-source.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-symbol-via-use.macos.png create mode 100644 src/AngleSharp.Renderer/Skia/Svg/SvgColorParsing.cs create mode 100644 src/AngleSharp.Renderer/Skia/Svg/SvgElementRenderer.cs create mode 100644 src/AngleSharp.Renderer/Skia/Svg/SvgFilterBuilder.cs create mode 100644 src/AngleSharp.Renderer/Skia/Svg/SvgGeometry.cs create mode 100644 src/AngleSharp.Renderer/Skia/Svg/SvgGradientBuilder.cs create mode 100644 src/AngleSharp.Renderer/Skia/Svg/SvgLength.cs create mode 100644 src/AngleSharp.Renderer/Skia/Svg/SvgPaintState.cs create mode 100644 src/AngleSharp.Renderer/Skia/Svg/SvgPathDataParser.cs create mode 100644 src/AngleSharp.Renderer/Skia/Svg/SvgPatternBuilder.cs create mode 100644 src/AngleSharp.Renderer/Skia/Svg/SvgRenderContext.cs create mode 100644 src/AngleSharp.Renderer/Skia/Svg/SvgSelector.cs create mode 100644 src/AngleSharp.Renderer/Skia/Svg/SvgShapeBuilder.cs create mode 100644 src/AngleSharp.Renderer/Skia/Svg/SvgStyleSheetParser.cs create mode 100644 src/AngleSharp.Renderer/Skia/Svg/SvgTransformParser.cs create mode 100644 src/AngleSharp.Renderer/Skia/Svg/SvgUrlReference.cs create mode 100644 src/AngleSharp.Renderer/Skia/Svg/SvgViewBoxMapping.cs create mode 100644 src/AngleSharp.Renderer/Skia/SvgRasterizer.cs diff --git a/AGENTS.md b/AGENTS.md index fb946aa..d256732 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,7 @@ The renderer is intentionally split into a small number of layers: - `ITextMeasurer` is the seam between layout and rasterization. Line breaking, text alignment and table column widths all go through it; a renderer built with a custom measurer lays out against that measurer. Never reintroduce a font-independent width heuristic here - it silently decouples layout from what is drawn. - `HtmlRenderOptions` holds viewport and text defaults. - `AngleSharp.Css` provides the render tree and computed-style data used by the renderer. +- `SvgRasterizer` (in `Skia/`) turns SVG content into PNG bytes without a second SVG parser: it walks the DOM AngleSharp already produced and paints directly with SkiaSharp (`Skia/Svg/`: `SvgElementRenderer` walks the tree, `SvgPathDataParser`/`SvgTransformParser`/`SvgColorParsing` decode the `d`/`transform`/paint attributes, `SvgPaintState` carries inherited fill/stroke/opacity down the tree). The PNG it produces flows into an ordinary `RenderedImage` afterward, so `DrawImageCommand` and `SkiaRenderBackend.DrawImage` need no SVG-specific handling. Font handling resolves each entry of a `font-family` list in order, and the first usable one wins: @@ -53,6 +54,12 @@ Table spans: a cell covers the columns and rows it spans, and a spanning cell's 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. +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. + +Supported elements: `rect`/`circle`/`ellipse`/`line`/`polyline`/`polygon`/`path` (full path-data grammar including elliptical arcs, via `SKPath.ArcTo`'s SVG-shaped overload; every geometry attribute - `x`/`y`/`width`/`height`/`cx`/`cy`/`r`/`rx`/`ry`/`stroke-width` - accepts percentages, resolved against the current `SvgViewport` via `SvgLength`, per axis for axis-specific properties and against the viewport diagonal for the rest), `g`/`a` grouping with `transform`, `use` (cycle-guarded in `SvgRenderContext.ActiveUseReferences`; referencing a `symbol` or nested `svg` establishes a new viewport sized from the `use`'s own `width`/`height`, not just a translated copy), a nested `` establishing its own sub-viewport (`x`/`y`/`width`/`height`/`viewBox`/`preserveAspectRatio`, clipped to its own box), `text`/`tspan` (via `SkiaTextShaping.CreateTypeface`, the same font-resolution path HTML text uses), `clipPath` (shape-union clipping via `SKCanvas.ClipPath`), `mask` (luminance masking via `SKCanvas.SaveLayer` + `SKColorFilter.CreateLumaColor()` + `SKBlendMode.DstIn`, honouring its own `maskUnits`/`x`/`y`/`width`/`height` region - default `-10%/-10%/120%/120%` of the masked element's bounding box, computed by `SvgGeometry.ComputeBounds` - and `maskContentUnits="objectBoundingBox"`), `linearGradient`/`radialGradient`/`pattern` as `fill`/`stroke` paint servers (`SvgGradientBuilder`/`SvgPatternBuilder`; `objectBoundingBox`/`userSpaceOnUse`, `gradientTransform`/`patternTransform`, `spreadMethod`, `href`/`xlink:href` inheritance chains; a pattern's own content is rendered once into a tile bitmap via the same `SvgElementRenderer.Render` entry point the root `` uses, then repeated with `SKShader.CreateImage`), `filter` (`SvgFilterBuilder`: a primitive-chain interpreter for `feGaussianBlur`/`feOffset`/`feMerge`/`feColorMatrix`/`feDropShadow`, applied via `SKCanvas.SaveLayer` with an `SKImageFilter`; an unsupported primitive passes its input through unchanged instead of breaking the chain), `currentColor` (resolves against the CSS `color` property, itself inherited like any other paint property - including when set directly on the root ``, which `SvgElementRenderer.Render` resolves before walking children, since nothing else ever visits the root itself), `preserveAspectRatio` (`meet`/`slice` and all nine alignment keywords - `SvgViewBoxMapping`, shared by the root ``, nested ``, and `symbol`), and SVG-internal ` + + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 240, + ViewPortHeight = 160, + FontSize = 16f, + }); + + var imageCommand = Assert.Single(displayList.Commands.OfType()); + Assert.Equal(40f, imageCommand.Rect.Width); + Assert.Equal(40f, imageCommand.Rect.Height); + Assert.NotEmpty(imageCommand.Image.Data); + Assert.Equal("image/png", imageCommand.Image.MimeType); + } + + [Fact] + public async Task BuildDisplayList_PaintsInlineSvgAsReplacedElement() + { + var document = await ParseAsync(""" + + + + + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 240, + ViewPortHeight = 160, + FontSize = 16f, + }); + + var imageCommand = Assert.Single(displayList.Commands.OfType()); + Assert.Equal(10f, imageCommand.Rect.Width); + Assert.Equal(10f, imageCommand.Rect.Height); + Assert.NotEmpty(imageCommand.Image.Data); + } + + [Fact] + public async Task BuildDisplayList_IgnoresInlineSvgTitleTextContent() + { + var document = await ParseAsync(""" + + + This must not be painted as page text + + + + """); + + var renderer = new HtmlRenderer(); + var displayList = renderer.BuildDisplayList(document, new DefaultRenderDevice + { + ViewPortWidth = 240, + ViewPortHeight = 160, + FontSize = 16f, + }); + + Assert.Single(displayList.Commands.OfType()); + Assert.DoesNotContain(displayList.Commands.OfType(), command => command.Text.Contains("must not be painted")); + } + [Fact] public async Task BuildDisplayList_ParsesLinearGradientBackgrounds() { diff --git a/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs b/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs index ceec361..71701a2 100644 --- a/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs +++ b/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs @@ -512,6 +512,646 @@ Indented text that wraps to a second line. maxDifferentPixels: 0); } + [Fact] + public async Task RenderToPng_RendersSvgImageSource() + { + var document = await ParseAsync(""" + + + + + + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-svg-image-source.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersInlineSvgShapes() + { + var document = await ParseAsync(""" + + + + + + + + + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-inline-svg-shapes.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersInlineSvgPathAndTransform() + { + var document = await ParseAsync(""" + + + + + + + + + + + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-inline-svg-path-and-transform.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersInlineSvgLinearGradient() + { + var document = await ParseAsync(""" + + + + + + + + + + + + + + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-inline-svg-linear-gradient.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersInlineSvgRadialGradient() + { + var document = await ParseAsync(""" + + + + + + + + + + + + + + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-inline-svg-radial-gradient.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersInlineSvgUseElement() + { + var document = await ParseAsync(""" + + + + + + + + + + + + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 50, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-inline-svg-use-element.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersInlineSvgTextWithTspanAndAnchor() + { + var document = await ParseAsync(""" + + + + + + + Hi! + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 160, + ViewPortHeight = 60, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-inline-svg-text.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersInlineSvgClipPath() + { + var document = await ParseAsync(""" + + + + + + + + + + + + + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-inline-svg-clip-path.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersInlineSvgMask() + { + var document = await ParseAsync(""" + + + + + + + + + + + + + + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-inline-svg-mask.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersInlineSvgPreserveAspectRatioSlice() + { + var document = await ParseAsync(""" + + + + + + + + + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-inline-svg-preserve-aspect-ratio-slice.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersInlineSvgInternalStyleSheet() + { + var document = await ParseAsync(""" + + + + + + + + + + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-inline-svg-internal-stylesheet.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersInlineSvgPercentageLengths() + { + var document = await ParseAsync(""" + + + + + + + + + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-inline-svg-percentage-lengths.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersNestedSvgViewport() + { + var document = await ParseAsync(""" + + + + + + + + + + + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-nested-svg-viewport.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersSymbolViaUse() + { + var document = await ParseAsync(""" + + + + + + + + + + + + + + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 50, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-symbol-via-use.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersInlineSvgCurrentColor() + { + var document = await ParseAsync(""" + + + + + + + + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-inline-svg-current-color.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersInlineSvgExplicitMaskRegion() + { + var document = await ParseAsync(""" + + + + + + + + + + + + + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-inline-svg-explicit-mask-region.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersInlineSvgPatternFill() + { + var document = await ParseAsync(""" + + + + + + + + + + + + + + + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-inline-svg-pattern-fill.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersInlineSvgGaussianBlurFilter() + { + var document = await ParseAsync(""" + + + + + + + + + + + + + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-inline-svg-gaussian-blur-filter.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_IgnoresMediaRuleInsideInlineSvgStyle() + { + var document = await ParseAsync(""" + + + + + + + + + + + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 100, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "ignores-media-rule-inside-inline-svg-style.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + [Fact] public async Task RenderToPng_RendersWebSafeFontFamiliesDifferently() { diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/ignores-media-rule-inside-inline-svg-style.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/ignores-media-rule-inside-inline-svg-style.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..03b7ed8e095da3c095e4b167918e3be14c69a001 GIT binary patch literal 355 zcmeAS@N?(olHy`uVBq!ia0vp^DImEakt zG3V`FMZUuhJPZe8rqqYm?rc8Xw``N%y OWAJqKb6Mw<&;$Sr9Bfel literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-clip-path.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-clip-path.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..e1f3830bb5857cec132034b921efb10e06a75e52 GIT binary patch literal 1709 zcmV;e22%NnP)TWnNC7=XXCyS+$D>0Kz5LZL({EsD}=sqqCxypYJ7F<>y7 z_@q98a`6R}pfC7lBJoBHPnw9)g7Tozf>1?D0g(c2p)JtcVrlJl_xLc|NDZ2DX3m~D zhxw9y*<}8iIp3bkoSFI8vTfVOpba6f8(^4>5uzDJh-Mfenqh=!h7qC}Mu=t@A(~-? zXoeA@8Aga^7$KTrglI;@>jpT&j>Fgh#6}@L2C*^meK-X|DG*MDluWSv_j~h+5F5Hr zK>Lr-a}utdgOMnVkAOWsS%?*ZNE)Q&Ku#Itl|%8vP*4e2p7vrYSX$19#75!I7U((# zt=~fH0T{e27c*lP%v}ldRzl%Y$eJt8B+bxEh;769eQ@G^=xT(&u8PZ(N~{oM%oJx# z<;yUCHCXzbIeG{g>V=MaIQlkRK9bzJXH`ShCMd3j^xWjueG|MF(smH)w?Nl#;!@=W z;Z!KR50-C-k`>;xA%S;7qRr6oE;Q{1ygx|=7F4_lRU06Cfj2Ekx)3`KzkdexTcQ6V z`pB5Tf~+~Pd>h>JT+$z%lPhGn4}RSR4evl~Bx%(;B9acvHo($XlX`DSo{+&SQ1?8v zeTRN$#eZQz$)m996Y*r0j2c}RV%yN)0^h8K&O@$NgIb_dwwHkNtSc6xfZms`i0CYD(-8#`H7jaL>aG&Tv=V8~XNIIQ|M4yKH zT&uzzA$D9mO|>0#CA_D!9fV`=seh%Dz#SoH_KHVa-#ks-3byFmnZ2$=S1%;m3_oqw zf1Dx1_^{|>v{`v*^+Jy9NQ}S(PX9${*r`0MS|Kg_;nc?j^HTM1Y-svW%+)AFosgj( z@qX&IkTHQR`aIO5ETTFgorj<&0OO5YrMpq-8wf>0Y&g8lyZH<+iH*X~+fj98P$T5r zmvHqLC82!Dl?HJP6d*4oJ_aW~P|y^g)3p1a)h`mr3%Py?dQVD+@jcy5;#f!`FQn@@ zTYn^l>0)v78zJ39!T21mntqNq*w0O5J)7CiE|-#40`{NsyvYNot2uraxP?S5Js;# zE*@BhqcA$)xVUp6v5{a;LOCTqB0dgrE+js#eM3;}D56M6Tk%5+C z+a;0mjYQAF(VcMeBlNk1S+ICLRK4!Xpl?bo@H`oR>j9DCsOz{$)8Pf76ezqGYPUnl zW8SnOf%if{#1uaQ1r^@5e~S1=$c+T}Z}a>qB*6IZClqju5X~?`G{Xqd z3?oD{j1bK*LNvn&(F`L*GmH?;FhVrL2+<59L^J*d*GKwrbVudB00000NkvXXu0mjf D651#i literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-current-color.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-current-color.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..03b7ed8e095da3c095e4b167918e3be14c69a001 GIT binary patch literal 355 zcmeAS@N?(olHy`uVBq!ia0vp^DImEakt zG3V`FMZUuhJPZe8rqqYm?rc8Xw``N%y OWAJqKb6Mw<&;$Sr9Bfel literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-explicit-mask-region.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-explicit-mask-region.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..fffc12cbbe4928cb2ac1baa0566fb94b8ec707ab GIT binary patch literal 336 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 Ef(UG8J^%m! literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-internal-stylesheet.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-internal-stylesheet.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..8b356937812a64f8146f10668622bf8ea17285e7 GIT binary patch literal 396 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 literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-pattern-fill.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-pattern-fill.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..caf4f1dd41cbf2f9df8cc5e3b6fd56741379b4c6 GIT binary patch literal 367 zcmeAS@N?(olHy`uVBq!ia0vp^DImEakt zG3V{gjhqJzco-ZlQ~pn#%o?QxpTW)!I&Gwn4ro8f5(rj4>dCPK{A<581NW_F(E1{W? zxz$o8_sC^ZC{dOsm6_uG`TPCjeb47}&hvSm^L)PNJkNQa@40-Kh*wtBR0M%Q$^=)e z2Ow!16D$v`XQ#6~0U=Ar5In)ahyl}5Kp^>p1gx`X)a@_%6KcF;=Lkrh_kAC*P4QAB`on3tKYwgS;NfCS}mS_BA*ag zUgjm?8kh8pR{PsW)5vj5D&{z{dc|ROCDl@67g)`ePtaPJZ1}QKj;W4*Jwstqfcn8Nasnhj4Ae<*O1@0=aU0fPgKUv!|nPuYZr%Q#R{PrzA-h}pDlRs0RTczI~TKHmk*aMM# zuTBlXsjGv1`T3~|(f)o1e_`P_-!QJ~eB@Vr12d2g=@fwd-1{6olJBn{(4{^}-rACr zQM7D>yQ@6*1#J$s)r~10$@_B>Ec}ADDNN__Ea6wnz=e}P47R&H1++YTN^yG)Pg*X% zJP}w?5jy`pQgK!}%E86fI@=@$W5cd7*NArb<^IhF-DYw?26aNZ-KSciAa6kKRi^nj@>PJzq`Dwh1lwnJu(i# z;nEndAtm?b%%fUb-bfo2?3h38Nyw|spIOuzB)^e9()fek=$DhXU!||`tM~Fm3(a}( zAcsB+mAyp#IN{TZc>Ovx&r2Q@-&yK>sOJ!BqMJoRs)|=HoI2sA(zb5HA?bEAb0Oz! z`RSRKoxE&qVgOY8y4e!>BZkW@e=IWE=NYc)xlbVxrs0${G<0gX?t@vbl@ZE+y|$n+ zJt4t)IEyyc(p1PWq$60Y<9Ju<)L$R-yICQuZBk=mK>CLL)2G@>lTP3P&A!r5?Lo0U zyce($!@P0J`*U2ZZf^5(w;&rL(WE>P8TlA|#k)actzq z9@xvo!$wybXgb0KP2#rI5mG7r3R9d*8x}XW@uJ4DJ|O&_o;Z@Xu($bEmx~IJiln57 zP~qxlw!chT0NlWko)bxD2oUh|{1)bnLj|FK$g>ysLQCRsF;XJddCZ}xu9YI@kw}ub zzi3P6@`vdXJQ?K#!3~ZdSBi~YjS5XjI}O6Nt*@0cN2QWQ-^V>8c}sR}VYthFvA<7G zZR<1+4-e?4xavKG#=q8x@ZlTMO9tjyI~O)O=IGG|^9-odeH}JdhQkfZY<^YG778JH z8j2*7fqSaK!RR>)JWsJ^%%4+Mc2FYw(8kpCls&qxXgmoTDUv{%c#dvt)@B~h@%i`f zZQ(bIYM~%(0~5}SZsr@tR8=CY%qwe^Heo=}qx-I1j8FT3k*Ogm);FvkKKe|C237+O z)$LE!@>S3fN2n=0kxB>c(Phq9K~P17CILyYwz!d;mGusi4^i8=?aIoYs2`shLytTs zaDm>&D^^OfF+kSKpUtG}!MK{Wb_x3W zTRHdd-|iwf0i9P~9){e@i1(*s0g73rwANtM8kn*{L9Lg#Tx&sZflRWWbhCzs7d6OO zODKFfIb5X)-?4aB_&(LeTVTB1O%^C&3riOr&X9x*6azKdo8{WB8>JVV3B%N@eQVqE gKiT)+e0}W#Xa;JPKmHtf68L981RN1tjiDs`112X7T>t<8 literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-preserve-aspect-ratio-slice.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-preserve-aspect-ratio-slice.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..fb617d8fc2b04325efba454139987d580bcf6cba GIT binary patch literal 334 zcmeAS@N?(olHy`uVBq!ia0vp^DImEakt zG3V{YgS-b61dbd~{`;LLU#p`vP3y+g>+^1YZuokv_vb&k1HKa^9Ftf&1y$UJA%pK+ X5%aMC8BQf&05N#F`njxgN@xNAwEA7O literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-radial-gradient.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-radial-gradient.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..e7480ac4d533fb426a1d4c23372d79bc716827e0 GIT binary patch literal 6002 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 literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-shapes.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-shapes.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..4bbbfa82c897460f3001c9223a7ec1142dc277fb GIT binary patch literal 1124 zcmeAS@N?(olHy`uVBq!ia0vp^DImuwxBpW={cE>4e|qt`>MtpedY;eb zJ0P(|@J;?~PwR#s0-TD@PA4V^7^$@IAecQlTddRXF?p*uOzm&nTHdfPalv=B3G(dE z{BO>$yviC9&S-AVaJcrs+^q)|Pd#vX>4DqZ{>B=rRjmDYHz8yG!M&3lb<-bMWm#6A zyC}I6R;Ky$eTkZHK~8p2-0HmN z-gfCPs+)5=bH1)(Y3ZZr=9xL4ThFAt-K_d#{;4xkG8R{B1ivQ#f7$l$chd61Y+n~x zaK+r=6Ir!T+@&sr`@pyE0~r@z8=c>tYxVE+!%qpnj2LD=+&%xYpP0@{}va^Sbv;6nK_1ZwGf| zxph)c%bK0D%bF5^ypJkC-V^zya}^}sIqiybg(%zd_L!;mJEvvO;Mywp*Z>t8q<=mV zd*ZICV6w`SZ@ab#vmA~+vGpv7cYPm(_ciXp`*R(EQYovrf8-`Z9CRspH_y!#&l*3z z+Y$`Y@%iY1r)vclo19phYZl%8V7p_e%vR|Top_^bR&Uv+9yiN-ofE+L?I`={iHl4Q zB;2g_pDX)ct^FNWK_dGFV=>K1%E`6|`nVmAzW=;-Rc;FO{vi|32nneXp7ArDyy33KyC%$^2uy zQ6zf8ev53Ip3n1!_R0sR#2y?!e85AqVhcyU^b`vxhH9N(pY7i=ZF(=YAyY!*F7q_s znhKq&BuRr!Qa@5B+e+DnvCPx$EYsOl+dARUzinAX3RtoRFqbHw-dB`sZsLrQZIE&h dRhd2I!KU}W+`lg^2`n}kJYD@<);T3K0RT}Z`)&XL literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-text.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-text.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..93ae08de4b0820d01ad39a5a7ffb99d440ed9e3c GIT binary patch literal 711 zcmeAS@N?(olHy`uVBq!ia0vp^3xL>$gAGVN%-$#pq*#ibJVQ8upoSx*gMopm&eO#) zq+-t7I~)C!0wvlW)+fXY8?$gradUHb?`7tm9UOW4auxTjYSy^M)=t)273@dKw08S$ zDONN&#&l9JyU=!`!@I4 z-?rw)rT3zLefo6YSYMiH&Z941uFNSetEsUmJbU(RujTVM4;~z-+I#Q$&(o*9Z{NSa z-EHy2%KgXP4%b^VP0%zZtjx^7M2-}#pnF|{Eo!| z-S_J2FQ0Fk;j7;t_exc>&zBTPV4Lc7bldjr$K$fM-!{ITzBFv}f|zYnneOgVJRrka zUiKz@bEdty`PIcbJdDpgc1CSI^)7E=Tu*_#fxDc(-o5|gH6=^rQWGm!&P@FA@7}KK z*TvSXTjd@pA;-9!qbcEuMdsP->-m=ky_|pl_x6X!Zv_?B=`zmmV_o{_&q~GV=+TBF eHi;H~#rutEf$zClE4_g!lfl!~&t;ucLK6VF!!$$y literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-use-element.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-use-element.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..fc477dc61707d49fb9284693e3bab83f6eeb5c3f GIT binary patch literal 1126 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 literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-svg-image-source.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-svg-image-source.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..aa9cef9192f9d9071e37673234f599d0c2aa14dd GIT binary patch literal 339 zcmeAS@N?(olHy`uVBq!ia0vp^DImEakt zG3V`_gS-t691M;o-|idjTYJDWZ#mP6HvQ}8-pomPTl#)K`-yNz1J;8gEWrXk1IDoV rrT^t}iQ7`+tVR`>otK(`ObJu_TSk)D! zSHAnUZqd9)Ki`TxKE0)S-E~Vd$3yFVdQ&MBb@0GUIIaR2}S literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer/HtmlRenderer.cs b/src/AngleSharp.Renderer/HtmlRenderer.cs index 5de2b4a..58e6093 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.cs @@ -24,6 +24,8 @@ public sealed class HtmlRenderer { // Per-document cache keeps image payloads stable across repeated renders of the same DOM instance. private static readonly ConditionalWeakTable s_imageCacheByDocument = new(); + // Inline markup has no URL to key a per-document cache on, so it is cached per element instead. + private static readonly ConditionalWeakTable s_inlineSvgCacheByElement = new(); private static readonly AsyncLocal s_layoutCapture = new(); private static readonly ITextMeasurer s_defaultTextMeasurer = new SkiaTextMeasurer(); @@ -410,7 +412,7 @@ private static void LayoutElement( return; } - var renderAsBlock = ShouldRenderAsBlock(computedStyle) || string.Equals(tagName, "img", StringComparison.OrdinalIgnoreCase); + var renderAsBlock = ShouldRenderAsBlock(computedStyle) || IsReplacedElementTag(tagName); var isInlineBlock = IsInlineBlock(computedStyle); var currentTextStyle = ResolveTextStyle(styleMap, inheritedTextStyle); @@ -558,7 +560,12 @@ private static void LayoutElement( var inlineLineHeight = currentTextStyle.FontSize * currentTextStyle.LineHeightMultiplier; var inlineCursorX = flowContainingX + (textIndentConsumed ? 0f : currentTextStyle.TextIndent); - var orderedChildren = OrderChildrenForPainting(node.Children).ToList(); + // 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) + ? [] + : OrderChildrenForPainting(node.Children).ToList(); var hasInlineRun = orderedChildren.Any(child => (child is ElementRenderNode childElement && !ShouldRenderAsBlock(childElement.ComputedStyle) && @@ -820,8 +827,7 @@ private static void LayoutElement( PaintBorder(displayList, box.BorderColor, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderWidth); PaintOutline(displayList, styleMap, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight); - if (string.Equals(tagName, "img", StringComparison.OrdinalIgnoreCase) && - TryResolveImage(node, styleMap, flowContainingWidth, borderBoxX + borderLeft + paddingLeft, borderBoxY + borderTop + paddingTop, out var image, out var imageRect)) + if (TryResolveReplacedElementImage(node, styleMap, flowContainingWidth, borderBoxX + borderLeft + paddingLeft, borderBoxY + borderTop + paddingTop, out var image, out var imageRect)) { displayList.DrawImage(imageRect, image!); } @@ -1183,8 +1189,7 @@ private static void LayoutFlexContainer( PaintBorder(displayList, box.BorderColor, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight, box.BorderWidth); PaintOutline(displayList, styleMap, borderBoxX, borderBoxY, borderBoxWidth, borderBoxHeight); - if (string.Equals(node.Ref.LocalName, "img", StringComparison.OrdinalIgnoreCase) && - TryResolveImage(node, styleMap, containingWidth, borderBoxX + borderLeft + paddingLeft, borderBoxY + borderTop + paddingTop, out var image, out var imageRect)) + if (TryResolveReplacedElementImage(node, styleMap, containingWidth, borderBoxX + borderLeft + paddingLeft, borderBoxY + borderTop + paddingTop, out var image, out var imageRect)) { displayList.DrawImage(imageRect, image!); } @@ -3024,6 +3029,14 @@ private static void PaintOutline(DisplayList displayList, Dictionary + string.Equals(tagName, "img", StringComparison.OrdinalIgnoreCase) || + string.Equals(tagName, "svg", StringComparison.OrdinalIgnoreCase); + + private static bool TryResolveReplacedElementImage(ElementRenderNode node, Dictionary styleMap, float containingWidth, float x, float y, out RenderedImage? image, out RenderRect rect) => + TryResolveImage(node, styleMap, containingWidth, x, y, out image, out rect) || + TryResolveInlineSvg(node, styleMap, containingWidth, x, y, out image, out rect); + private static bool TryResolveImage(ElementRenderNode node, Dictionary styleMap, float containingWidth, float x, float y, out RenderedImage? image, out RenderRect rect) { image = null; @@ -3040,6 +3053,32 @@ private static bool TryResolveImage(ElementRenderNode node, Dictionary styleMap, float containingWidth, float x, float y, out RenderedImage? image, out RenderRect rect) + { + image = null; + rect = default; + + if (!string.Equals(node.Ref.LocalName, "svg", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (!TryGetOrLoadInlineSvgResource(node.Ref, out var imageResource) || imageResource is null) + { + return false; + } + + return TryResolveReplacedElementRect(imageResource, styleMap, containingWidth, x, y, out image, out rect); + } + + private static bool TryResolveReplacedElementRect(CachedImageResource imageResource, Dictionary styleMap, float containingWidth, float x, float y, out RenderedImage? image, out RenderRect rect) + { + image = null; + rect = default; + var width = ParseLength(styleMap, "width", containingWidth, float.NaN, allowAuto: true); var height = ParseLength(styleMap, "height", containingWidth, float.NaN, allowAuto: true); @@ -3071,6 +3110,39 @@ private static bool TryResolveImage(ElementRenderNode node, Dictionary +/// Parses SVG/CSS paint values (colors, "none", "currentColor" and a common set of named colors) +/// into . Scoped to the presentation-attribute subset SVG rendering needs - +/// not a general CSS color parser. +/// +internal static class SvgColorParsing +{ + /// + /// Attempts to parse a paint value. Returns false for "none" (explicitly unpainted) or + /// an unrecognized value. + /// + public static bool TryParsePaint(string? value, out SKColor color) + { + color = SKColors.Black; + + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var trimmed = value.Trim(); + + if (string.Equals(trimmed, "none", StringComparison.OrdinalIgnoreCase) || + string.Equals(trimmed, "transparent", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // currentColor would need the CSS `color` property resolved from an ancestor; without a + // wider style cascade to resolve it against, black is a reasonable fallback. + if (string.Equals(trimmed, "currentColor", StringComparison.OrdinalIgnoreCase)) + { + color = SKColors.Black; + return true; + } + + if (trimmed.StartsWith('#')) + { + return TryParseHex(trimmed, out color); + } + + if (trimmed.StartsWith("rgb", StringComparison.OrdinalIgnoreCase)) + { + return TryParseRgbFunction(trimmed, out color); + } + + return TryParseNamedColor(trimmed, out color); + } + + private static bool TryParseHex(string value, out SKColor color) + { + color = SKColors.Black; + var hex = value[1..]; + + static bool TryHexByte(string s, out byte result) => + byte.TryParse(s, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out result); + + static byte Expand(char c) => (byte)(Uri.FromHex(c) * 16 + Uri.FromHex(c)); + + try + { + switch (hex.Length) + { + case 3: + color = new SKColor(Expand(hex[0]), Expand(hex[1]), Expand(hex[2])); + return true; + case 4: + color = new SKColor(Expand(hex[0]), Expand(hex[1]), Expand(hex[2]), Expand(hex[3])); + return true; + case 6 when TryHexByte(hex[..2], out var r) && TryHexByte(hex[2..4], out var g) && TryHexByte(hex[4..6], out var b): + color = new SKColor(r, g, b); + return true; + case 8 when TryHexByte(hex[..2], out var r2) && TryHexByte(hex[2..4], out var g2) && TryHexByte(hex[4..6], out var b2) && TryHexByte(hex[6..8], out var a2): + color = new SKColor(r2, g2, b2, a2); + return true; + default: + return false; + } + } + catch (FormatException) + { + return false; + } + } + + private static bool TryParseRgbFunction(string value, out SKColor color) + { + color = SKColors.Black; + + var openParen = value.IndexOf('('); + var closeParen = value.LastIndexOf(')'); + + if (openParen < 0 || closeParen <= openParen) + { + return false; + } + + var content = value[(openParen + 1)..closeParen]; + var separators = content.Contains(',') ? new[] { ',' } : new[] { ' ' }; + var parts = content.Split(separators, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (parts.Length < 3) + { + return false; + } + + if (!TryParseColorChannel(parts[0], out var r) || + !TryParseColorChannel(parts[1], out var g) || + !TryParseColorChannel(parts[2], out var b)) + { + return false; + } + + byte a = 255; + + if (parts.Length >= 4 && float.TryParse(parts[3].TrimEnd('%'), NumberStyles.Float, CultureInfo.InvariantCulture, out var alpha)) + { + a = (byte)Math.Clamp(Math.Round(alpha * 255f), 0, 255); + } + + color = new SKColor(r, g, b, a); + return true; + } + + private static bool TryParseColorChannel(string token, out byte value) + { + value = 0; + + if (token.EndsWith('%')) + { + if (!float.TryParse(token[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out var percent)) + { + return false; + } + + value = (byte)Math.Clamp(Math.Round(percent * 255f / 100f), 0, 255); + return true; + } + + if (!float.TryParse(token, NumberStyles.Float, CultureInfo.InvariantCulture, out var raw)) + { + return false; + } + + value = (byte)Math.Clamp(Math.Round(raw), 0, 255); + return true; + } + + private static bool TryParseNamedColor(string name, out SKColor color) + { + var found = name.ToLowerInvariant() switch + { + "black" => SKColors.Black, + "white" => SKColors.White, + "red" => SKColors.Red, + "green" => new SKColor(0, 128, 0), + "blue" => SKColors.Blue, + "yellow" => SKColors.Yellow, + "orange" => new SKColor(255, 165, 0), + "purple" => new SKColor(128, 0, 128), + "gray" or "grey" => new SKColor(128, 128, 128), + "silver" => new SKColor(192, 192, 192), + "cyan" or "aqua" => SKColors.Cyan, + "magenta" or "fuchsia" => SKColors.Magenta, + "lime" => new SKColor(0, 255, 0), + "navy" => new SKColor(0, 0, 128), + "teal" => new SKColor(0, 128, 128), + "olive" => new SKColor(128, 128, 0), + "maroon" => new SKColor(128, 0, 0), + "pink" => new SKColor(255, 192, 203), + "brown" => new SKColor(165, 42, 42), + "gold" => new SKColor(255, 215, 0), + "indigo" => new SKColor(75, 0, 130), + "violet" => new SKColor(238, 130, 238), + "coral" => new SKColor(255, 127, 80), + "salmon" => new SKColor(250, 128, 114), + "khaki" => new SKColor(240, 230, 140), + "crimson" => new SKColor(220, 20, 60), + "turquoise" => new SKColor(64, 224, 208), + "beige" => new SKColor(245, 245, 220), + "ivory" => new SKColor(255, 255, 240), + "lavender" => new SKColor(230, 230, 250), + "chocolate" => new SKColor(210, 105, 30), + "tomato" => new SKColor(255, 99, 71), + "orchid" => new SKColor(218, 112, 214), + "plum" => new SKColor(221, 160, 221), + "skyblue" => new SKColor(135, 206, 235), + "steelblue" => new SKColor(70, 130, 180), + "royalblue" => new SKColor(65, 105, 225), + "forestgreen" => new SKColor(34, 139, 34), + "seagreen" => new SKColor(46, 139, 87), + "darkgreen" => new SKColor(0, 100, 0), + "darkred" => new SKColor(139, 0, 0), + "darkblue" => new SKColor(0, 0, 139), + "lightblue" => new SKColor(173, 216, 230), + "lightgreen" => new SKColor(144, 238, 144), + "lightgray" or "lightgrey" => new SKColor(211, 211, 211), + "darkgray" or "darkgrey" => new SKColor(169, 169, 169), + _ => (SKColor?)null, + }; + + if (found is null) + { + color = SKColors.Black; + return false; + } + + color = found.Value; + return true; + } +} diff --git a/src/AngleSharp.Renderer/Skia/Svg/SvgElementRenderer.cs b/src/AngleSharp.Renderer/Skia/Svg/SvgElementRenderer.cs new file mode 100644 index 0000000..ceda46d --- /dev/null +++ b/src/AngleSharp.Renderer/Skia/Svg/SvgElementRenderer.cs @@ -0,0 +1,520 @@ +namespace AngleSharp.Renderer.Skia.Svg; + +using AngleSharp.Dom; + +using SkiaSharp; + +/// +/// Walks an SVG element tree that AngleSharp has already parsed (either as part of the host HTML +/// document, for inline <svg>, or as a standalone document for an SVG image source) and +/// paints its shapes directly onto an . No SVG markup is re-parsed here - +/// this only reads the attributes AngleSharp already exposes on the DOM. +/// +internal static class SvgElementRenderer +{ + private static readonly HashSet NonRenderingTags = new(StringComparer.OrdinalIgnoreCase) + { + "defs", "title", "desc", "metadata", "style", "symbol", "clipPath", "mask", "linearGradient", "radialGradient", "pattern", "filter", + }; + + public static void Render(SKCanvas canvas, IElement svgRoot, SvgPaintState initialState, SvgRenderContext context, SvgViewport viewport) + { + // The root itself is never visited by RenderElement (that only walks its children), so its + // own presentation attributes/style (e.g. a `color` establishing currentColor for the whole + // document) would otherwise be silently ignored. + var rootState = initialState.Resolve(svgRoot, context, viewport); + + foreach (var child in svgRoot.Children) + { + RenderElement(canvas, child, rootState, context, viewport); + } + } + + private static void RenderElement(SKCanvas canvas, IElement element, SvgPaintState inheritedState, SvgRenderContext context, SvgViewport viewport) + { + var tagName = element.LocalName; + + if (NonRenderingTags.Contains(tagName)) + { + return; + } + + var state = inheritedState.Resolve(element, context, viewport); + var transform = SvgTransformParser.Parse(element.GetAttribute("transform")); + var hasTransform = !transform.IsIdentity; + + var clipPathElement = ResolveReferencedElement(element, "clip-path", "clipPath", context); + var maskElement = ResolveReferencedElement(element, "mask", "mask", context); + var filterElement = ResolveReferencedElement(element, "filter", "filter", context); + var needsScope = hasTransform || clipPathElement is not null || maskElement is not null || filterElement is not null; + + if (needsScope) + { + canvas.Save(); + } + + if (hasTransform) + { + canvas.Concat(ref transform); + } + + if (clipPathElement is not null) + { + using var clipPath = BuildClipPath(clipPathElement, viewport); + canvas.ClipPath(clipPath, SKClipOperation.Intersect, antialias: true); + } + + var filterLayerCount = -1; + + if (filterElement is not null && SvgFilterBuilder.Build(filterElement) is { } imageFilter) + { + using var filterPaint = new SKPaint { ImageFilter = imageFilter }; + filterLayerCount = canvas.SaveLayer(filterPaint); + } + + if (maskElement is not null) + { + RenderMasked(canvas, element, maskElement, tagName, state, context, viewport); + } + else + { + RenderElementContent(canvas, element, tagName, state, context, viewport); + } + + if (filterLayerCount >= 0) + { + canvas.RestoreToCount(filterLayerCount); + } + + if (needsScope) + { + canvas.Restore(); + } + } + + private static void RenderMasked(SKCanvas canvas, IElement element, IElement maskElement, string tagName, SvgPaintState state, SvgRenderContext context, SvgViewport viewport) + { + var contentLayerCount = canvas.SaveLayer(); + + ApplyMaskRegionClip(canvas, maskElement, element, viewport, context); + RenderElementContent(canvas, element, tagName, state, context, viewport); + + using (var maskPaint = new SKPaint { ColorFilter = SKColorFilter.CreateLumaColor(), BlendMode = SKBlendMode.DstIn }) + { + canvas.SaveLayer(maskPaint); + + var contentUnitsIsObjectBoundingBox = string.Equals(maskElement.GetAttribute("maskContentUnits"), "objectBoundingBox", StringComparison.OrdinalIgnoreCase); + var maskViewport = viewport; + + if (contentUnitsIsObjectBoundingBox && SvgGeometry.ComputeBounds(element, viewport, context) is { } bbox) + { + canvas.Translate(bbox.Left, bbox.Top); + canvas.Scale(bbox.Width, bbox.Height); + maskViewport = new SvgViewport(1f, 1f); + } + + foreach (var maskChild in maskElement.Children) + { + RenderElement(canvas, maskChild, SvgPaintState.Initial, context, maskViewport); + } + } + + canvas.RestoreToCount(contentLayerCount); + } + + private static void ApplyMaskRegionClip(SKCanvas canvas, IElement maskElement, IElement maskedElement, SvgViewport viewport, SvgRenderContext context) + { + var isObjectBoundingBox = !string.Equals(maskElement.GetAttribute("maskUnits"), "userSpaceOnUse", StringComparison.OrdinalIgnoreCase); + + if (isObjectBoundingBox) + { + var bounds = SvgGeometry.ComputeBounds(maskedElement, viewport, context); + + if (bounds is not { } bbox) + { + // Can't determine a bounding box (e.g. a group of only text) - don't clip, so the + // mask still applies rather than silently painting nothing. + return; + } + + var x = ParseFraction(maskElement.GetAttribute("x"), -0.1f); + var y = ParseFraction(maskElement.GetAttribute("y"), -0.1f); + var w = ParseFraction(maskElement.GetAttribute("width"), 1.2f); + var h = ParseFraction(maskElement.GetAttribute("height"), 1.2f); + + canvas.ClipRect(new SKRect( + bbox.Left + (x * bbox.Width), + bbox.Top + (y * bbox.Height), + bbox.Left + ((x + w) * bbox.Width), + bbox.Top + ((y + h) * bbox.Height))); + } + else + { + var x = SvgLength.Parse(maskElement.GetAttribute("x"), viewport.Width, -0.1f * viewport.Width); + var y = SvgLength.Parse(maskElement.GetAttribute("y"), viewport.Height, -0.1f * viewport.Height); + var w = SvgLength.Parse(maskElement.GetAttribute("width"), viewport.Width, 1.2f * viewport.Width); + var h = SvgLength.Parse(maskElement.GetAttribute("height"), viewport.Height, 1.2f * viewport.Height); + + canvas.ClipRect(new SKRect(x, y, x + w, y + h)); + } + } + + private static float ParseFraction(string? raw, float defaultValue) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return defaultValue; + } + + var trimmed = raw.Trim(); + + if (trimmed.EndsWith('%') && float.TryParse(trimmed[..^1], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var percent)) + { + return percent / 100f; + } + + return float.TryParse(trimmed, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var value) ? value : defaultValue; + } + + private static void RenderElementContent(SKCanvas canvas, IElement element, string tagName, SvgPaintState state, SvgRenderContext context, SvgViewport viewport) + { + switch (tagName.ToLowerInvariant()) + { + case "g": + case "a": + foreach (var child in element.Children) + { + RenderElement(canvas, child, state, context, viewport); + } + + break; + + case "svg": + RenderNestedViewport(canvas, element, element.Children, state, context, viewport, translate: true); + break; + + case "use": + RenderUse(canvas, element, state, context, viewport); + break; + + case "rect": + RenderPath(canvas, SvgShapeBuilder.BuildRect(element, viewport), state, context, viewport); + break; + + case "circle": + RenderPath(canvas, SvgShapeBuilder.BuildCircle(element, viewport), state, context, viewport); + break; + + case "ellipse": + RenderPath(canvas, SvgShapeBuilder.BuildEllipse(element, viewport), state, context, viewport); + break; + + case "line": + RenderPath(canvas, SvgShapeBuilder.BuildLine(element, viewport), state, context, viewport, strokeOnly: true); + break; + + case "polyline": + case "polygon": + case "path": + if (SvgShapeBuilder.Build(element, viewport) is { } shapePath) + { + RenderPath(canvas, shapePath, state, context, viewport); + } + + break; + + case "text": + var x = SvgShapeBuilder.ReadX(element, "x", viewport); + var y = SvgShapeBuilder.ReadY(element, "y", viewport); + RenderTextContent(canvas, element, state, context, viewport, ref x, y); + break; + + default: + // Unsupported element (image, ...): descend in case it groups further shapes, but + // paint nothing for the element itself. + foreach (var child in element.Children) + { + RenderElement(canvas, child, state, context, viewport); + } + + break; + } + } + + /// + /// Establishes a new viewport for a nested <svg> or a <symbol>/<svg> + /// referenced through <use>: translates to `x`/`y` (only meaningful for a direct nested + /// <svg>, not a <use>'s already-applied translate), clips to `width`x`height`, maps + /// the element's own `viewBox` onto that box, and renders its children in the resulting + /// coordinate system. + /// + private static void RenderNestedViewport(SKCanvas canvas, IElement viewportElement, IEnumerable content, SvgPaintState state, SvgRenderContext context, SvgViewport outerViewport, bool translate) + { + var width = SvgShapeBuilder.ReadOptionalX(viewportElement, "width", outerViewport) ?? outerViewport.Width; + var height = SvgShapeBuilder.ReadOptionalY(viewportElement, "height", outerViewport) ?? outerViewport.Height; + + if (width <= 0f || height <= 0f) + { + return; + } + + canvas.Save(); + + if (translate) + { + var x = SvgShapeBuilder.ReadX(viewportElement, "x", outerViewport); + var y = SvgShapeBuilder.ReadY(viewportElement, "y", outerViewport); + canvas.Translate(x, y); + } + + canvas.ClipRect(new SKRect(0f, 0f, width, height)); + + var viewBox = SvgViewBoxMapping.ParseViewBox(viewportElement.GetAttribute("viewBox")); + var (align, meet) = SvgViewBoxMapping.ParsePreserveAspectRatio(viewportElement.GetAttribute("preserveAspectRatio")); + var viewBoxMatrix = SvgViewBoxMapping.ComputeMatrix(viewBox, width, height, align, meet); + canvas.Concat(ref viewBoxMatrix); + + var innerViewport = viewBox is { } box ? new SvgViewport(box.Width, box.Height) : new SvgViewport(width, height); + + foreach (var child in content) + { + RenderElement(canvas, child, state, context, innerViewport); + } + + canvas.Restore(); + } + + private static void RenderUse(SKCanvas canvas, IElement element, SvgPaintState state, SvgRenderContext context, SvgViewport viewport) + { + var href = SvgUrlReference.GetHref(element); + + if (href is not { Length: > 1 } || !href.StartsWith('#') || + !context.ElementsById.TryGetValue(href[1..], out var referenced) || + !context.ActiveUseReferences.Add(referenced)) + { + return; + } + + try + { + var x = SvgShapeBuilder.ReadX(element, "x", viewport); + var y = SvgShapeBuilder.ReadY(element, "y", viewport); + + canvas.Save(); + canvas.Translate(x, y); + + var referencedTag = referenced.LocalName.ToLowerInvariant(); + + if (referencedTag is "symbol" or "svg") + { + // A referencing a / establishes a new viewport sized from the + // 's own width/height (falling back to the referenced element's, then to the + // outer viewport), per the SVG spec - it is not just a translated copy. + var useWidth = SvgShapeBuilder.ReadOptionalX(element, "width", viewport) + ?? SvgShapeBuilder.ReadOptionalX(referenced, "width", viewport) + ?? viewport.Width; + var useHeight = SvgShapeBuilder.ReadOptionalY(element, "height", viewport) + ?? SvgShapeBuilder.ReadOptionalY(referenced, "height", viewport) + ?? viewport.Height; + + if (useWidth > 0f && useHeight > 0f) + { + canvas.Save(); + canvas.ClipRect(new SKRect(0f, 0f, useWidth, useHeight)); + + var viewBox = SvgViewBoxMapping.ParseViewBox(referenced.GetAttribute("viewBox")); + var (align, meet) = SvgViewBoxMapping.ParsePreserveAspectRatio(referenced.GetAttribute("preserveAspectRatio")); + var viewBoxMatrix = SvgViewBoxMapping.ComputeMatrix(viewBox, useWidth, useHeight, align, meet); + canvas.Concat(ref viewBoxMatrix); + + var innerViewport = viewBox is { } box ? new SvgViewport(box.Width, box.Height) : new SvgViewport(useWidth, useHeight); + + foreach (var child in referenced.Children) + { + RenderElement(canvas, child, state, context, innerViewport); + } + + canvas.Restore(); + } + } + else + { + RenderElement(canvas, referenced, state, context, viewport); + } + + canvas.Restore(); + } + finally + { + context.ActiveUseReferences.Remove(referenced); + } + } + + private static IElement? ResolveReferencedElement(IElement element, string property, string expectedTag, SvgRenderContext context) + { + var raw = element.GetAttribute(property); + var style = element.GetAttribute("style"); + + if (!string.IsNullOrWhiteSpace(style)) + { + foreach (var declaration in style.Split(';', StringSplitOptions.RemoveEmptyEntries)) + { + var colonIndex = declaration.IndexOf(':'); + + if (colonIndex > 0 && declaration[..colonIndex].Trim().Equals(property, StringComparison.OrdinalIgnoreCase)) + { + raw = declaration[(colonIndex + 1)..].Trim(); + } + } + } + + return SvgUrlReference.TryExtract(raw, out var id) && + context.ElementsById.TryGetValue(id, out var referenced) && + string.Equals(referenced.LocalName, expectedTag, StringComparison.OrdinalIgnoreCase) + ? referenced + : null; + } + + private static SKPath BuildClipPath(IElement clipPathElement, SvgViewport viewport) + { + var combined = new SKPath(); + + foreach (var child in clipPathElement.Children) + { + using var shapePath = SvgShapeBuilder.Build(child, viewport); + + if (shapePath is null) + { + continue; + } + + var transform = SvgTransformParser.Parse(child.GetAttribute("transform")); + + using var transformedPath = transform.IsIdentity ? null : new SKPath(); + + if (transformedPath is not null) + { + shapePath.Transform(transform, transformedPath); + combined.AddPath(transformedPath); + } + else + { + combined.AddPath(shapePath); + } + } + + return combined; + } + + private static void RenderPath(SKCanvas canvas, SKPath path, SvgPaintState state, SvgRenderContext context, SvgViewport viewport, bool strokeOnly = false) + { + path.FillType = state.FillRule; + var bounds = path.Bounds; + + if (!strokeOnly) + { + using var fillPaint = state.CreateFillPaint(bounds, context, viewport); + + if (fillPaint is not null) + { + canvas.DrawPath(path, fillPaint); + } + } + + using var strokePaint = state.CreateStrokePaint(bounds, context, viewport); + + if (strokePaint is not null) + { + canvas.DrawPath(path, strokePaint); + } + + path.Dispose(); + } + + private static void RenderTextContent(SKCanvas canvas, IElement element, SvgPaintState inheritedState, SvgRenderContext context, SvgViewport viewport, ref float cursorX, float baselineY) + { + var state = inheritedState.Resolve(element, context, viewport); + + var explicitX = SvgShapeBuilder.ReadOptionalX(element, "x", viewport); + var explicitY = SvgShapeBuilder.ReadOptionalY(element, "y", viewport); + + if (explicitX is { } x) + { + cursorX = x; + } + + var y = explicitY ?? baselineY; + + foreach (var node in element.ChildNodes) + { + if (node is IText textNode) + { + var text = NormalizeWhitespace(textNode.Data); + + if (text.Length > 0) + { + DrawTextRun(canvas, text, state, context, viewport, ref cursorX, y); + } + } + else if (node is IElement childElement && string.Equals(childElement.LocalName, "tspan", StringComparison.OrdinalIgnoreCase)) + { + RenderTextContent(canvas, childElement, state, context, viewport, ref cursorX, y); + } + } + } + + private static void DrawTextRun(SKCanvas canvas, string text, SvgPaintState state, SvgRenderContext context, SvgViewport viewport, ref float cursorX, float y) + { + using var measurePaint = new SKPaint + { + Typeface = SkiaTextShaping.CreateTypeface(state.FontFamily, SkiaTextShaping.CreateFontStyle(state.FontWeight, state.IsItalic)), + TextSize = state.FontSize, + }; + + var width = measurePaint.MeasureText(text); + var ascent = -measurePaint.FontMetrics.Ascent; + var descent = measurePaint.FontMetrics.Descent; + var bounds = new SKRect(cursorX, y - ascent, cursorX + width, y + descent); + + var anchorOffset = state.TextAnchor switch + { + SvgTextAnchor.Middle => -width / 2f, + SvgTextAnchor.End => -width, + _ => 0f, + }; + + var drawX = cursorX + anchorOffset; + + using var fillPaint = state.CreateFillPaint(bounds, context, viewport); + + if (fillPaint is not null) + { + fillPaint.Typeface = measurePaint.Typeface; + fillPaint.TextSize = state.FontSize; + canvas.DrawText(text, drawX, y, fillPaint); + } + + using var strokePaint = state.CreateStrokePaint(bounds, context, viewport); + + if (strokePaint is not null) + { + strokePaint.Typeface = measurePaint.Typeface; + strokePaint.TextSize = state.FontSize; + canvas.DrawText(text, drawX, y, strokePaint); + } + + cursorX += width; + } + + private static string NormalizeWhitespace(string text) + { + var normalized = text.Replace('\t', ' ').Replace('\n', ' ').Replace('\r', ' '); + + while (normalized.Contains(" ", StringComparison.Ordinal)) + { + normalized = normalized.Replace(" ", " ", StringComparison.Ordinal); + } + + return normalized.Trim(); + } +} diff --git a/src/AngleSharp.Renderer/Skia/Svg/SvgFilterBuilder.cs b/src/AngleSharp.Renderer/Skia/Svg/SvgFilterBuilder.cs new file mode 100644 index 0000000..3aef785 --- /dev/null +++ b/src/AngleSharp.Renderer/Skia/Svg/SvgFilterBuilder.cs @@ -0,0 +1,213 @@ +namespace AngleSharp.Renderer.Skia.Svg; + +using System.Globalization; +using System.Linq; + +using AngleSharp.Dom; + +using SkiaSharp; + +/// +/// Builds an from a <filter> element's primitive chain. Supports +/// the common `feGaussianBlur`/`feOffset`/`feMerge`/`feColorMatrix`/`feDropShadow` primitives, +/// each composed via SkiaSharp's own filter graph ( is itself a DAG of +/// inputs, so this only has to translate primitives one at a time, not build a compositor). +/// An unsupported primitive (feFlood, feComposite, feTurbulence, feDisplacementMap, feTile, +/// feImage, feComponentTransfer, feConvolveMatrix, feDiffuseLighting, feSpecularLighting, +/// feMorphology) passes its resolved input through unchanged rather than being dropped, so later +/// primitives in the same chain still have something to work with. +/// +internal static class SvgFilterBuilder +{ + public static SKImageFilter? Build(IElement filterElement) + { + var results = new Dictionary(StringComparer.Ordinal); + SKImageFilter? last = null; + var hasPrimitive = false; + + foreach (var primitive in filterElement.Children) + { + var tag = primitive.LocalName.ToLowerInvariant(); + + if (tag == "femerge") + { + last = BuildMerge(primitive, results, last); + } + else + { + var input = ResolveInput(primitive.GetAttribute("in"), results, last); + + last = tag switch + { + "fegaussianblur" => BuildGaussianBlur(primitive, input), + "feoffset" => BuildOffset(primitive, input), + "fecolormatrix" => BuildColorMatrix(primitive, input), + "fedropshadow" => BuildDropShadow(primitive, input), + _ => input, + }; + } + + hasPrimitive = true; + + var resultName = primitive.GetAttribute("result"); + + if (!string.IsNullOrWhiteSpace(resultName)) + { + results[resultName] = last; + } + } + + return hasPrimitive ? last : null; + } + + private static SKImageFilter? ResolveInput(string? inName, Dictionary results, SKImageFilter? last) + { + if (string.IsNullOrWhiteSpace(inName)) + { + return last; + } + + // SourceAlpha (an alpha-only copy of the element's own content) is approximated as + // SourceGraphic - a documented simplification, not a distinction this builder makes. + if (inName is "SourceGraphic" or "SourceAlpha") + { + return null; + } + + return results.TryGetValue(inName, out var named) ? named : last; + } + + private static SKImageFilter BuildGaussianBlur(IElement primitive, SKImageFilter? input) + { + var (sigmaX, sigmaY) = ParseStdDeviation(primitive.GetAttribute("stdDeviation")); + return SKImageFilter.CreateBlur(sigmaX, sigmaY, input); + } + + private static SKImageFilter BuildOffset(IElement primitive, SKImageFilter? input) + { + var dx = ParseFloat(primitive.GetAttribute("dx"), 0f); + var dy = ParseFloat(primitive.GetAttribute("dy"), 0f); + return SKImageFilter.CreateOffset(dx, dy, input); + } + + private static SKImageFilter BuildMerge(IElement primitive, Dictionary results, SKImageFilter? last) + { + var nodes = primitive.Children + .Where(child => string.Equals(child.LocalName, "feMergeNode", StringComparison.OrdinalIgnoreCase)) + .Select(node => ResolveInput(node.GetAttribute("in"), results, last)) + .ToArray(); + + return nodes.Length > 0 ? SKImageFilter.CreateMerge(nodes) : (last ?? SKImageFilter.CreateOffset(0f, 0f)); + } + + private static SKImageFilter BuildColorMatrix(IElement primitive, SKImageFilter? input) + { + var type = primitive.GetAttribute("type"); + var values = primitive.GetAttribute("values"); + + var matrix = type?.ToLowerInvariant() switch + { + "saturate" => CreateSaturateMatrix(ParseFloat(values, 1f)), + "luminancetoalpha" => LuminanceToAlphaMatrix, + "matrix" when TryParseMatrixValues(values, out var explicitMatrix) => explicitMatrix, + _ => IdentityMatrix, + }; + + using var colorFilter = SKColorFilter.CreateColorMatrix(matrix); + return SKImageFilter.CreateColorFilter(colorFilter, input); + } + + private static SKImageFilter BuildDropShadow(IElement primitive, SKImageFilter? input) + { + var dx = ParseFloat(primitive.GetAttribute("dx"), 2f); + var dy = ParseFloat(primitive.GetAttribute("dy"), 2f); + var (sigmaX, sigmaY) = ParseStdDeviation(primitive.GetAttribute("stdDeviation"), defaultValue: 2f); + + var floodColorRaw = primitive.GetAttribute("flood-color"); + var color = SvgColorParsing.TryParsePaint(string.IsNullOrWhiteSpace(floodColorRaw) ? "black" : floodColorRaw, out var parsed) ? parsed : SKColors.Black; + + if (float.TryParse(primitive.GetAttribute("flood-opacity"), NumberStyles.Float, CultureInfo.InvariantCulture, out var floodOpacity)) + { + color = color.WithAlpha((byte)Math.Round(color.Alpha * Math.Clamp(floodOpacity, 0f, 1f))); + } + + return SKImageFilter.CreateDropShadow(dx, dy, sigmaX, sigmaY, color, input); + } + + private static (float SigmaX, float SigmaY) ParseStdDeviation(string? value, float defaultValue = 2f) + { + if (string.IsNullOrWhiteSpace(value)) + { + return (defaultValue, defaultValue); + } + + var parts = value.Trim().Split([' ', ','], StringSplitOptions.RemoveEmptyEntries); + var x = parts.Length > 0 && float.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var px) ? px : defaultValue; + var y = parts.Length > 1 && float.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var py) ? py : x; + + return (Math.Max(0f, x), Math.Max(0f, y)); + } + + private static float ParseFloat(string? value, float defaultValue) => + !string.IsNullOrWhiteSpace(value) && float.TryParse(value.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) + ? parsed + : defaultValue; + + private static bool TryParseMatrixValues(string? value, out float[] matrix) + { + matrix = IdentityMatrix; + + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var tokens = value.Trim().Split([' ', ',', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + + if (tokens.Length != 20) + { + return false; + } + + var parsed = new float[20]; + + for (var i = 0; i < 20; i++) + { + if (!float.TryParse(tokens[i], NumberStyles.Float, CultureInfo.InvariantCulture, out parsed[i])) + { + return false; + } + } + + matrix = parsed; + return true; + } + + private static float[] CreateSaturateMatrix(float s) + { + // The SVG spec's saturate color matrix (a standard luminance-preserving desaturation). + return + [ + 0.213f + (0.787f * s), 0.715f - (0.715f * s), 0.072f - (0.072f * s), 0f, 0f, + 0.213f - (0.213f * s), 0.715f + (0.285f * s), 0.072f - (0.072f * s), 0f, 0f, + 0.213f - (0.213f * s), 0.715f - (0.715f * s), 0.072f + (0.928f * s), 0f, 0f, + 0f, 0f, 0f, 1f, 0f, + ]; + } + + private static readonly float[] IdentityMatrix = + [ + 1f, 0f, 0f, 0f, 0f, + 0f, 1f, 0f, 0f, 0f, + 0f, 0f, 1f, 0f, 0f, + 0f, 0f, 0f, 1f, 0f, + ]; + + private static readonly float[] LuminanceToAlphaMatrix = + [ + 0f, 0f, 0f, 0f, 0f, + 0f, 0f, 0f, 0f, 0f, + 0f, 0f, 0f, 0f, 0f, + 0.2125f, 0.7154f, 0.0721f, 0f, 0f, + ]; +} diff --git a/src/AngleSharp.Renderer/Skia/Svg/SvgGeometry.cs b/src/AngleSharp.Renderer/Skia/Svg/SvgGeometry.cs new file mode 100644 index 0000000..713d903 --- /dev/null +++ b/src/AngleSharp.Renderer/Skia/Svg/SvgGeometry.cs @@ -0,0 +1,97 @@ +namespace AngleSharp.Renderer.Skia.Svg; + +using AngleSharp.Dom; + +using SkiaSharp; + +/// +/// Computes the (approximate) geometric bounding box of an element's rendered content, in its own +/// local user space - the "object bounding box" `mask`'s default region and gradient +/// `objectBoundingBox` units are defined against. Descends into `g`/`a`/`svg` and resolved `use` +/// targets; text content is not measured and contributes no bounds, a documented simplification. +/// +internal static class SvgGeometry +{ + public static SKRect? ComputeBounds(IElement element, SvgViewport viewport, SvgRenderContext context) => + ComputeBounds(element, viewport, context, []); + + private static SKRect? ComputeBounds(IElement element, SvgViewport viewport, SvgRenderContext context, HashSet visited) + { + if (!visited.Add(element)) + { + return null; + } + + try + { + var localBounds = ComputeLocalBounds(element, viewport, context, visited); + + if (localBounds is not { } bounds) + { + return null; + } + + var transform = SvgTransformParser.Parse(element.GetAttribute("transform")); + return transform.IsIdentity ? bounds : transform.MapRect(bounds); + } + finally + { + visited.Remove(element); + } + } + + private static SKRect? ComputeLocalBounds(IElement element, SvgViewport viewport, SvgRenderContext context, HashSet visited) + { + var tag = element.LocalName.ToLowerInvariant(); + + switch (tag) + { + case "g": + case "a": + case "svg": + case "symbol": + return UnionChildren(element.Children, viewport, context, visited); + + case "use": + { + var href = SvgUrlReference.GetHref(element); + + if (href is not { Length: > 1 } || !href.StartsWith('#') || !context.ElementsById.TryGetValue(href[1..], out var referenced)) + { + return null; + } + + var x = SvgShapeBuilder.ReadX(element, "x", viewport); + var y = SvgShapeBuilder.ReadY(element, "y", viewport); + var referencedBounds = ComputeBounds(referenced, viewport, context, visited); + + return referencedBounds is { } rb ? SKRect.Create(rb.Left + x, rb.Top + y, rb.Width, rb.Height) : null; + } + + default: + using (var path = SvgShapeBuilder.Build(element, viewport)) + { + return path is { IsEmpty: false } ? path.Bounds : null; + } + } + } + + private static SKRect? UnionChildren(IEnumerable children, SvgViewport viewport, SvgRenderContext context, HashSet visited) + { + SKRect? union = null; + + foreach (var child in children) + { + var childBounds = ComputeBounds(child, viewport, context, visited); + + if (childBounds is not { } bounds) + { + continue; + } + + union = union is { } existing ? SKRect.Union(existing, bounds) : bounds; + } + + return union; + } +} diff --git a/src/AngleSharp.Renderer/Skia/Svg/SvgGradientBuilder.cs b/src/AngleSharp.Renderer/Skia/Svg/SvgGradientBuilder.cs new file mode 100644 index 0000000..1dd2f12 --- /dev/null +++ b/src/AngleSharp.Renderer/Skia/Svg/SvgGradientBuilder.cs @@ -0,0 +1,255 @@ +namespace AngleSharp.Renderer.Skia.Svg; + +using System.Globalization; + +using AngleSharp.Dom; + +using SkiaSharp; + +/// +/// Builds an from a <linearGradient>/<radialGradient> element, +/// resolving `href`/`xlink:href` chains for inherited stops and attributes the way SVG paint +/// servers do. +/// +internal static class SvgGradientBuilder +{ + public static SKShader? Build(IElement gradientElement, SKRect boundingBox, IReadOnlyDictionary elementsById) + { + var stops = ResolveStops(gradientElement, elementsById); + + if (stops.Count == 0) + { + return null; + } + + if (stops.Count == 1) + { + // A single-stop gradient paints as a solid color; SKShader.CreateLinearGradient needs + // at least two color/position pairs to be well-defined. + return SKShader.CreateColor(stops[0].Color); + } + + var colors = new SKColor[stops.Count]; + var positions = new float[stops.Count]; + + for (var i = 0; i < stops.Count; i++) + { + colors[i] = stops[i].Color; + positions[i] = stops[i].Offset; + } + + var isObjectBoundingBox = !string.Equals( + GetInheritedAttribute(gradientElement, "gradientUnits", elementsById), + "userSpaceOnUse", + StringComparison.OrdinalIgnoreCase); + + var tileMode = GetInheritedAttribute(gradientElement, "spreadMethod", elementsById)?.ToLowerInvariant() switch + { + "reflect" => SKShaderTileMode.Mirror, + "repeat" => SKShaderTileMode.Repeat, + _ => SKShaderTileMode.Clamp, + }; + + var gradientTransform = SvgTransformParser.Parse(GetInheritedAttribute(gradientElement, "gradientTransform", elementsById)); + + var isRadial = string.Equals(gradientElement.LocalName, "radialGradient", StringComparison.OrdinalIgnoreCase); + + return isRadial + ? BuildRadial(gradientElement, boundingBox, isObjectBoundingBox, gradientTransform, colors, positions, tileMode, elementsById) + : BuildLinear(gradientElement, boundingBox, isObjectBoundingBox, gradientTransform, colors, positions, tileMode, elementsById); + } + + private static SKShader BuildLinear( + IElement element, + SKRect boundingBox, + bool isObjectBoundingBox, + SKMatrix gradientTransform, + SKColor[] colors, + float[] positions, + SKShaderTileMode tileMode, + IReadOnlyDictionary elementsById) + { + var x1 = ResolveCoordinate(GetInheritedAttribute(element, "x1", elementsById), 0f, isObjectBoundingBox, boundingBox.Left, boundingBox.Width); + var y1 = ResolveCoordinate(GetInheritedAttribute(element, "y1", elementsById), 0f, isObjectBoundingBox, boundingBox.Top, boundingBox.Height); + var x2 = ResolveCoordinate(GetInheritedAttribute(element, "x2", elementsById), 1f, isObjectBoundingBox, boundingBox.Left, boundingBox.Width); + var y2 = ResolveCoordinate(GetInheritedAttribute(element, "y2", elementsById), 0f, isObjectBoundingBox, boundingBox.Top, boundingBox.Height); + + var p1 = gradientTransform.MapPoint(x1, y1); + var p2 = gradientTransform.MapPoint(x2, y2); + + return SKShader.CreateLinearGradient(p1, p2, colors, positions, tileMode); + } + + private static SKShader BuildRadial( + IElement element, + SKRect boundingBox, + bool isObjectBoundingBox, + SKMatrix gradientTransform, + SKColor[] colors, + float[] positions, + SKShaderTileMode tileMode, + IReadOnlyDictionary elementsById) + { + var cx = ResolveCoordinate(GetInheritedAttribute(element, "cx", elementsById), 0.5f, isObjectBoundingBox, boundingBox.Left, boundingBox.Width); + var cy = ResolveCoordinate(GetInheritedAttribute(element, "cy", elementsById), 0.5f, isObjectBoundingBox, boundingBox.Top, boundingBox.Height); + var r = ResolveCoordinate(GetInheritedAttribute(element, "r", elementsById), 0.5f, isObjectBoundingBox, 0f, Math.Max(boundingBox.Width, boundingBox.Height)); + + var fxRaw = GetInheritedAttribute(element, "fx", elementsById); + var fyRaw = GetInheritedAttribute(element, "fy", elementsById); + var fx = fxRaw is null ? cx : ResolveCoordinate(fxRaw, 0.5f, isObjectBoundingBox, boundingBox.Left, boundingBox.Width); + var fy = fyRaw is null ? cy : ResolveCoordinate(fyRaw, 0.5f, isObjectBoundingBox, boundingBox.Top, boundingBox.Height); + + var center = gradientTransform.MapPoint(cx, cy); + var focal = gradientTransform.MapPoint(fx, fy); + + // Scale a unit radius through the transform so a non-uniform gradientTransform (or an + // objectBoundingBox mapping onto a non-square box) still produces a roughly elliptical + // falloff rather than only ever a uniformly-scaled circle. + var edge = gradientTransform.MapPoint(cx + r, cy); + var radius = Distance(center, edge); + + if (radius <= 0f) + { + return SKShader.CreateColor(colors[^1]); + } + + return (focal.X == center.X && focal.Y == center.Y) + ? SKShader.CreateRadialGradient(center, radius, colors, positions, tileMode) + : SKShader.CreateTwoPointConicalGradient(focal, 0f, center, radius, colors, positions, tileMode); + } + + private static float Distance(SKPoint a, SKPoint b) => (float)Math.Sqrt(Math.Pow(b.X - a.X, 2) + Math.Pow(b.Y - a.Y, 2)); + + private static float ResolveCoordinate(string? raw, float defaultFraction, bool isObjectBoundingBox, float boxOrigin, float boxSize) + { + var fraction = ParseFractionOrNumber(raw, defaultFraction); + return isObjectBoundingBox ? boxOrigin + (fraction * boxSize) : fraction; + } + + private static float ParseFractionOrNumber(string? raw, float defaultValue) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return defaultValue; + } + + var trimmed = raw.Trim(); + + if (trimmed.EndsWith('%') && float.TryParse(trimmed[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out var percent)) + { + return percent / 100f; + } + + return float.TryParse(trimmed, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ? value : defaultValue; + } + + private static List<(float Offset, SKColor Color)> ResolveStops(IElement start, IReadOnlyDictionary elementsById) + { + var visited = new HashSet(); + var current = start; + + while (current is not null && visited.Add(current)) + { + var stops = ReadStops(current); + + if (stops.Count > 0) + { + return stops; + } + + current = SvgUrlReference.GetHref(current) is { } href && href.StartsWith('#') && elementsById.TryGetValue(href[1..], out var next) + ? next + : null; + } + + return []; + } + + private static List<(float Offset, SKColor Color)> ReadStops(IElement gradientElement) + { + var stops = new List<(float, SKColor)>(); + var previousOffset = 0f; + + foreach (var child in gradientElement.Children) + { + if (!string.Equals(child.LocalName, "stop", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var offset = Math.Clamp(ParseFractionOrNumber(child.GetAttribute("offset"), previousOffset), 0f, 1f); + offset = Math.Max(offset, previousOffset); + previousOffset = offset; + + var declarations = ReadStopDeclarations(child); + var colorValue = declarations.TryGetValue("stop-color", out var explicitColor) ? explicitColor : "black"; + SvgColorParsing.TryParsePaint(colorValue, out var color); + + var opacity = declarations.TryGetValue("stop-opacity", out var opacityValue) && float.TryParse(opacityValue, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedOpacity) + ? Math.Clamp(parsedOpacity, 0f, 1f) + : 1f; + + stops.Add((offset, color.WithAlpha((byte)Math.Round(color.Alpha * opacity)))); + } + + return stops; + } + + private static Dictionary ReadStopDeclarations(IElement stopElement) + { + var declarations = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (stopElement.GetAttribute("stop-color") is { Length: > 0 } stopColor) + { + declarations["stop-color"] = stopColor; + } + + if (stopElement.GetAttribute("stop-opacity") is { Length: > 0 } stopOpacity) + { + declarations["stop-opacity"] = stopOpacity; + } + + var style = stopElement.GetAttribute("style"); + + if (string.IsNullOrWhiteSpace(style)) + { + return declarations; + } + + foreach (var declaration in style.Split(';', StringSplitOptions.RemoveEmptyEntries)) + { + var colonIndex = declaration.IndexOf(':'); + + if (colonIndex <= 0) + { + continue; + } + + declarations[declaration[..colonIndex].Trim()] = declaration[(colonIndex + 1)..].Trim(); + } + + return declarations; + } + + private static string? GetInheritedAttribute(IElement start, string attribute, IReadOnlyDictionary elementsById) + { + var visited = new HashSet(); + var current = start; + + while (current is not null && visited.Add(current)) + { + var value = current.GetAttribute(attribute); + + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + + current = SvgUrlReference.GetHref(current) is { } href && href.StartsWith('#') && elementsById.TryGetValue(href[1..], out var next) + ? next + : null; + } + + return null; + } +} diff --git a/src/AngleSharp.Renderer/Skia/Svg/SvgLength.cs b/src/AngleSharp.Renderer/Skia/Svg/SvgLength.cs new file mode 100644 index 0000000..d2ad036 --- /dev/null +++ b/src/AngleSharp.Renderer/Skia/Svg/SvgLength.cs @@ -0,0 +1,51 @@ +namespace AngleSharp.Renderer.Skia.Svg; + +using System.Globalization; + +/// +/// The current SVG viewport (in user-space units) that percentage lengths resolve against. The +/// root <svg> establishes the first one; a nested <svg> or a <symbol> referenced +/// through <use> establishes a new one for its own subtree. +/// +internal readonly record struct SvgViewport(float Width, float Height) +{ + /// + /// The reference length percentages on non-axis-specific properties (`r`, `stroke-width`, ...) + /// resolve against - the length of the viewport diagonal divided by sqrt(2), per the SVG spec. + /// + public float DiagonalReference => (float)(Math.Sqrt((double)(Width * Width) + (Height * Height)) / Math.Sqrt(2)); +} + +/// +/// Resolves an SVG/CSS length that may be a plain number, a `px` value, or a percentage of some +/// reference dimension (a viewport axis, or the viewport diagonal for non-axis-specific lengths). +/// +internal static class SvgLength +{ + public static float? ParseOptional(string? value, float referenceDimension) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var trimmed = value.Trim(); + + if (trimmed.EndsWith('%')) + { + return float.TryParse(trimmed[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out var percent) + ? percent / 100f * referenceDimension + : null; + } + + if (trimmed.EndsWith("px", StringComparison.OrdinalIgnoreCase)) + { + trimmed = trimmed[..^2]; + } + + return float.TryParse(trimmed, NumberStyles.Float, CultureInfo.InvariantCulture, out var number) ? number : null; + } + + public static float Parse(string? value, float referenceDimension, float fallback) => + ParseOptional(value, referenceDimension) ?? fallback; +} diff --git a/src/AngleSharp.Renderer/Skia/Svg/SvgPaintState.cs b/src/AngleSharp.Renderer/Skia/Svg/SvgPaintState.cs new file mode 100644 index 0000000..f7fa1e3 --- /dev/null +++ b/src/AngleSharp.Renderer/Skia/Svg/SvgPaintState.cs @@ -0,0 +1,386 @@ +namespace AngleSharp.Renderer.Skia.Svg; + +using System.Globalization; +using System.Linq; + +using AngleSharp.Dom; + +using SkiaSharp; + +/// +/// Text alignment relative to the position an SVG <text>/<tspan> was given. +/// +internal enum SvgTextAnchor +{ + Start, + Middle, + End, +} + +/// +/// What a `fill`/`stroke` value resolved to: unpainted, a solid color, or a `url(#id)` reference +/// to a paint server (a gradient) that needs the element's own geometry to finish resolving. +/// +internal readonly record struct SvgPaintValue(SvgPaintKind Kind, SKColor Color, string? ReferenceId) +{ + public static SvgPaintValue None { get; } = new(SvgPaintKind.None, SKColors.Transparent, null); + + public static SvgPaintValue FromColor(SKColor color) => new(SvgPaintKind.Color, color, null); + + public static SvgPaintValue FromReference(string id) => new(SvgPaintKind.Reference, SKColors.Transparent, id); + + public static SvgPaintValue Parse(string raw, SvgPaintValue fallback, SKColor currentColor) + { + var trimmed = raw.Trim(); + + if (SvgUrlReference.TryExtract(trimmed, out var id)) + { + return FromReference(id); + } + + if (trimmed.Equals("currentColor", StringComparison.OrdinalIgnoreCase)) + { + return FromColor(currentColor); + } + + return SvgColorParsing.TryParsePaint(trimmed, out var color) ? FromColor(color) : None; + } +} + +internal enum SvgPaintKind +{ + None, + Color, + Reference, +} + +/// +/// The inherited paint and font state while walking an SVG element tree. Presentation attributes +/// inherit down the tree by default; an SVG-internal <style> rule overrides them, and an +/// inline `style=""` declaration on the element overrides both, matching the CSS cascade. +/// +internal readonly record struct SvgPaintState( + SvgPaintValue Fill, + float FillOpacity, + SvgPaintValue Stroke, + float StrokeOpacity, + float StrokeWidth, + float Opacity, + SKPathFillType FillRule, + string FontFamily, + float FontSize, + float FontWeight, + bool IsItalic, + SvgTextAnchor TextAnchor, + SKColor CurrentColor) +{ + public static SvgPaintState Initial { get; } = new( + Fill: SvgPaintValue.FromColor(SKColors.Black), + FillOpacity: 1f, + Stroke: SvgPaintValue.None, + StrokeOpacity: 1f, + StrokeWidth: 1f, + Opacity: 1f, + FillRule: SKPathFillType.Winding, + FontFamily: "sans-serif", + FontSize: 16f, + FontWeight: 400f, + IsItalic: false, + TextAnchor: SvgTextAnchor.Start, + CurrentColor: SKColors.Black); + + /// + /// Resolves the paint state for , inheriting from this instance and + /// layering in, from weakest to strongest: presentation attributes, matched + /// SVG-internal <style> rules, then an inline `style` attribute. + /// + public SvgPaintState Resolve(IElement element, SvgRenderContext context, SvgViewport viewport) + { + var declarations = ReadDeclarations(element, context); + + // `color` is resolved first so `currentColor` on this same element's fill/stroke picks up + // its own computed value, not the inherited one, matching the CSS `currentColor` keyword. + var currentColor = CurrentColor; + + if (TryGetDeclaration(declarations, "color", out var colorValue) && SvgColorParsing.TryParsePaint(colorValue, out var parsedColor)) + { + currentColor = parsedColor; + } + + var fill = TryGetDeclaration(declarations, "fill", out var fillValue) ? SvgPaintValue.Parse(fillValue, Fill, currentColor) : Fill; + var stroke = TryGetDeclaration(declarations, "stroke", out var strokeValue) ? SvgPaintValue.Parse(strokeValue, Stroke, currentColor) : Stroke; + + var fillOpacity = FillOpacity; + + if (TryGetDeclaration(declarations, "fill-opacity", out var fillOpacityValue) && + TryParseOpacity(fillOpacityValue, out var parsedFillOpacity)) + { + fillOpacity = parsedFillOpacity; + } + + var strokeOpacity = StrokeOpacity; + + if (TryGetDeclaration(declarations, "stroke-opacity", out var strokeOpacityValue) && + TryParseOpacity(strokeOpacityValue, out var parsedStrokeOpacity)) + { + strokeOpacity = parsedStrokeOpacity; + } + + var strokeWidth = StrokeWidth; + + if (TryGetDeclaration(declarations, "stroke-width", out var strokeWidthValue)) + { + strokeWidth = SvgLength.Parse(strokeWidthValue, viewport.DiagonalReference, strokeWidth); + } + + // Element opacity composites with a translucent group in a real SVG renderer (via an + // isolated layer); multiplying it directly into fill/stroke alpha is a simplification + // that is exact for opaque descendants and close enough for the common icon/logo case. + var opacity = Opacity; + + if (TryGetDeclaration(declarations, "opacity", out var opacityValue) && + TryParseOpacity(opacityValue, out var parsedOpacity)) + { + opacity *= parsedOpacity; + } + + var fillRule = FillRule; + + if (TryGetDeclaration(declarations, "fill-rule", out var fillRuleValue)) + { + fillRule = string.Equals(fillRuleValue.Trim(), "evenodd", StringComparison.OrdinalIgnoreCase) + ? SKPathFillType.EvenOdd + : SKPathFillType.Winding; + } + + var fontFamily = TryGetDeclaration(declarations, "font-family", out var fontFamilyValue) ? fontFamilyValue : FontFamily; + + var fontSize = FontSize; + + if (TryGetDeclaration(declarations, "font-size", out var fontSizeValue)) + { + fontSize = ParseFontSize(fontSizeValue, FontSize); + } + + var fontWeight = FontWeight; + + if (TryGetDeclaration(declarations, "font-weight", out var fontWeightValue)) + { + fontWeight = ParseFontWeight(fontWeightValue, FontWeight); + } + + var isItalic = IsItalic; + + if (TryGetDeclaration(declarations, "font-style", out var fontStyleValue)) + { + var normalized = fontStyleValue.Trim(); + isItalic = normalized.Equals("italic", StringComparison.OrdinalIgnoreCase) || normalized.Equals("oblique", StringComparison.OrdinalIgnoreCase); + } + + var textAnchor = TextAnchor; + + if (TryGetDeclaration(declarations, "text-anchor", out var textAnchorValue)) + { + textAnchor = textAnchorValue.Trim().ToLowerInvariant() switch + { + "middle" => SvgTextAnchor.Middle, + "end" => SvgTextAnchor.End, + _ => SvgTextAnchor.Start, + }; + } + + return new SvgPaintState(fill, fillOpacity, stroke, strokeOpacity, strokeWidth, opacity, fillRule, fontFamily, fontSize, fontWeight, isItalic, textAnchor, currentColor); + } + + public SKPaint? CreateFillPaint(SKRect bounds, SvgRenderContext context, SvgViewport viewport) => + CreatePaint(Fill, FillOpacity, SKPaintStyle.Fill, bounds, context, viewport); + + public SKPaint? CreateStrokePaint(SKRect bounds, SvgRenderContext context, SvgViewport viewport) + { + if (StrokeWidth <= 0f) + { + return null; + } + + var paint = CreatePaint(Stroke, StrokeOpacity, SKPaintStyle.Stroke, bounds, context, viewport); + + if (paint is not null) + { + paint.StrokeWidth = StrokeWidth; + } + + return paint; + } + + private SKPaint? CreatePaint(SvgPaintValue paintValue, float paintOpacity, SKPaintStyle style, SKRect bounds, SvgRenderContext context, SvgViewport viewport) + { + var alpha = Math.Clamp(paintOpacity * Opacity, 0f, 1f); + + switch (paintValue.Kind) + { + case SvgPaintKind.None: + return null; + + case SvgPaintKind.Color: + return new SKPaint + { + Style = style, + IsAntialias = true, + Color = paintValue.Color.WithAlpha((byte)Math.Round(paintValue.Color.Alpha * alpha)), + }; + + case SvgPaintKind.Reference when paintValue.ReferenceId is { } id && context.ElementsById.TryGetValue(id, out var paintServer): + var shader = string.Equals(paintServer.LocalName, "pattern", StringComparison.OrdinalIgnoreCase) + ? SvgPatternBuilder.Build(paintServer, bounds, context, viewport) + : SvgGradientBuilder.Build(paintServer, bounds, context.ElementsById); + + if (shader is null) + { + return null; + } + + // A shader ignores the paint's RGB, but its alpha still modulates every sample the + // shader produces - that is how fill-opacity/opacity reach a gradient or pattern fill. + return new SKPaint + { + Style = style, + IsAntialias = true, + Shader = shader, + Color = SKColors.White.WithAlpha((byte)Math.Round(255 * alpha)), + }; + + default: + // A url() reference to a nonexistent or unsupported paint server paints nothing, + // per the SVG spec, rather than falling back to some default color. + return null; + } + } + + private static float ParseFontSize(string value, float inherited) + { + var trimmed = value.Trim(); + + if (trimmed.EndsWith('%') && float.TryParse(trimmed[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out var percent)) + { + return inherited * percent / 100f; + } + + if (trimmed.EndsWith("px", StringComparison.OrdinalIgnoreCase)) + { + trimmed = trimmed[..^2]; + } + + return float.TryParse(trimmed, NumberStyles.Float, CultureInfo.InvariantCulture, out var number) && number > 0f ? number : inherited; + } + + private static float ParseFontWeight(string value, float inherited) + { + var trimmed = value.Trim(); + + return trimmed.ToLowerInvariant() switch + { + "normal" => 400f, + "bold" => 700f, + "bolder" => Math.Min(900f, inherited + 300f), + "lighter" => Math.Max(100f, inherited - 300f), + _ when float.TryParse(trimmed, NumberStyles.Float, CultureInfo.InvariantCulture, out var numeric) => numeric, + _ => inherited, + }; + } + + private static bool TryGetDeclaration(Dictionary declarations, string name, out string value) + { + if (declarations.TryGetValue(name, out var found) && !string.IsNullOrWhiteSpace(found)) + { + value = found; + return true; + } + + value = string.Empty; + return false; + } + + private static bool TryParseOpacity(string value, out float opacity) + { + var trimmed = value.Trim(); + + if (trimmed.EndsWith('%') && float.TryParse(trimmed[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out var percent)) + { + opacity = Math.Clamp(percent / 100f, 0f, 1f); + return true; + } + + if (float.TryParse(trimmed, NumberStyles.Float, CultureInfo.InvariantCulture, out var raw)) + { + opacity = Math.Clamp(raw, 0f, 1f); + return true; + } + + opacity = 1f; + return false; + } + + private static readonly string[] PresentationProperties = + [ + "fill", "stroke", "fill-opacity", "stroke-opacity", "stroke-width", "opacity", "fill-rule", + "font-family", "font-size", "font-weight", "font-style", "text-anchor", "color", + ]; + + private static Dictionary ReadDeclarations(IElement element, SvgRenderContext context) + { + var declarations = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // 1. Presentation attributes - weakest precedence. + foreach (var property in PresentationProperties) + { + AddIfPresent(declarations, property, element.GetAttribute(property)); + } + + // 2. SVG-internal {svgMarkup}"; + + try + { + var context = BrowsingContext.New(Configuration.Default); + var document = context.OpenAsync(request => request.Content(wrapped)).GetAwaiter().GetResult(); + var svgRoot = document.QuerySelector("svg"); + + if (svgRoot is null) + { + return false; + } + + return TryRasterizeElement(svgRoot, out pngBytes, out naturalWidth, out naturalHeight); + } + catch + { + return false; + } + } + + /// + /// Rasterizes an SVG element that was already parsed as part of a larger document (an inline + /// <svg> in the host HTML) - the DOM is walked directly, nothing is re-parsed. + /// + public static bool TryRasterizeElement(IElement svgRoot, out byte[] pngBytes, out int naturalWidth, out int naturalHeight) + { + pngBytes = []; + naturalWidth = 0; + naturalHeight = 0; + + try + { + var (viewBoxMatrix, width, height, contentViewport) = ResolveViewport(svgRoot); + + if (width <= 0f || height <= 0f) + { + return false; + } + + naturalWidth = (int)Math.Max(1, Math.Round(width)); + naturalHeight = (int)Math.Max(1, Math.Round(height)); + + var scale = OversampleFactor; + var maxNatural = Math.Max(width, height); + + if (maxNatural * scale > MaxRasterDimension) + { + scale = Math.Max(1f, MaxRasterDimension / maxNatural); + } + + var pixelWidth = Math.Max(1, (int)Math.Round(width * scale)); + var pixelHeight = Math.Max(1, (int)Math.Round(height * scale)); + + using var bitmap = new SKBitmap(pixelWidth, pixelHeight, SKColorType.Rgba8888, SKAlphaType.Premul); + bitmap.Erase(SKColors.Transparent); + + var renderContext = SvgRenderContext.Build(svgRoot); + + using (var canvas = new SKCanvas(bitmap)) + { + canvas.Scale(scale, scale); + canvas.Concat(ref viewBoxMatrix); + SvgElementRenderer.Render(canvas, svgRoot, SvgPaintState.Initial, renderContext, contentViewport); + canvas.Flush(); + } + + using var image = SKImage.FromBitmap(bitmap); + using var data = image.Encode(SKEncodedImageFormat.Png, 100); + + if (data is null) + { + return false; + } + + pngBytes = data.ToArray(); + return pngBytes.Length > 0; + } + catch + { + return false; + } + } + + private static (SKMatrix ViewBoxMatrix, float Width, float Height, SvgViewport ContentViewport) ResolveViewport(IElement svgRoot) + { + var viewBox = SvgViewBoxMapping.ParseViewBox(svgRoot.GetAttribute("viewBox")); + var explicitWidth = ParseLength(svgRoot.GetAttribute("width")); + var explicitHeight = ParseLength(svgRoot.GetAttribute("height")); + + float width; + float height; + + if (explicitWidth is { } specifiedWidth && explicitHeight is { } specifiedHeight) + { + width = specifiedWidth; + height = specifiedHeight; + } + else if (explicitWidth is { } widthOnly) + { + width = widthOnly; + height = viewBox is { } vbForHeight && vbForHeight.Width > 0f ? widthOnly * (vbForHeight.Height / vbForHeight.Width) : widthOnly; + } + else if (explicitHeight is { } heightOnly) + { + height = heightOnly; + width = viewBox is { } vbForWidth && vbForWidth.Height > 0f ? heightOnly * (vbForWidth.Width / vbForWidth.Height) : heightOnly; + } + else if (viewBox is { } vbOnly) + { + width = vbOnly.Width; + height = vbOnly.Height; + } + else + { + width = DefaultNaturalSize; + height = DefaultNaturalSize / 2f; + } + + var (align, meet) = SvgViewBoxMapping.ParsePreserveAspectRatio(svgRoot.GetAttribute("preserveAspectRatio")); + var matrix = SvgViewBoxMapping.ComputeMatrix(viewBox, width, height, align, meet); + var contentViewport = viewBox is { } box ? new SvgViewport(box.Width, box.Height) : new SvgViewport(width, height); + + return (matrix, width, height, contentViewport); + } + + private static float? ParseLength(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var trimmed = value.Trim(); + + if (trimmed.EndsWith('%')) + { + return null; + } + + if (trimmed.EndsWith("px", StringComparison.OrdinalIgnoreCase)) + { + trimmed = trimmed[..^2]; + } + + return float.TryParse(trimmed, NumberStyles.Float, CultureInfo.InvariantCulture, out var number) && number > 0f + ? number + : null; + } +} From 4d7aeeddf8e36b9fddca19406d466c28972fc4ac Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Mon, 7 Sep 2026 13:19:22 +0200 Subject: [PATCH 21/24] Improved gradient rendering --- AGENTS.md | 10 + CHANGELOG.md | 1 + .../VisualConformanceTests.cs | 196 ++++++++ ...ers-closest-side-radial-gradient.macos.png | Bin 0 -> 3432 bytes ...conic-gradient-with-degree-stops.macos.png | Bin 0 -> 389 bytes ...fault-elliptical-radial-gradient.macos.png | Bin 0 -> 3842 bytes ...ositioned-circle-radial-gradient.macos.png | Bin 0 -> 5346 bytes ...renders-repeating-conic-gradient.macos.png | Bin 0 -> 1651 bytes ...enders-repeating-linear-gradient.macos.png | Bin 0 -> 388 bytes ...enders-repeating-radial-gradient.macos.png | Bin 0 -> 1589 bytes src/AngleSharp.Renderer/HtmlRenderer.cs | 459 ++++++++++++++++-- .../Rendering/RenderPaint.cs | 65 ++- .../Skia/SkiaRenderBackend.cs | 177 +++++-- 13 files changed, 848 insertions(+), 60 deletions(-) create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-closest-side-radial-gradient.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-conic-gradient-with-degree-stops.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-default-elliptical-radial-gradient.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-positioned-circle-radial-gradient.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-conic-gradient.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-linear-gradient.macos.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-radial-gradient.macos.png diff --git a/AGENTS.md b/AGENTS.md index d256732..94cae8d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,6 +60,16 @@ Supported elements: `rect`/`circle`/`ellipse`/`line`/`polyline`/`polygon`/`path` Not implemented, and silently skipped rather than approximated: `feFlood`/`feComposite`/`feTurbulence`/`feDisplacementMap`/`feTile`/`feImage`/`feComponentTransfer`/`feConvolveMatrix`/`feDiffuseLighting`/`feSpecularLighting`/`feMorphology` filter primitives (pass their input through unchanged), a `filter`'s own region clipping (`x`/`y`/`width`/`height`/`filterUnits` - the effect is not clipped to the nominal -10%/120% region), `SourceAlpha` as distinct from `SourceGraphic` in a filter chain, percentages in `points` (the SVG spec disallows them there too), text measurement contributing to `SvgGeometry.ComputeBounds` (a ``/gradient/pattern bounding box that depends on text extent falls back to "no bounds", which skips the mask-region clip rather than guessing), and CSS attribute selectors/pseudo-classes/child/sibling combinators in an SVG ` + + +
+ + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 200, + ViewPortHeight = 100, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-default-elliptical-radial-gradient.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersPositionedCircleRadialGradient() + { + var document = await ParseAsync(""" + + + + + +
+ + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 120, + ViewPortHeight = 120, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-positioned-circle-radial-gradient.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersClosestSideRadialGradient() + { + var document = await ParseAsync(""" + + + + + +
+ + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 120, + ViewPortHeight = 120, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-closest-side-radial-gradient.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersConicGradientWithDegreeStops() + { + var document = await ParseAsync(""" + + + + + +
+ + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 120, + ViewPortHeight = 120, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-conic-gradient-with-degree-stops.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersRepeatingLinearGradient() + { + var document = await ParseAsync(""" + + + + + +
+ + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 120, + ViewPortHeight = 120, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-repeating-linear-gradient.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersRepeatingRadialGradient() + { + var document = await ParseAsync(""" + + + + + +
+ + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 120, + ViewPortHeight = 120, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-repeating-radial-gradient.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + + [Fact] + public async Task RenderToPng_RendersRepeatingConicGradient() + { + var document = await ParseAsync(""" + + + + + +
+ + + """); + + var renderer = new HtmlRenderer(); + var image = renderer.RenderToPng(document, new DefaultRenderDevice + { + ViewPortWidth = 120, + ViewPortHeight = 120, + }); + + VisualSnapshotVerifier.VerifyOrCreate( + snapshotName: "renders-repeating-conic-gradient.png", + actualPng: image.Data, + perChannelTolerance: 0, + maxDifferentPixels: 0); + } + [Fact] public async Task RenderToPng_RendersSimpleTableLayout() { diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-closest-side-radial-gradient.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-closest-side-radial-gradient.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..879099581506d79a29cd36833fb699888b90a2f1 GIT binary patch literal 3432 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 A8UO$Q literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-conic-gradient.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-conic-gradient.macos.png new file mode 100644 index 0000000000000000000000000000000000000000..155850bff1c89f42a3abeaa8daaace5435e3a36b GIT binary patch literal 1651 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 nEV1 0) + var isCircle = false; + var sizeKind = RenderGradientSizeKind.FarthestCorner; + float? explicitRadiusX = null; + float? explicitRadiusY = null; + var centerX = 0.5f; + var centerY = 0.5f; + + if (parts.Length > 0 && LooksLikeRadialConfiguration(parts[0])) { - var first = parts[0].Trim(); - if (first.StartsWith("circle", StringComparison.OrdinalIgnoreCase) || first.StartsWith("ellipse", StringComparison.OrdinalIgnoreCase)) + var configText = parts[0].Trim(); + startIndex = 1; + + var atIndex = configText.IndexOf(" at ", StringComparison.OrdinalIgnoreCase); + var shapeSizeText = atIndex >= 0 ? configText[..atIndex].Trim() : configText; + var positionText = atIndex >= 0 ? configText[(atIndex + 4)..].Trim() : null; + + ParseRadialShapeAndSize(shapeSizeText, out isCircle, out sizeKind, out explicitRadiusX, out explicitRadiusY); + + if (positionText is not null) { - startIndex = 1; + (centerX, centerY) = ParsePosition(positionText); } } var stops = ParseGradientStops(parts.Skip(startIndex).ToArray(), fallbackColor); - return new RenderGradient(RenderGradientKind.Radial, stops); + return new RenderGradient( + RenderGradientKind.Radial, + stops, + CenterX: centerX, + CenterY: centerY, + IsCircle: isCircle, + Repeating: repeating, + SizeKind: sizeKind, + ExplicitRadiusX: explicitRadiusX, + ExplicitRadiusY: explicitRadiusY); } - private static RenderGradient ParseConicGradient(string rawValue, RenderColor fallbackColor) + private static RenderGradient ParseConicGradient(string rawValue, string functionName, bool repeating, RenderColor fallbackColor) { - var inner = ExtractGradientInnerExpression(rawValue, "conic-gradient"); + var inner = ExtractGradientInnerExpression(rawValue, functionName); var parts = SplitGradientArguments(inner); var startIndex = 0; var angleDegrees = 0f; + var centerX = 0.5f; + var centerY = 0.5f; if (parts.Length > 0) { var first = parts[0].Trim(); - if (first.StartsWith("from", StringComparison.OrdinalIgnoreCase)) + + if (first.StartsWith("from", StringComparison.OrdinalIgnoreCase) || first.StartsWith("at", StringComparison.OrdinalIgnoreCase)) { - angleDegrees = ParseAngle(first[4..].Trim()); + var atIndex = first.IndexOf(" at ", StringComparison.OrdinalIgnoreCase); + var fromText = atIndex >= 0 ? first[..atIndex].Trim() : first; + var positionText = atIndex >= 0 + ? first[(atIndex + 4)..].Trim() + : (first.StartsWith("at", StringComparison.OrdinalIgnoreCase) ? first[2..].Trim() : null); + + if (fromText.StartsWith("from", StringComparison.OrdinalIgnoreCase)) + { + angleDegrees = ParseAngle(fromText[4..].Trim()); + } + + if (positionText is not null) + { + (centerX, centerY) = ParsePosition(positionText); + } + startIndex = 1; } } - var stops = ParseGradientStops(parts.Skip(startIndex).ToArray(), fallbackColor); - return new RenderGradient(RenderGradientKind.Conic, stops, AngleDegrees: angleDegrees); + var stops = ParseGradientStops(parts.Skip(startIndex).ToArray(), fallbackColor, isConic: true); + return new RenderGradient(RenderGradientKind.Conic, stops, AngleDegrees: angleDegrees, CenterX: centerX, CenterY: centerY, Repeating: repeating); + } + + /// + /// Distinguishes a radial-gradient's leading `<ending-shape> || <size> [at + /// <position>]` configuration clause from what is actually just its first color stop - + /// a color stop never starts with a shape/size keyword, "at", or a bare length. + /// + private static bool LooksLikeRadialConfiguration(string part) + { + var lower = part.Trim().ToLowerInvariant(); + + return lower.StartsWith("circle", StringComparison.Ordinal) || + lower.StartsWith("ellipse", StringComparison.Ordinal) || + lower.StartsWith("closest-", StringComparison.Ordinal) || + lower.StartsWith("farthest-", StringComparison.Ordinal) || + lower.StartsWith("at ", StringComparison.Ordinal) || + lower.Contains(" at ", StringComparison.Ordinal) || + (TryParsePixelValue(lower.Split(' ')[0], out _) && !lower.Contains(',')); + } + + private static void ParseRadialShapeAndSize(string text, out bool isCircle, out RenderGradientSizeKind sizeKind, out float? explicitRadiusX, out float? explicitRadiusY) + { + isCircle = false; + sizeKind = RenderGradientSizeKind.FarthestCorner; + explicitRadiusX = null; + explicitRadiusY = null; + + if (string.IsNullOrWhiteSpace(text)) + { + return; + } + + var tokens = text.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var lengths = new List(); + + foreach (var token in tokens) + { + switch (token.ToLowerInvariant()) + { + case "circle": + isCircle = true; + break; + case "ellipse": + isCircle = false; + break; + case "closest-side": + sizeKind = RenderGradientSizeKind.ClosestSide; + break; + case "farthest-side": + sizeKind = RenderGradientSizeKind.FarthestSide; + break; + case "closest-corner": + sizeKind = RenderGradientSizeKind.ClosestCorner; + break; + case "farthest-corner": + sizeKind = RenderGradientSizeKind.FarthestCorner; + break; + default: + if (TryParsePixelValue(token, out var pixels)) + { + lengths.Add(pixels); + } + + break; + } + } + + if (lengths.Count > 0) + { + sizeKind = RenderGradientSizeKind.Explicit; + explicitRadiusX = lengths[0]; + explicitRadiusY = lengths.Count > 1 ? lengths[1] : lengths[0]; + + if (lengths.Count == 1) + { + // A single explicit length implies a circle - CSS grammar doesn't allow one + // length with an explicit "ellipse" keyword (that needs two lengths). + isCircle = true; + } + } + } + + private static readonly string[] PositionKeywords = ["left", "right", "top", "bottom", "center"]; + + /// + /// Parses a CSS `<position>` value (1-2 tokens, keywords and/or percentages, in either + /// order for keywords) into fractional (0-1) X/Y coordinates. Absolute lengths (`at 20px + /// 10px`) and the 4-value edge-offset syntax are not supported and fall back to center. + /// + private static (float X, float Y) ParsePosition(string text) + { + var tokens = text.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); + + if (tokens.Length == 0) + { + return (0.5f, 0.5f); + } + + float? x = null; + float? y = null; + + foreach (var token in tokens) + { + switch (token.ToLowerInvariant()) + { + case "left": + x = 0f; + break; + case "right": + x = 1f; + break; + case "top": + y = 0f; + break; + case "bottom": + y = 1f; + break; + } + } + + var positionalTokens = tokens.Where(token => !PositionKeywords.Contains(token.ToLowerInvariant())).ToArray(); + var assignedX = x is not null; + + foreach (var token in positionalTokens) + { + var value = ParseStopPosition(token); + + if (!assignedX) + { + x = value; + assignedX = true; + } + else if (y is null) + { + y = value; + } + } + + return (x ?? 0.5f, y ?? 0.5f); } private static string ExtractGradientInnerExpression(string rawValue, string functionName) @@ -3656,7 +3855,7 @@ private static string[] SplitGradientArguments(string value) return parts.ToArray(); } - private static IReadOnlyList ParseGradientStops(string[] parts, RenderColor fallbackColor) + private static IReadOnlyList ParseGradientStops(string[] parts, RenderColor fallbackColor, bool isConic = false) { if (parts.Length == 0) { @@ -3678,17 +3877,29 @@ private static IReadOnlyList ParseGradientStops(string[] par var positionToken = separatorIndex >= 0 ? part[(separatorIndex + 1)..].Trim() : string.Empty; var color = ParseColor(colorToken, fallbackColor); - var position = string.IsNullOrWhiteSpace(positionToken) - ? (parts.Length == 1 ? 0f : (index / (float)Math.Max(1, parts.Length - 1))) - : ParseStopPosition(positionToken); + var autoPosition = parts.Length == 1 ? 0f : (index / (float)Math.Max(1, parts.Length - 1)); - stops.Add(new RenderGradientStop(position, color)); + if (string.IsNullOrWhiteSpace(positionToken)) + { + stops.Add(new RenderGradientStop(autoPosition, color)); + } + else if (!isConic && TryParsePixelValue(positionToken, out var pixels)) + { + // An absolute-length stop position ("red 10px") cannot become a fraction until + // the gradient's own rendered geometry (line length/radius) is known, so the raw + // pixel value is carried through and resolved by the backend at paint time. + stops.Add(new RenderGradientStop(autoPosition, color, pixels)); + } + else + { + stops.Add(new RenderGradientStop(ParseStopPosition(positionToken, isConic), color)); + } } return stops; } - private static float ParseStopPosition(string rawPosition) + private static float ParseStopPosition(string rawPosition, bool isConic = false) { var value = rawPosition.Trim(); if (string.IsNullOrWhiteSpace(value)) @@ -3696,6 +3907,14 @@ private static float ParseStopPosition(string rawPosition) return 0f; } + // conic-gradient stops are naturally written as angles ("90deg", "0.25turn"), which are a + // fraction of the full circle rather than of a linear 0-100% run - resolve those first. + if (isConic && TryParseAngle(value, out var angleDegrees)) + { + var normalizedDegrees = ((angleDegrees % 360f) + 360f) % 360f; + return Math.Clamp(normalizedDegrees / 360f, 0f, 1f); + } + if (value.EndsWith("%", StringComparison.Ordinal) && float.TryParse(value[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out var percent)) { @@ -3739,16 +3958,32 @@ private static bool TryParseDirection(string value, out float angleDegrees) private static bool TryParseAngle(string value, out float angleDegrees) { angleDegrees = 90f; + var trimmed = value.Trim(); - if (value.EndsWith("deg", StringComparison.Ordinal) && - float.TryParse(value[..^3].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var degrees)) + if (trimmed.EndsWith("deg", StringComparison.Ordinal) && + float.TryParse(trimmed[..^3].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var degrees)) { angleDegrees = degrees; return true; } - if (value.EndsWith("rad", StringComparison.Ordinal) && - float.TryParse(value[..^3].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var radians)) + if (trimmed.EndsWith("grad", StringComparison.Ordinal) && + float.TryParse(trimmed[..^4].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var gradians)) + { + angleDegrees = gradians * 0.9f; + return true; + } + + if (trimmed.EndsWith("turn", StringComparison.Ordinal) && + float.TryParse(trimmed[..^4].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var turns)) + { + angleDegrees = turns * 360f; + return true; + } + + // Checked after "grad" - "grad" also ends with "rad" and would otherwise be misread here. + if (trimmed.EndsWith("rad", StringComparison.Ordinal) && + float.TryParse(trimmed[..^3].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var radians)) { angleDegrees = radians * 180f / (float)Math.PI; return true; @@ -3827,9 +4062,173 @@ private static RenderColor ParseColor(string? rawColor, RenderColor fallback) return RenderColor.Transparent; } + // Regular box/text colors are normalized by AngleSharp.Css's own computed-style engine + // before they ever reach this method (it resolves "red" to an rgb()/hex form itself), so + // this named-color table only matters for values this renderer parses from raw CSS text + // itself - chiefly gradient stop colors, since a `background-image: linear-gradient(...)` + // function's internals are opaque to AngleSharp.Css and are hand-parsed here instead. + // Without it, every named gradient stop color silently fell back to the same color, + // producing an invisible (fallback-to-fallback) "gradient". + if (NamedColors.TryGetValue(color, out var named)) + { + return named; + } + return fallback; } + private static readonly IReadOnlyDictionary NamedColors = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["aliceblue"] = new RenderColor(240, 248, 255), + ["antiquewhite"] = new RenderColor(250, 235, 215), + ["aqua"] = new RenderColor(0, 255, 255), + ["aquamarine"] = new RenderColor(127, 255, 212), + ["azure"] = new RenderColor(240, 255, 255), + ["beige"] = new RenderColor(245, 245, 220), + ["bisque"] = new RenderColor(255, 228, 196), + ["black"] = new RenderColor(0, 0, 0), + ["blanchedalmond"] = new RenderColor(255, 235, 205), + ["blue"] = new RenderColor(0, 0, 255), + ["blueviolet"] = new RenderColor(138, 43, 226), + ["brown"] = new RenderColor(165, 42, 42), + ["burlywood"] = new RenderColor(222, 184, 135), + ["cadetblue"] = new RenderColor(95, 158, 160), + ["chartreuse"] = new RenderColor(127, 255, 0), + ["chocolate"] = new RenderColor(210, 105, 30), + ["coral"] = new RenderColor(255, 127, 80), + ["cornflowerblue"] = new RenderColor(100, 149, 237), + ["cornsilk"] = new RenderColor(255, 248, 220), + ["crimson"] = new RenderColor(220, 20, 60), + ["cyan"] = new RenderColor(0, 255, 255), + ["darkblue"] = new RenderColor(0, 0, 139), + ["darkcyan"] = new RenderColor(0, 139, 139), + ["darkgoldenrod"] = new RenderColor(184, 134, 11), + ["darkgray"] = new RenderColor(169, 169, 169), + ["darkgreen"] = new RenderColor(0, 100, 0), + ["darkgrey"] = new RenderColor(169, 169, 169), + ["darkkhaki"] = new RenderColor(189, 183, 107), + ["darkmagenta"] = new RenderColor(139, 0, 139), + ["darkolivegreen"] = new RenderColor(85, 107, 47), + ["darkorange"] = new RenderColor(255, 140, 0), + ["darkorchid"] = new RenderColor(153, 50, 204), + ["darkred"] = new RenderColor(139, 0, 0), + ["darksalmon"] = new RenderColor(233, 150, 122), + ["darkseagreen"] = new RenderColor(143, 188, 143), + ["darkslateblue"] = new RenderColor(72, 61, 139), + ["darkslategray"] = new RenderColor(47, 79, 79), + ["darkslategrey"] = new RenderColor(47, 79, 79), + ["darkturquoise"] = new RenderColor(0, 206, 209), + ["darkviolet"] = new RenderColor(148, 0, 211), + ["deeppink"] = new RenderColor(255, 20, 147), + ["deepskyblue"] = new RenderColor(0, 191, 255), + ["dimgray"] = new RenderColor(105, 105, 105), + ["dimgrey"] = new RenderColor(105, 105, 105), + ["dodgerblue"] = new RenderColor(30, 144, 255), + ["firebrick"] = new RenderColor(178, 34, 34), + ["floralwhite"] = new RenderColor(255, 250, 240), + ["forestgreen"] = new RenderColor(34, 139, 34), + ["fuchsia"] = new RenderColor(255, 0, 255), + ["gainsboro"] = new RenderColor(220, 220, 220), + ["ghostwhite"] = new RenderColor(248, 248, 255), + ["gold"] = new RenderColor(255, 215, 0), + ["goldenrod"] = new RenderColor(218, 165, 32), + ["gray"] = new RenderColor(128, 128, 128), + ["green"] = new RenderColor(0, 128, 0), + ["greenyellow"] = new RenderColor(173, 255, 47), + ["grey"] = new RenderColor(128, 128, 128), + ["honeydew"] = new RenderColor(240, 255, 240), + ["hotpink"] = new RenderColor(255, 105, 180), + ["indianred"] = new RenderColor(205, 92, 92), + ["indigo"] = new RenderColor(75, 0, 130), + ["ivory"] = new RenderColor(255, 255, 240), + ["khaki"] = new RenderColor(240, 230, 140), + ["lavender"] = new RenderColor(230, 230, 250), + ["lavenderblush"] = new RenderColor(255, 240, 245), + ["lawngreen"] = new RenderColor(124, 252, 0), + ["lemonchiffon"] = new RenderColor(255, 250, 205), + ["lightblue"] = new RenderColor(173, 216, 230), + ["lightcoral"] = new RenderColor(240, 128, 128), + ["lightcyan"] = new RenderColor(224, 255, 255), + ["lightgoldenrodyellow"] = new RenderColor(250, 250, 210), + ["lightgray"] = new RenderColor(211, 211, 211), + ["lightgreen"] = new RenderColor(144, 238, 144), + ["lightgrey"] = new RenderColor(211, 211, 211), + ["lightpink"] = new RenderColor(255, 182, 193), + ["lightsalmon"] = new RenderColor(255, 160, 122), + ["lightseagreen"] = new RenderColor(32, 178, 170), + ["lightskyblue"] = new RenderColor(135, 206, 250), + ["lightslategray"] = new RenderColor(119, 136, 153), + ["lightslategrey"] = new RenderColor(119, 136, 153), + ["lightsteelblue"] = new RenderColor(176, 196, 222), + ["lightyellow"] = new RenderColor(255, 255, 224), + ["lime"] = new RenderColor(0, 255, 0), + ["limegreen"] = new RenderColor(50, 205, 50), + ["linen"] = new RenderColor(250, 240, 230), + ["magenta"] = new RenderColor(255, 0, 255), + ["maroon"] = new RenderColor(128, 0, 0), + ["mediumaquamarine"] = new RenderColor(102, 205, 170), + ["mediumblue"] = new RenderColor(0, 0, 205), + ["mediumorchid"] = new RenderColor(186, 85, 211), + ["mediumpurple"] = new RenderColor(147, 112, 219), + ["mediumseagreen"] = new RenderColor(60, 179, 113), + ["mediumslateblue"] = new RenderColor(123, 104, 238), + ["mediumspringgreen"] = new RenderColor(0, 250, 154), + ["mediumturquoise"] = new RenderColor(72, 209, 204), + ["mediumvioletred"] = new RenderColor(199, 21, 133), + ["midnightblue"] = new RenderColor(25, 25, 112), + ["mintcream"] = new RenderColor(245, 255, 250), + ["mistyrose"] = new RenderColor(255, 228, 225), + ["moccasin"] = new RenderColor(255, 228, 181), + ["navajowhite"] = new RenderColor(255, 222, 173), + ["navy"] = new RenderColor(0, 0, 128), + ["oldlace"] = new RenderColor(253, 245, 230), + ["olive"] = new RenderColor(128, 128, 0), + ["olivedrab"] = new RenderColor(107, 142, 35), + ["orange"] = new RenderColor(255, 165, 0), + ["orangered"] = new RenderColor(255, 69, 0), + ["orchid"] = new RenderColor(218, 112, 214), + ["palegoldenrod"] = new RenderColor(238, 232, 170), + ["palegreen"] = new RenderColor(152, 251, 152), + ["paleturquoise"] = new RenderColor(175, 238, 238), + ["palevioletred"] = new RenderColor(219, 112, 147), + ["papayawhip"] = new RenderColor(255, 239, 213), + ["peachpuff"] = new RenderColor(255, 218, 185), + ["peru"] = new RenderColor(205, 133, 63), + ["pink"] = new RenderColor(255, 192, 203), + ["plum"] = new RenderColor(221, 160, 221), + ["powderblue"] = new RenderColor(176, 224, 230), + ["purple"] = new RenderColor(128, 0, 128), + ["rebeccapurple"] = new RenderColor(102, 51, 153), + ["red"] = new RenderColor(255, 0, 0), + ["rosybrown"] = new RenderColor(188, 143, 143), + ["royalblue"] = new RenderColor(65, 105, 225), + ["saddlebrown"] = new RenderColor(139, 69, 19), + ["salmon"] = new RenderColor(250, 128, 114), + ["sandybrown"] = new RenderColor(244, 164, 96), + ["seagreen"] = new RenderColor(46, 139, 87), + ["seashell"] = new RenderColor(255, 245, 238), + ["sienna"] = new RenderColor(160, 82, 45), + ["silver"] = new RenderColor(192, 192, 192), + ["skyblue"] = new RenderColor(135, 206, 235), + ["slateblue"] = new RenderColor(106, 90, 205), + ["slategray"] = new RenderColor(112, 128, 144), + ["slategrey"] = new RenderColor(112, 128, 144), + ["snow"] = new RenderColor(255, 250, 250), + ["springgreen"] = new RenderColor(0, 255, 127), + ["steelblue"] = new RenderColor(70, 130, 180), + ["tan"] = new RenderColor(210, 180, 140), + ["teal"] = new RenderColor(0, 128, 128), + ["thistle"] = new RenderColor(216, 191, 216), + ["tomato"] = new RenderColor(255, 99, 71), + ["turquoise"] = new RenderColor(64, 224, 208), + ["violet"] = new RenderColor(238, 130, 238), + ["wheat"] = new RenderColor(245, 222, 179), + ["white"] = new RenderColor(255, 255, 255), + ["whitesmoke"] = new RenderColor(245, 245, 245), + ["yellow"] = new RenderColor(255, 255, 0), + ["yellowgreen"] = new RenderColor(154, 205, 50), + }; + private static bool TryParseColorChannel(string raw, out byte value) { var token = raw.Trim(); diff --git a/src/AngleSharp.Renderer/Rendering/RenderPaint.cs b/src/AngleSharp.Renderer/Rendering/RenderPaint.cs index afd109d..5989cd6 100644 --- a/src/AngleSharp.Renderer/Rendering/RenderPaint.cs +++ b/src/AngleSharp.Renderer/Rendering/RenderPaint.cs @@ -18,6 +18,17 @@ public sealed record RenderGradientPaint(RenderGradient Gradient) : RenderPaint; /// /// Describes a gradient definition. /// +/// The gradient's shape: linear, radial, or conic. +/// The ordered color stops along the gradient. +/// The gradient's angle in degrees, for linear and conic gradients. +/// Fractional center X (0-1) within the painted box, for radial/conic gradients. +/// Fractional center Y (0-1) within the painted box, for radial/conic gradients. +/// Unused; retained for source compatibility. Radial sizing is governed by . +/// Whether a radial gradient uses a circular (vs. the CSS-default elliptical) ending shape. +/// Whether the gradient repeats past its defined extent (`repeating-*-gradient`). +/// How a radial gradient's ending shape is sized when not given explicit radii. +/// An explicit radial gradient radius (or the X radius of an explicit ellipse), in pixels. +/// An explicit radial gradient's Y radius, in pixels, when it differs from . public sealed record RenderGradient( RenderGradientKind Kind, IReadOnlyList Stops, @@ -25,12 +36,29 @@ public sealed record RenderGradient( float CenterX = 0.5f, float CenterY = 0.5f, float Radius = 0.5f, - bool IsCircle = false); + bool IsCircle = false, + bool Repeating = false, + RenderGradientSizeKind SizeKind = RenderGradientSizeKind.FarthestCorner, + float? ExplicitRadiusX = null, + float? ExplicitRadiusY = null); /// /// Describes a single gradient stop. /// -public sealed record RenderGradientStop(float Position, RenderColor Color); +/// +/// The stop's position as a 0-1 fraction. When the stop was given in an absolute length (`10px`) +/// rather than a percentage, this holds the value that was in effect before +/// could be resolved (typically 0) and is not the position +/// actually painted with - the backend resolves against the +/// gradient's own geometry once that is known, since a stop's absolute-length position cannot be +/// turned into a fraction until the gradient's rendered size is. +/// +/// The stop's color. +/// +/// The stop's position as an absolute length in pixels (`red 10px`), if it was given as one +/// rather than a percentage/unitless fraction; otherwise . +/// +public sealed record RenderGradientStop(float Position, RenderColor Color, float? AbsolutePositionPixels = null); /// /// Describes the available gradient kinds. @@ -52,3 +80,36 @@ public enum RenderGradientKind /// Conic, } + +/// +/// Describes how a radial gradient's ending shape is sized, matching the CSS `<extent-keyword>` +/// values (or an explicit radius/radii). +/// +public enum RenderGradientSizeKind +{ + /// + /// The ending shape meets the corner of the box farthest from its center. The CSS default. + /// + FarthestCorner, + + /// + /// The ending shape meets the side of the box closest to its center. + /// + ClosestSide, + + /// + /// The ending shape meets the side of the box farthest from its center. + /// + FarthestSide, + + /// + /// The ending shape meets the corner of the box closest to its center. + /// + ClosestCorner, + + /// + /// The ending shape uses an explicit radius ( / + /// ) rather than one derived from the box. + /// + Explicit, +} diff --git a/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs b/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs index 0dd9c43..1ec32d4 100644 --- a/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs +++ b/src/AngleSharp.Renderer/Skia/SkiaRenderBackend.cs @@ -106,51 +106,172 @@ private static SKPaint CreateGradientPaint(RenderGradient gradient, RenderRect r }; var colors = gradient.Stops.Select(stop => new SKColor(stop.Color.R, stop.Color.G, stop.Color.B, stop.Color.A)).ToArray(); - var positions = gradient.Stops.Select(stop => stop.Position).ToArray(); - var centerX = rect.X + (rect.Width / 2f); - var centerY = rect.Y + (rect.Height / 2f); - var radius = (float)Math.Max(rect.Width, rect.Height) / 2f; + var centerX = rect.X + (gradient.CenterX * rect.Width); + var centerY = rect.Y + (gradient.CenterY * rect.Height); + var diagonal = (float)Math.Sqrt((rect.Width * rect.Width) + (rect.Height * rect.Height)); + var (unscaledRx, unscaledRy) = gradient.Kind == RenderGradientKind.Radial + ? ComputeRadialRadii(gradient, rect, centerX, centerY) + : (0f, 0f); + + // An absolute-length stop position ("red 10px") only becomes a fraction once the + // gradient's own rendered geometry is known - the gradient line's length for linear, the + // resolved (pre-repeat-scale) radius for radial. Conic stops use angles, never lengths. + var referenceLength = gradient.Kind switch + { + RenderGradientKind.Linear => diagonal, + RenderGradientKind.Radial => Math.Max(unscaledRx, unscaledRy), + _ => 0f, + }; + + var rawPositions = gradient.Stops + .Select(stop => stop.AbsolutePositionPixels is { } pixels && referenceLength > 0f + ? Math.Clamp(pixels / referenceLength, 0f, 1f) + : stop.Position) + .ToArray(); + + var tileMode = gradient.Repeating ? SKShaderTileMode.Repeat : SKShaderTileMode.Clamp; + + // A repeating-*-gradient's defined stops are one period of an infinitely repeated + // pattern. Rescaling every position by the last stop's position makes that period fill + // the shader's whole [0,1] domain; shrinking the shader's own geometric extent by the + // same factor (see each Create*Shader below) then gives Repeat room to tile the rest. + // This assumes the first stop is at/near 0%, the overwhelmingly common case - a repeating + // gradient whose first stop is well past 0% renders its period at the right cadence but + // not anchored at the exact original offset. + var repeatScale = 1f; + var positions = rawPositions; + + if (gradient.Repeating && rawPositions.Length > 0) + { + var maxPosition = rawPositions.Max(); + + if (maxPosition > 0f && maxPosition < 1f) + { + repeatScale = maxPosition; + positions = rawPositions.Select(position => position / maxPosition).ToArray(); + } + } paint.Shader = gradient.Kind switch { - RenderGradientKind.Linear => CreateLinearGradientShader(gradient, rect), - RenderGradientKind.Radial => SKShader.CreateRadialGradient( - new SKPoint(centerX, centerY), - radius, - colors, - positions, - SKShaderTileMode.Clamp), - RenderGradientKind.Conic => SKShader.CreateSweepGradient( - new SKPoint(centerX, centerY), - colors, - positions), + RenderGradientKind.Linear => CreateLinearGradientShader(gradient, centerX, centerY, diagonal, colors, positions, tileMode, repeatScale), + RenderGradientKind.Radial => CreateRadialGradientShader(centerX, centerY, unscaledRx, unscaledRy, colors, positions, tileMode, repeatScale), + RenderGradientKind.Conic => CreateConicGradientShader(gradient, centerX, centerY, colors, positions, tileMode, repeatScale), _ => throw new NotSupportedException($"Unsupported gradient kind: {gradient.Kind}"), }; return paint; } - private static SKShader CreateLinearGradientShader(RenderGradient gradient, RenderRect rect) + private static SKShader CreateLinearGradientShader(RenderGradient gradient, float centerX, float centerY, float diagonal, SKColor[] colors, float[] positions, SKShaderTileMode tileMode, float repeatScale) { - var centerX = rect.X + (rect.Width / 2f); - var centerY = rect.Y + (rect.Height / 2f); - var diagonal = Math.Sqrt((rect.Width * rect.Width) + (rect.Height * rect.Height)); - var halfDiagonal = (float)diagonal / 2f; + var halfDiagonal = diagonal / 2f; var radians = (gradient.AngleDegrees % 360f + 360f) % 360f; var angle = radians * (Math.PI / 180d); var dx = (float)Math.Cos(angle); var dy = (float)Math.Sin(angle); var start = new SKPoint(centerX - (dx * halfDiagonal), centerY - (dy * halfDiagonal)); - var end = new SKPoint(centerX + (dx * halfDiagonal), centerY + (dy * halfDiagonal)); - - return SKShader.CreateLinearGradient( - start, - end, - gradient.Stops.Select(stop => new SKColor(stop.Color.R, stop.Color.G, stop.Color.B, stop.Color.A)).ToArray(), - gradient.Stops.Select(stop => stop.Position).ToArray(), - SKShaderTileMode.Clamp); + var fullEnd = new SKPoint(centerX + (dx * halfDiagonal), centerY + (dy * halfDiagonal)); + var end = new SKPoint(start.X + ((fullEnd.X - start.X) * repeatScale), start.Y + ((fullEnd.Y - start.Y) * repeatScale)); + + return SKShader.CreateLinearGradient(start, end, colors, positions, tileMode); + } + + private static SKShader CreateRadialGradientShader(float centerX, float centerY, float unscaledRx, float unscaledRy, SKColor[] colors, float[] positions, SKShaderTileMode tileMode, float repeatScale) + { + var rx = Math.Max(0.0001f, unscaledRx * repeatScale); + var ry = Math.Max(0.0001f, unscaledRy * repeatScale); + + if (Math.Abs(rx - ry) < 0.01f) + { + // A circle - no elliptical distortion needed, so the plain center+radius overload + // (unambiguous, no matrix semantics to get backwards) is enough. + return SKShader.CreateRadialGradient(new SKPoint(centerX, centerY), rx, colors, positions, tileMode); + } + + // An ellipse: build the gradient as a unit circle at the origin, then use the + // constructor-time local-matrix overload to map it onto an ellipse of the right size at + // the right position. Verified empirically (not just by API docs) that this matrix maps + // the shader's local space directly into world space - i.e. this scales/positions the + // visible gradient exactly as constructed, not its inverse. + var matrix = new SKMatrix(rx, 0f, centerX, 0f, ry, centerY, 0f, 0f, 1f); + return SKShader.CreateRadialGradient(new SKPoint(0f, 0f), 1f, colors, positions, tileMode, matrix); + } + + /// + /// Resolves a radial gradient's ending-shape radii from its , + /// matching the CSS `<size>` keyword definitions (farthest-corner is the CSS default). + /// + private static (float Rx, float Ry) ComputeRadialRadii(RenderGradient gradient, RenderRect rect, float centerX, float centerY) + { + if (gradient.SizeKind == RenderGradientSizeKind.Explicit && gradient.ExplicitRadiusX is { } explicitX) + { + var explicitY = gradient.ExplicitRadiusY ?? explicitX; + return gradient.IsCircle ? (explicitX, explicitX) : (explicitX, explicitY); + } + + var nearestX = Math.Min(Math.Abs(centerX - rect.X), Math.Abs((rect.X + rect.Width) - centerX)); + var farthestX = Math.Max(Math.Abs(centerX - rect.X), Math.Abs((rect.X + rect.Width) - centerX)); + var nearestY = Math.Min(Math.Abs(centerY - rect.Y), Math.Abs((rect.Y + rect.Height) - centerY)); + var farthestY = Math.Max(Math.Abs(centerY - rect.Y), Math.Abs((rect.Y + rect.Height) - centerY)); + + if (gradient.IsCircle) + { + var radius = gradient.SizeKind switch + { + RenderGradientSizeKind.ClosestSide => Math.Min(nearestX, nearestY), + RenderGradientSizeKind.FarthestSide => Math.Max(farthestX, farthestY), + RenderGradientSizeKind.ClosestCorner => (float)Math.Sqrt((nearestX * nearestX) + (nearestY * nearestY)), + _ => (float)Math.Sqrt((farthestX * farthestX) + (farthestY * farthestY)), + }; + + return (radius, radius); + } + + return gradient.SizeKind switch + { + RenderGradientSizeKind.ClosestSide => (nearestX, nearestY), + RenderGradientSizeKind.FarthestSide => (farthestX, farthestY), + RenderGradientSizeKind.ClosestCorner => EllipseThroughCorner(nearestX, nearestY, rect.Width, rect.Height), + _ => EllipseThroughCorner(farthestX, farthestY, rect.Width, rect.Height), + }; + } + + /// + /// The semi-axes of an ellipse that shares the box's aspect ratio and passes through a corner + /// offset by (, ) from its center - the CSS + /// closest-corner/farthest-corner ellipse sizing algorithm. + /// + private static (float Rx, float Ry) EllipseThroughCorner(float dx, float dy, float boxWidth, float boxHeight) + { + if (boxWidth <= 0f || boxHeight <= 0f) + { + return (Math.Abs(dx), Math.Abs(dy)); + } + + var aspect = boxWidth / boxHeight; + var ry = (float)Math.Sqrt(((double)dx * dx / (aspect * aspect)) + ((double)dy * dy)); + var rx = aspect * ry; + + return (rx, ry); + } + + private static SKShader CreateConicGradientShader(RenderGradient gradient, float centerX, float centerY, SKColor[] colors, float[] positions, SKShaderTileMode tileMode, float repeatScale) + { + // Skia's sweep gradient silently degenerates to a zero-width (solid first-color) span + // once endAngle exceeds 360 - verified empirically, it does not treat e.g. [270,630] as + // "a full revolution starting at 270". So the shader's own angle span always stays a + // plain [0, 360*repeatScale], and CSS's "from " rotation - plus the fact that CSS + // conic-gradient's 0deg points up while Skia's sweep 0deg points right, both clockwise + // (also verified empirically) - is applied via the constructor-time rotation matrix + // instead, the same "shader-local-space maps directly into world-space" mechanism already + // used for elliptical radial gradients. + var rotation = SKMatrix.CreateRotationDegrees(gradient.AngleDegrees - 90f, centerX, centerY); + var endAngle = 360f * repeatScale; + + return SKShader.CreateSweepGradient(new SKPoint(centerX, centerY), colors, positions, tileMode, 0f, endAngle, rotation); } private static void DrawImage(SKCanvas canvas, DrawImageCommand command) From b0157356448ecd48b0f733fd1057c74cb76ed0a2 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Mon, 7 Sep 2026 13:28:59 +0200 Subject: [PATCH 22/24] Updated visuals --- ...ed-wrapped-text-with-line-height.macos.png | Bin 3621 -> 3619 bytes ...ed-text-sizes-styles-decorations.macos.png | Bin 3727 -> 3729 bytes .../renders-simple-table-layout.macos.png | Bin 1772 -> 1772 bytes ...s-table-with-colspan-and-rowspan.macos.png | Bin 4116 -> 4117 bytes .../text-indent-and-vertical-align.macos.png | Bin 5375 -> 5375 bytes .../web-safe-font-families.macos.png | Bin 9919 -> 9919 bytes 6 files changed, 0 insertions(+), 0 deletions(-) diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/aligned-wrapped-text-with-line-height.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/aligned-wrapped-text-with-line-height.macos.png index cf76e2a3f380f7e3ae7476dbdbc8aa1be787ad57..7bc827e28fa0d983d44bc8c0a312dd769d69b3de 100644 GIT binary patch delta 3152 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 delta 3417 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 ZuNc!Gm^m2SzYGC~u9m)Lsk&A0{{j6G*}wn* diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-simple-table-layout.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-simple-table-layout.macos.png index 0a4b0e7ea118f84053211dbaa15fad071f486ffe..601cffa7993baa036822cc9933b9fb6f40749ec8 100644 GIT binary patch delta 33 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 SbOg}ngY>kFG)pubpZy1X((Bv+ literal 4116 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 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/text-indent-and-vertical-align.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/text-indent-and-vertical-align.macos.png index c1309a5d3d014ff0dcd8a11815f2b1724afee359..1990c773afa0e060db26a09586167de9a93ae496 100644 GIT binary patch literal 5375 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 literal 5375 zcmcIoXE>W}-w%qqOV!@=R;4YqVzg!lRkf=|ZK_ctcI=V%rj*jsVNKY2Vj)2!T+Y z0mq-t(t-C?66-ziamMG4_M@}l6>`=w8UkUj($={BC?I`(Dmm*fQ!6cAw~t?` zG`w24kZIfXa9G#a$oRearRiOv)P8>bMB?8pTiZ1yR!L;l8RF8>KL1jKH%WD=!5}$> zM_3q%Y7)CBtjJ9JBKpQ(XIQ-0&kFy!azUfb=wftq?yUD^*~vD01Oiv%!w(fz24s zCOGbKIc&H)gLH5B{FlDC2Gvz^)k#CW^ReRwD`)nZ7+fey@v}?tG{WfQ9$8jQb<{(D zoAYcrtXKHv&6Fd`?ohsgP>7mPLPCOWmRd$kObjyA3}vGibzWd&KO}^lHZw03zOOSN z!z`fGhNPm2e*L;_iJXv}+=)ju3wlpE59O7*jmbh16BF<2>V6L1CHy_i@N_zf7SSyR_r+3D1h$pKA%EHGheru^8ut4xQloJL? z)%N3ujK{Rfg9i_maVQ~(b)B!*T;tKsgzwrY3sOrc^nGXN{f`ol@9Yw$#u@^lQ*{Wk zRkd-xe#jBtR!m(z>-dPmCMc*M5D;)5(a=YEFg(0t(QJ^V_F70iGdY=CR4=vd6`PpV zI+smAi!s>zqk_DX?(3FSh87ms(b6Z*lQlB6wY3s94axq~fl-HtAt7qE_sNM$c8ZvH z25Qd8g{7r>_}!@U@)%iJ*~zNJL{=IJdHG>Ds1FpQcCdV@8veUAlFmFy(t5J$^Jfv7 zVl;XHPQf)FKSf>^5s9$!pYKedGY7T6STu}mDiQ4p3)d^dG&Li?eEGs-2d@1wHPr

&nxoPuaP+ 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 literal 9919 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 Date: Mon, 7 Sep 2026 11:33:35 +0000 Subject: [PATCH 23/24] Regenerate visual snapshot baselines for all platforms --- ...ed-wrapped-text-with-line-height.macos.png | Bin 3619 -> 3621 bytes ...dia-rule-inside-inline-svg-style.linux.png | Bin 0 -> 355 bytes ...a-rule-inside-inline-svg-style.windows.png | Bin 0 -> 355 bytes ...ed-text-sizes-styles-decorations.macos.png | Bin 3729 -> 3727 bytes ...ers-closest-side-radial-gradient.linux.png | Bin 0 -> 3432 bytes ...s-closest-side-radial-gradient.windows.png | Bin 0 -> 3432 bytes ...conic-gradient-with-degree-stops.linux.png | Bin 0 -> 389 bytes ...nic-gradient-with-degree-stops.windows.png | Bin 0 -> 389 bytes ...fault-elliptical-radial-gradient.linux.png | Bin 0 -> 3842 bytes ...ult-elliptical-radial-gradient.windows.png | Bin 0 -> 3842 bytes .../renders-inline-svg-clip-path.linux.png | Bin 0 -> 1716 bytes .../renders-inline-svg-clip-path.windows.png | Bin 0 -> 1716 bytes ...renders-inline-svg-current-color.linux.png | Bin 0 -> 355 bytes ...nders-inline-svg-current-color.windows.png | Bin 0 -> 355 bytes ...-inline-svg-explicit-mask-region.linux.png | Bin 0 -> 336 bytes ...nline-svg-explicit-mask-region.windows.png | Bin 0 -> 336 bytes ...-inline-svg-gaussian-blur-filter.linux.png | Bin 0 -> 2958 bytes ...nline-svg-gaussian-blur-filter.windows.png | Bin 0 -> 2958 bytes ...s-inline-svg-internal-stylesheet.linux.png | Bin 0 -> 396 bytes ...inline-svg-internal-stylesheet.windows.png | Bin 0 -> 396 bytes ...nders-inline-svg-linear-gradient.linux.png | Bin 0 -> 350 bytes ...ers-inline-svg-linear-gradient.windows.png | Bin 0 -> 350 bytes .../renders-inline-svg-mask.linux.png | Bin 0 -> 1725 bytes .../renders-inline-svg-mask.windows.png | Bin 0 -> 1725 bytes ...rs-inline-svg-path-and-transform.linux.png | Bin 0 -> 1248 bytes ...-inline-svg-path-and-transform.windows.png | Bin 0 -> 1248 bytes .../renders-inline-svg-pattern-fill.linux.png | Bin 0 -> 367 bytes ...enders-inline-svg-pattern-fill.windows.png | Bin 0 -> 367 bytes ...rs-inline-svg-percentage-lengths.linux.png | Bin 0 -> 1671 bytes ...-inline-svg-percentage-lengths.windows.png | Bin 0 -> 1671 bytes ...-svg-preserve-aspect-ratio-slice.linux.png | Bin 0 -> 334 bytes ...vg-preserve-aspect-ratio-slice.windows.png | Bin 0 -> 334 bytes ...nders-inline-svg-radial-gradient.linux.png | Bin 0 -> 6002 bytes ...ers-inline-svg-radial-gradient.windows.png | Bin 0 -> 6002 bytes .../renders-inline-svg-shapes.linux.png | Bin 0 -> 1127 bytes .../renders-inline-svg-shapes.windows.png | Bin 0 -> 1127 bytes .../renders-inline-svg-text.linux.png | Bin 0 -> 616 bytes .../renders-inline-svg-text.windows.png | Bin 0 -> 628 bytes .../renders-inline-svg-use-element.linux.png | Bin 0 -> 1117 bytes ...renders-inline-svg-use-element.windows.png | Bin 0 -> 1117 bytes .../renders-nested-svg-viewport.linux.png | Bin 0 -> 1669 bytes .../renders-nested-svg-viewport.windows.png | Bin 0 -> 1669 bytes ...ositioned-circle-radial-gradient.linux.png | Bin 0 -> 5346 bytes ...itioned-circle-radial-gradient.windows.png | Bin 0 -> 5346 bytes ...renders-repeating-conic-gradient.linux.png | Bin 0 -> 1651 bytes ...nders-repeating-conic-gradient.windows.png | Bin 0 -> 1651 bytes ...enders-repeating-linear-gradient.linux.png | Bin 0 -> 388 bytes ...ders-repeating-linear-gradient.windows.png | Bin 0 -> 388 bytes ...enders-repeating-radial-gradient.linux.png | Bin 0 -> 1589 bytes ...ders-repeating-radial-gradient.windows.png | Bin 0 -> 1589 bytes .../renders-simple-table-layout.macos.png | Bin 1772 -> 1772 bytes .../renders-svg-image-source.linux.png | Bin 0 -> 339 bytes .../renders-svg-image-source.windows.png | Bin 0 -> 339 bytes .../renders-symbol-via-use.linux.png | Bin 0 -> 345 bytes .../renders-symbol-via-use.windows.png | Bin 0 -> 345 bytes ...s-table-with-colspan-and-rowspan.macos.png | Bin 4117 -> 4116 bytes .../text-indent-and-vertical-align.macos.png | Bin 5375 -> 5375 bytes .../web-safe-font-families.macos.png | Bin 9919 -> 9919 bytes 58 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/ignores-media-rule-inside-inline-svg-style.linux.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/ignores-media-rule-inside-inline-svg-style.windows.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-closest-side-radial-gradient.linux.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-closest-side-radial-gradient.windows.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-conic-gradient-with-degree-stops.linux.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-conic-gradient-with-degree-stops.windows.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-default-elliptical-radial-gradient.linux.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-default-elliptical-radial-gradient.windows.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-clip-path.linux.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-clip-path.windows.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-current-color.linux.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-current-color.windows.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-explicit-mask-region.linux.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-explicit-mask-region.windows.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-gaussian-blur-filter.linux.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-gaussian-blur-filter.windows.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-internal-stylesheet.linux.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-internal-stylesheet.windows.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-linear-gradient.linux.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-linear-gradient.windows.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-mask.linux.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-mask.windows.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-path-and-transform.linux.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-path-and-transform.windows.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-pattern-fill.linux.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-pattern-fill.windows.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-percentage-lengths.linux.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-percentage-lengths.windows.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-preserve-aspect-ratio-slice.linux.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-preserve-aspect-ratio-slice.windows.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-radial-gradient.linux.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-radial-gradient.windows.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-shapes.linux.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-shapes.windows.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-text.linux.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-text.windows.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-use-element.linux.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-use-element.windows.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-nested-svg-viewport.linux.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-nested-svg-viewport.windows.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-positioned-circle-radial-gradient.linux.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-positioned-circle-radial-gradient.windows.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-conic-gradient.linux.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-conic-gradient.windows.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-linear-gradient.linux.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-linear-gradient.windows.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-radial-gradient.linux.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-radial-gradient.windows.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-svg-image-source.linux.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-svg-image-source.windows.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-symbol-via-use.linux.png create mode 100644 src/AngleSharp.Renderer.Tests/verification-assets/renders-symbol-via-use.windows.png diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/aligned-wrapped-text-with-line-height.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/aligned-wrapped-text-with-line-height.macos.png index 7bc827e28fa0d983d44bc8c0a312dd769d69b3de..cf76e2a3f380f7e3ae7476dbdbc8aa1be787ad57 100644 GIT binary patch delta 3135 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 OWAJqKb6Mw<&;$Sr9Bfel literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/ignores-media-rule-inside-inline-svg-style.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/ignores-media-rule-inside-inline-svg-style.windows.png new file mode 100644 index 0000000000000000000000000000000000000000..03b7ed8e095da3c095e4b167918e3be14c69a001 GIT binary patch literal 355 zcmeAS@N?(olHy`uVBq!ia0vp^DImEakt zG3V`FMZUuhJPZe8rqqYm?rc8Xw``N%y OWAJqKb6Mw<&;$Sr9Bfel literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/mixed-text-sizes-styles-decorations.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/mixed-text-sizes-styles-decorations.macos.png index f254598d8762c2b49356736920c1a75d25f09a35..bef354c2337f559f32e94d53ff3fabef93e9d817 100644 GIT binary patch delta 3417 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 ZuNc!Gm^m2SzYGC~u9m)Lsk&A0{{j6G*}wn* delta 3416 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 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-closest-side-radial-gradient.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-closest-side-radial-gradient.linux.png new file mode 100644 index 0000000000000000000000000000000000000000..879099581506d79a29cd36833fb699888b90a2f1 GIT binary patch literal 3432 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 EAKv#5SpWb4 literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-clip-path.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-clip-path.windows.png new file mode 100644 index 0000000000000000000000000000000000000000..ab1938e0b4c88416004500cee5de59f8f30b06db GIT binary patch literal 1716 zcmZ{ldpr{g8^^~kX09{WTr#G~H59po4QtG`xkQdw(yUw-$7ECSo?JV%vGRsl6S+Gi z$0g)$LM+KCd?FgvaWr8e_3Hiq{63%O_j|s7J^wwwY+vt_arWHbR)J?c&b)!el=~X` z5pqi(zj@V)NvlLXvVuTyh@g)!cBZE9e`4b=NQxvZYLCJnnDVjuU|tTpR2MuWX*z-4 zh~SEK)FyG13ARU<8@Y`OhBsbkWkh=!c57d;4{2|RZ1Dp8JEmTZQr(H_*<7=ef_+Y+7kR3t5=_S&` z(Hq;w5j`IKU`X_r0Zy4ligaU4Yog+F255Ej^R%3WLsdTv^Gu66#O{L0OkXb_=RC+{ z_Lz}tZXnirn-QWqdaNxwF0I1||NLg$9IcyBhtRQfWxTh9eAN0P9SyV7TA7U}NW%Zg6F{Z}snwX-@ z?hPJjm7n_b%^Z0Dfez(Wu47vkeg7~o<-}ucT3t@-WQylTbmdI5(JY)a)IaGz0uSn? zdyK%3Z}N76>=s+~m%d4e$e)TTs-EUZJLEX-{RiwJ6QtIE){yHI@ z%>1?A$Gd^vYv(Ix${lJ$kY2~)w}|Ps{(G952OL!|9)tOzBO8bv+(K2$Lk(_RDf>Ha z{^+t*^+ZT`7qunvrEw~dhtHB5q9wPKEEovjU!&30ILI-q*;TO3)j*|Z1)+Z+rky3$ zv7`qR4JR=~hId~W)fB~2dqH!%G z$$$Bg>sj*phx;>xln*!%1f1_`i(K8DparzU(|9f5_N)0YB)Y6FusT_{ZN1Z9A^>k!;!9vB?AMO zmcq`?-09xg_a4~oJo-VEx8*S3$Gdask4#UQPcaC`;m=x1jm~h|zaZOGCKH4u&7k!u zFXohV8(K=|ZM{ixT=FH&QGTGR_o_x;X|r#KjuWbgJ*1TSOPTP=Imyk6_>q=u1`&gf zDOsr@y_6X?Os}S;cP6X2tEP?`p3-^4@st-o1)MLE7EYY+SsyFCZ8m>P(HLdfc%|6) zg$gXN`_XhLVQ)Fi`u>t9Ys1(lHr3hqvau^S)N^||wyTzH+d$#w6~vb0{{pKKKPmY* zzQ-!$2a7ph7Er81 zTPz#N!b3@t_K@YRK0yobM-D%v8gUnFRYA~uKl7mtfTs1l_L;ZSxIDyS4E*xfEN!)?u-ZzlS4kJ#^o zjYW4m6)pQ9B?hk@{40oTlr?v#*7^OH_iG5??&j@Uj}1@% EAKv#5SpWb4 literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-current-color.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-current-color.linux.png new file mode 100644 index 0000000000000000000000000000000000000000..03b7ed8e095da3c095e4b167918e3be14c69a001 GIT binary patch literal 355 zcmeAS@N?(olHy`uVBq!ia0vp^DImEakt zG3V`FMZUuhJPZe8rqqYm?rc8Xw``N%y OWAJqKb6Mw<&;$Sr9Bfel literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-current-color.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-current-color.windows.png new file mode 100644 index 0000000000000000000000000000000000000000..03b7ed8e095da3c095e4b167918e3be14c69a001 GIT binary patch literal 355 zcmeAS@N?(olHy`uVBq!ia0vp^DImEakt zG3V`FMZUuhJPZe8rqqYm?rc8Xw``N%y OWAJqKb6Mw<&;$Sr9Bfel literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-explicit-mask-region.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-explicit-mask-region.linux.png new file mode 100644 index 0000000000000000000000000000000000000000..fffc12cbbe4928cb2ac1baa0566fb94b8ec707ab GIT binary patch literal 336 zcmeAS@N?(olHy`uVBq!ia0vp^DImEakt zG3V{YgS-s}0?Y@Sk1hQC!H;##qM2MdZ%yB?u{==Kx5M!JedZgSLdqUJ3WiNbIEEkN a47=DYmLsP(Eakt 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 Ef(UG8J^%m! literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-gaussian-blur-filter.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-gaussian-blur-filter.windows.png new file mode 100644 index 0000000000000000000000000000000000000000..9b77ad0944b90c1a1aceb72361558396b934c7c5 GIT binary patch literal 2958 zcmV;93vu*`P)&t(RE56ImLuYcwUtEMMK;Z%qm_3j&z!@A6ixJxKDBs`CeSRLl$Hh`v+UXY;q0HyU z@9FpPoG}^U4fD2Kyvp!5QW+6!g{1Az|TY@B<4ua?cG3Je&u-v?0Z7=lp_ zb}$%=g)cWZ)dGM}R{(Qy;h#T~KvN&U^u`c2+A!EqP1|kg@b%gQ@b|ATc#XnAk`xeShS~}SfXym^7-i{y zW4ZLdb9);H{BZEkb1r~_-%KgV8DfC}JE1_lzWVa^7OwaVYKsj!cb0nZ&K-N@%n~-} z3dm*?K;P}EHcF!ckiI}k28d80)P*>5KAXOG$djfam=D#UIH`z{0R(!?SMYl(HaZAYaJlk zIYr_Bz9@YCX0uSwx;UBTY;ugu3g@Q?u_R-N+762yUcS!)AHJjEYw*lUXH@U+ff)eRT6{VM#;Lk8dpOvMD+6!M&^S8Gkz)V@ zmT-i)jkE$JW;@s$hq?ko00jFR%nY4srU1gKGX!=810Xg#vm8r~5;G`xW*7CZ)OXtD14?7V-8O86`-&RL;VM32seOdEF4CM%0f5i4Tvr_X}k)6Xu!~lO<8N>Lkd{_`RQM) zy1KdwV3ehAOIe0+JwW;TswP#mk;Z^nD}cP}2X<)D5QP=I8yXb-JqBjC3xJ?aH#dH8 z0TeI=?Z3PXOTg+>o-3`5x~>4~%Ad8^q&C9}PmCozvxBi`)?01T0miD=N-H`?T5G(R zcMd?f0qSR1YHDni#rN#w>deuK5Z47Hn@S^wXdHrAl;QyMcw*F0jC=Fzj=+G;8XAXKbCDYwmPAKD)a_cW1Cfansfrtw(o8AJFQ zZN<_6=d@3keFp`EE6jcfhq3GOsOr`-auAI|C@8TcJ`LNqZCfjb;K~tG1*L9{Ay;(; z(I~_@ZM4!`M`4J1>kNQS1&8v)Q;7On3`(rMDJCk708t$aFk%%}Fmh*xicVl+;8<;J z%aYbco!1i>6uw$(3_05mh*d`gLkD82>3k>05LXK%Yc};0z<|W+9cyE%_H~L)T9xGx z#7{8b=Ze+ovG=;jv`;-V=>$k@rz?`bn)=dgdd;fmj1#=y zl`XAQma-(4w-n1=`U#+L`lywkrETU^pe_TimCC}E*x73GbSsVm&KoHO9iKC6wRFeEl|IGGIhnoO2*Mv_`$Hd5QiS8bnaf~L~gsH>sF zPFGZzce=(h{Bkz%mTlC*hV-B7>ncmEtoT8Vy6y+J_JUiL&e5L4?1nYiP^|35u7jiDWmx0iuIM4a_LWczUd;N0)u{<>NPFjQTo4h40dW#NhrJsf;PH7`qNR91kJOJ7H8V9@B)wk(61 zTCD;Y4-Zv#+}y;y^U(o;s6D}Xg`D-r9jLQ2p8v1c{`&D@=gyv!1*wd%Lg<*X0*u?+ zs?7ixP*(thr@DOvN|T)igbskSi2Zh|YGTabHHOV5oZ8dJ@l33y*v9}EY&QS~HhC%- zU1L`&$ryr;VJ}rP`K-`3AKwZefX~dbPLqp)QU?YvEU}*fKzLRK6~(hEo#x`%+y^Nn zDMKJ&%6wk^+%l}Oec;&77uy(9rwT1K6+7Y&?6|wDI)MPn3L zvjd%k(;oUh1XE*F92(&OBACU@=xZ$}`5~AVC=3LzH&`3<^>9i(53%bpvHCk;3Bn;z zPrTyE-guv2IM)Lp5L8x^8q|jH=;f0VhB!!>qqcmIGP5GJCw@wL0YodT`Zneha7QT^ zsgD}xW5#(&)=FIHx=!9idSeJgnG$0Ok78@5YoCYlb0S9FVf=jp@vpyz_y_mah8Nu$ z##@hv{&yApe?=4s_4@mkK>%?szR`uZhGFD$$JZTH$*Ye48bpAoQ>g#Y1@^&8OKjXi{bND)x3=W8M$IG<39nLCX1FNYz zPo32lv&JeGBlvkk$|!)aSd76n7NCr6va}YW)O+XG&W+CqmC*rl&R*jIi->20&f-UGH-RKqU)?)QMHQb8CDG=o}?`K>lyiA;)f#K4bzU zGnoL%OeR1wlL?T_WCA2JnE=U5CO|Ti36RWW0wgo}4;K4`rmx$(asU7T07*qoM6N<$ Ef(UG8J^%m! literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-internal-stylesheet.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-internal-stylesheet.linux.png new file mode 100644 index 0000000000000000000000000000000000000000..8b356937812a64f8146f10668622bf8ea17285e7 GIT binary patch literal 396 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 ja!)hAEQqk5Y9#tE{eS^`T2w ztyQ~j+K^Zp?>A-j#x&LRR!V|FyM$boimIFb++TO@nK|b>Gv~~l$DH|2X&CwR0ey3Q z003}+fX9Vvk@;1@Ag$#n?w;2IFcnKU57x?6FtY>z0QnJc0p~MoRv*ej&#^26{fFbMM$kV)9NF*@gMCIqD%;t-KH*I*gM{%o26cHB7Skn70>)3| ziJ52=aA6oOS$A{YA2s7&G=R+6Ud0k>5@MH?EB|pgS-X(Ei2Fdrww6?1xnYObzICFjeDL`P9! zxTR6{w@OT+qlx0A+#bK8%NSLJCeEuZN$ZOa0eVeOd`qV;e!mH!UHW;-l=oyeWA$s_5s~yt zzZT0Mo0fg*Vp6Sw=l^sZ`#+K#}V0 z->(jOMXY}`=VuDrf_#9Z7bL$qFj^c-n(h}53jmFwbdzb9g&*YioZ{9uSK2Q0%80Nu z88Eut2zzwSN(oaT!l|<6gm?g;igcppcuEM~O9U;mF>Gk)J^NZv-@575Oc_|{UclD8 zfHGM(&%G85F6!sKG_z9Lz;8eBMlRq)at_JndIhy}Eim!@!E`4d5}GQ`L8Z9pJa*F9yHO=3dc5uAq-a|qPuuz#kP zcU8ueJic|RG`6hY|5OAQDHK|sjTFQumH&gcfW_6IB57TpsU#zpdb7=*pVN<@C10TyYSHd^mH)HMQ+{`giz; z+gS!{v$(!Z5qdBOq-aYI3L{9E5P zuD^f9Z!pMo-#E^o$_S-CIs{WeO;ZyHhNwDVyu^rK3=Ll@xCRX=i%nW{rFEo%(tSPU zR%yP6T(Iu!9JaZWbKI+)@T^263Rza~tc&Z~fG3RzSmea9>Oo4( zRX74MacyR^e#(^1b{thGIxk|bYdPhiJ1{s%3t)YgH^T%sh7W?S$LYJd6)0O?eIkQF?u^879eHz*3Lk;CFXGus ja!)hEakt zG3V{YgS-b61dbd~{`;LLU#p`vP3y+g>+^1YZuokv_vb&k1HKa^9Ftf&1y$UJA%pK+ X5%aMC8BQf&05N#F`njxgN@xNAwEA7O literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-preserve-aspect-ratio-slice.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-preserve-aspect-ratio-slice.windows.png new file mode 100644 index 0000000000000000000000000000000000000000..fb617d8fc2b04325efba454139987d580bcf6cba GIT binary patch literal 334 zcmeAS@N?(olHy`uVBq!ia0vp^DImEakt zG3V{YgS-b61dbd~{`;LLU#p`vP3y+g>+^1YZuokv_vb&k1HKa^9Ftf&1y$UJA%pK+ X5%aMC8BQf&05N#F`njxgN@xNAwEA7O literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-radial-gradient.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-radial-gradient.linux.png new file mode 100644 index 0000000000000000000000000000000000000000..e7480ac4d533fb426a1d4c23372d79bc716827e0 GIT binary patch literal 6002 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 literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-radial-gradient.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-radial-gradient.windows.png new file mode 100644 index 0000000000000000000000000000000000000000..e7480ac4d533fb426a1d4c23372d79bc716827e0 GIT binary patch literal 6002 zcmV-&7metNP)QKDlQbr{r1GRoro zh;uR?HS@=TsB>~0bAmdWA5mw{Ow9by2$(UV4v`r$lb{KLVsLAq8$m^Anxz|LX|$KG z?wuc1x9YuHeP3^MqkYe*zE!vDtGf4n-sfFzZA?ljF;oUk;RD@)p`w=|Kn7VE0%VYt z+(37rC#5s&L`S{@U7`z}VJAAnP5?k@P>Rx^6eYX_Ww|nx2Bibs)9#l+4@i5y9q;ac z7Y!{9*m-ydb{zT;`%mmgN4^7{VJGr2C_n^Aj+dY`C`DPW4AsM{ao*^OIB)bsOc^r; z7gS$>@?7}6y~^R6~90E|WZ(RE*u5|GCF z@X|_LRd+S!o;w$_YA(U}%JHbFtN~|($8xj55cWzbv2NEo{CxAjT zhy>3ZHB{|7Ui?QQ1%uWCKzT3>)fF|kXxzp4yIFVQ>aSf5RtV*jPzDSTAqAR_HQ~Q^ ztiiv${S>yfZbblqaU?)+7T3@L0a7qb8LH8k>`*{R2?$BH1rP$5I%XQ~y5w$Lf8GtK z9Z@@AtQinM_Og}&!&(U1~1b7Spr$Dd_h9G5%R;a0hhaobp=>l*}7ii-Cwu6z9-P z7U#C9;vT22R_Q>fQ&&Jx(OcF#r@{=`vs8J&s*{2rYG?DhJQvVI}&DCH5@)fnJkkj z@HqC2D9h_Z)PmG!P#>Zg5=y%cSQ+k}y#znF?C&vp*yz3%UEc$;`{-`m{_Kunh5L`DIjWl+Sr*`am(|VJZ5sAMPYpg$lJ|tW6`Pw*mYzla-0CcV|KVq7IpHb$s(r9 zWwxkOH=`_vl-Rs)Gj3dQ zJ&v@uCSaJ%=&tEvq^9`@6jPAx)PYFgpix!)zfh)icTAPZ6t#>hAA{fAwg$6mX7!pW zy#}PQwGs25pNCyXcA|u^!2*V-AkcM_ApvCwVTLPf2a0#*nBv@BX^SA#sng8zBXrcW9&8j>+n~XNQ z=d27*&-)K7n6a?`0cmV)#EmQd9D9#{ngT&IKFgOloxHgaXcR*B4PKMij#gx*ZCJznFM4e*N7Sy9G$m*VLtycz)~i_^%INN(37? z>@AI^Ond7!UknHWn<2hv%NP1)g}j2LHIISd5(pW>8s_76AtuTejl) z`jy3sxi}wkpzQ#r|JziwbsR=69%L>^YbwAjpVBR+c+8f@kc=qH#}HR!mCRE~+h>Fx zAE9l>4qZ9-PFf3~qVxmQsAd={uD>QIGj#xaJ}|vhs%NcnBww9 zGvqmG(X7Oyj(+z9~ORb0<1Esx2Pba0a~8 z_#&E)HT52l-|k$C-A6u&oU3QC*y7NhOzkUz8Ob`$zG)WDjWz;=>62VcDW;D;D?nx7 zTXrA$1poWdZ+j2OQ*S>BKtxC*&JPS$aHIAmdj*+dYOQIe90E5@mJ=|C`Lg_87Bd`? zWD*UUV<&7;$!x#?K;X$uPZR_sGwgk1_Z!&KQjbzjE*{aEI$je5Qs+3LJj0=BPNpy< zc}b3E%mBlm7$%OK#m+nqbD1C^Gs#ERH+%&%+X#Er|CJ0+bhY9A{qK2iir_v# z)_%0sMjFsiXQbWdTQsHsUQ?F%7p8-vMPDRBDm5Z3Eje_Ay5?*FjijtOG}|;gDOrz4oKEKC!zQ(iwJQ-LBtjw{f}_>Q}T&tp(WP0z=COn4c-8>2flJn5xiW zrquMjEI(_9o&n9daSKdPlb8lncVsZ!1y zi1QPcMp0JO1d=xpri5giUh6=%eR42mxvgbuaezoE(a^FDaGkl)1QMK-&li^pZhiO9P~oR)S~&(jK|@zh=-Ou5Zfc)hb7hJdlqsAW3}Iqv9y4+sgo(qU|DS}ZByIS23wJu zhb`1}0da%`XqmXmM<^>)gk+x2*EdrZt~QGiMw=;%j(i6?MJI-#03cnW3teGXdH{L? zwQig^tBtwZsOA|O<;r`sJOJr-Nt{ellf@Z;FrITFzt>nn&ZB8MGuZCyzbTW9HkbUA zoICTKK$$Lo3y{3XBOe2z4y@6HwmaIac9)hI?Gz}NrczC&*I<%7CH_rFr%r$*!`6=N z(0-SX)cWd?gLAwd*_d(&MIL#Pw|27`5_Tak?H$by(gxT0&2}d!p7kuvq|8!m$p!Hb z!;}?tU*GYH=}T7MontjY^X%`Fvz3Q1#`GJIhRR7T7DI9@=S=AV2Tsz{?%1E++ai7Y zkbRRW4W!OlyI2&PrBEWGMZB&;Te`3Bc*XQ3tMAUSN>95{N`eyPn4JdNfbbjwo;FK0 z0<#02HmhBF1|)T%(8rePd9}xxe{ZJZ-n9OJb>F@^0K zuSYhf9CAU4Q}Hc8N^{W*z-Wi0xi0ngtTv~nOI_3He%d3;NU!*D8D-##6)a|kkVe=` z?zy7B4;{x9Qty|>ESb@NQzjX0F8KjK2`?599=-f4(I)ag+UC_LQ`22-4o#(+oLupX z*^sb;UJnPNi%1~bfOhk=x&ba>R$Tke=j)p(3s;-Pc%#jfMOm&4Wq}d%x&SH9m1A7R zI43Jynx1I3V;|Se0xsZn@)#>Kze;#9W$OfQD9JZ(8mwxxu`#~SmYUt z#;gpvP}xzis)tuQ0dYoIlj3I@vTUrS1=8qHfkkO*!UQD@L!;?qWyunf+c!g?GhS$0 zK6F2z={kpS^rzTyIAuWFOrYYn$yHOVc`U#)+dH*tniFNy#%da*2IJVKnizuk0>lh} z%?fpAI%AQ)pv+M7Rq9f%8kZU{WrTL$uGCp$vL%fBm#~!dTNvXu(Pa zwLAeyMPteSW^^WuF%%eUF|tzI)2pTz56JYf(=n?2477C~){)NA60)DmK5O?#nM{cp zp`xdR8OCK-<-`Mq^kEwZAoN_mH!-;Ui^&Y_Xz!-K7zg zcleAjHb?omrWb(#-2_1*xjRGCWQ0o|zscK^&ro;C9&fH2hmPr;!+C6g_}KCdfc zt1KCTX=B~~BNLDbBPO7BWF2-K-d50#Dux8ACW7FeMe`LBiIJ5KFp~4A>35Lp7e?f$ zIa)YaNI*#znJ%s<%o&vtW>EiANKQ2)YB6D?|J55_K&now!WFf%@nP#WJ3?|XAkL(D z+?GU5q~;=>I@hMs<8v~jmP~i%^tRAsO5NeqRjmn*I(Mlv#Kjb?-C@dTo|MSL!5}kI zn?2!5RGn7k1;pz^7-RU}g?H$_0Jes>A`(|-Oczrt=>zxXGkaYkdREVL;7-5cXjAQ- zLO-VUyr9vi1UL^voa4=qkNW@jF1*7F22kMnkjrW>!;G;r(a`dNn-xl9V(nKoCp8m5 z9yTM?Wn92!1{5=Rr0D@ebhkNllSSE~O2QIivNY4B%o6bz7E{b6G^S#V+GLH--^?6) zJ}w=9X(k|EhNyD)CHFu|X2t0~jd69DGJ{4^g_xnXY2>8U1~araW8Wn5a-74}m%Pzu zGa5_4@L2@t{#8nbyDquAkhlxKIN-W-zJ~rLw+wv?OW@qFUxYfllHZ%hG|pR)DaN?0aeHJVBrtE%eAJGp zEeJ?aFAiuv-i&GgItA_dV?!?vaBUNCT`vw8mK%v}cQvBAqPid;1sMVWHI+43GJ7e6 zWZvjWi%f*U43GAi3~_3+v2FPk2Rk%>QQ%lKtIz7A`Gz2E$|oZl6M|vMl}n2P11Q3W zsIvI{+p)a#kVH=*bRVJG6W?HN8+CFek*E2FM4WFR(s0g^JV6_p4Ds|e@gcAo zV!o%>dg)~$pF$MOR6v)HnK)(&7N386v0^U%{yMV7no)E z6aj>S;l}fB!i#^ssypKC{zt(cx%N?fu>UyohR_wh1*L>+!|?XvEQxBGxh9y1va&UpxT zzWROSMF*%%Oxd_>yyi2g50UYl71!si%m_=%@H3(;AZ3WvR=C)~K=dK~RIR1`D5Y@e$*eOMKKbK4qR zJnmxW&2QZ)efB|@O?x+C@kHc_E&=Wd$aUp44k*sM6;j1>19U5B}!m+p%HKAHbOCwyCllIRQYsqY?Ub z6aWcD5~djJu$W>v_hL<#lpM1sT!m+DezsQ*+*SJJvw4TUIE1G*J&DIQJc_(%_nIn= zA*RXlfYMW^?gRs=$oEi3Tw`eiuQ{#oKP3T5Ul9|9C$2r_&K zGBWp=86(P1$Nk@U0JlwFfa>AZeHKL(Dg*L?`vYwU@c72Z@yhm>u=~g-00z$o?(0UK zjro&q#=TeEi!&?SFRJX%G9Z8ek<)KF)`Z`E{C{|2)6Y7lE>s2#5VLG-+K6Ag^EBRSdK1mZo6y#E-094FS3>rXy$j=-+aS%<%n(*HKcd=&Y8m#-|_c-2p)M7^= zlTMwwTCXe}iEo~L4Q@F123$~mA!h($ literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-shapes.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-shapes.linux.png new file mode 100644 index 0000000000000000000000000000000000000000..65175fd14983ac7b2782625e9aaae760a7181672 GIT binary patch literal 1127 zcmeAS@N?(olHy`uVBq!ia0vp^DImyuR zUNZJLiXXJQ?Q@IqNVf-vq>xiv%LGNm6eMQK;=7igMIwxu*X-uz>0^GXWl+L)h05j;9qd_c6qN8-q}aj9NcO9 z(bc5Cc=y}Qs!LY-)#YuNkh_0E>zdkaEjFvqEIN2F_$|;NzB!pM_v@EcRJ}F2`uo} zwC{ai8dqsTt?pZqg9YM7C895KU3xAl<(oF>hF|s*6qEixyP6AJNb)dMP}6t@DT53$*-h@oG(zmz3UH)_f{idV8_i3I^+s z7rmBf=o&Mrhw1C|Giiv%5)>Ie|^78$D@-I z*XK+-xA>^r&Wm1Ey+z0QR|dYT-&%9+!JB*ExGi^Iw~Ka)Q+wL@c5Ci&ugDWpHT&Jq zUdvx2x|vO#@2GguqDdL4CH)hgZ_U@a*EqK}Ink(W>V)U+xB7U0&(*HE`r?vu?!Whn zdQ;RI4&P_EUC;7qdv5ze?$4GF%H)0|S{HmVFbL$5(>;{aEac9S(C@nc^Y+?Cop{D= zp^cY(6W*TbKE5dZ8k4Ye!~AVG=U-gjbztw+i+!%;>ji>tq_0+EH&SWg;Z$^XIx#^2 rnYrmr{<^zuVOX;cQZAw%^P~Jv^ZS3yx9U3rOAZE4S3j3^P6vHS! literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-shapes.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-shapes.windows.png new file mode 100644 index 0000000000000000000000000000000000000000..65175fd14983ac7b2782625e9aaae760a7181672 GIT binary patch literal 1127 zcmeAS@N?(olHy`uVBq!ia0vp^DImyuR zUNZJLiXXJQ?Q@IqNVf-vq>xiv%LGNm6eMQK;=7igMIwxu*X-uz>0^GXWl+L)h05j;9qd_c6qN8-q}aj9NcO9 z(bc5Cc=y}Qs!LY-)#YuNkh_0E>zdkaEjFvqEIN2F_$|;NzB!pM_v@EcRJ}F2`uo} zwC{ai8dqsTt?pZqg9YM7C895KU3xAl<(oF>hF|s*6qEixyP6AJNb)dMP}6t@DT53$*-h@oG(zmz3UH)_f{idV8_i3I^+s z7rmBf=o&Mrhw1C|Giiv%5)>Ie|^78$D@-I z*XK+-xA>^r&Wm1Ey+z0QR|dYT-&%9+!JB*ExGi^Iw~Ka)Q+wL@c5Ci&ugDWpHT&Jq zUdvx2x|vO#@2GguqDdL4CH)hgZ_U@a*EqK}Ink(W>V)U+xB7U0&(*HE`r?vu?!Whn zdQ;RI4&P_EUC;7qdv5ze?$4GF%H)0|S{HmVFbL$5(>;{aEac9S(C@nc^Y+?Cop{D= zp^cY(6W*TbKE5dZ8k4Ye!~AVG=U-gjbztw+i+!%;>ji>tq_0+EH&SWg;Z$^XIx#^2 rnYrmr{<^zuVOX;cQZAw%^P~Jv^ZS3yx9U3rOAZE4S3j3^P6vHS! literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-text.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-text.linux.png new file mode 100644 index 0000000000000000000000000000000000000000..22a65b1801dc68fa6b8cf56e13f9eaeeef0c7264 GIT binary patch literal 616 zcmeAS@N?(olHy`uVBq!ia0vp^3xL>$gAGVN%-$#pq*#ibJVQ8upoSx*gMoob-qXb~ zq+-t7I~%i<0wvlW-p`2;pVBLETI6!;-fqe4`~!M7&bL01V-niK;%IH#npwn{_qb{5 zp$!iN&WXJ4%G;>2MnGKgmHDIJChT(#eiqk1XRxC6Yt>#ehbK<94Cka7ow^tlT^lAS zId}*vEa4CcV&TwaY7s#ee$c4mnRL!__Sv*ofknLx{}*3=86o0&EpPkwdB$@6Yva~O z%N*Ypqjx{9{9GhZ{Powed+(20{+4q!+r9t)g*(fi1lc;w^IaZXd@gcr*zKym@Aujc zFI<|L^}lZGrVy=20hTbm>E-v{mruIUT^z>K)ZbGZ#PPx5kinLN2?_$T zFv^{HZJV(Dc4+^kY?caz$Z0oIj11rJt(p?^SgwG5ZP@8wfB)@1zUTY2)SGo?S|VoG zjU5`SXPke&?d|dA?xTmoR$q<#wsTF$zjfEIS6A*221;g_%@#ZUarbez#S=}XdiTEh zEG5U7FVdQ&MBb@0GEURSO5S3 literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-text.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-text.windows.png new file mode 100644 index 0000000000000000000000000000000000000000..2a7d4971b82c79876ed7d64bc4b43c6d7fa451bf GIT binary patch literal 628 zcmeAS@N?(olHy`uVBq!ia0vp^3xL>$gAGVN%-$#pq*#ibJVQ8upoSx*gMoob+tbA{ zq+-t7JBHbh3M7tyJpL+nhhw2e$ifRg)2bPpl8t{cu^ivmx~Io)U!&gLxl{elUa;Cd zw|jzO!9$CKmk%omzA5rvD7RDS@6p%ajny9huW!@$Jd=>P`)(eufMpZgpe$tfbPj}t^{CUrBR@mID^c8Wv z_Oj&6_rKxC*_#_H=cP|Rsq(dM|GW2ZXFtFBefI3~^EEkMuNXh+bSZH+9eDQn=eg{8 z^XKQ9NJTEbSW&tsMsN1(UC%81Uj3b9vCUOr#^YV@Wq-a9crb6v?c8j$*K%=NjK6qpG=v5UR*paEo@DQQRI6GfdsC-ap$AfUYq^=kinL`?XyqX zEwwMd>7SlHb4@u9he2!9+O&6l`Nf`-A~r^xNtr*n=;oO-Udxw5WZma{F8=-NLqfcs z{?)}YJdDpg4i>H{DqSk~c)<^z$;z)^Ua7lx_vZWM>R&b3of|AS)zoZx`E~2x9eYFj tiwsyeRx> literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-use-element.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-use-element.linux.png new file mode 100644 index 0000000000000000000000000000000000000000..0a808cdfa15c160a6380c670e018e52372b3c92a GIT binary patch literal 1117 zcmV-j1fu(iP)i8xSv1Z+;cc{hdVRhVZQJC(2#*|v;oc2=t7903n7Lsgc!OIV(3DMp$j2~ zE`%7m5Mmh0lspfOLJ_q)vv987gfsmc>T?TdEUf?lV(l3i-JM7cY=bqp3+78bh~=^X zCw5J2a1N&8)rnUsW973iQTqA{>NE53*J{sRG!ci-gPH0!tO%5HNEhN&s#rRC9;J)l!K7 zOrUk&J7_<31l&r7(h`MQ4yi)CvV+AF=TQ3M8~C0lweWw8aE|oB5!fdWgSVuVXf}gl z4&g$)wHgY?$FX|hTV)}hnBg4h1Mk8(%0cM&~hGgJY3aLG>E7zr-L+F-e^ma*^k|FkD2h6^_a$V{<0v8hN zu+cI+AZ1DhYp5TwT$^%T>N$cRdpSe)*nSxKO)|cOF!GzwHu@pJBRHZG&mr`IDrRSq z8#@M;ObD9`3QH!CJ3We6gvV^kIfTC$T5k^^y>}>@2Q0>r-a7K{@Bj4HP~-3-`XeDCJNyN?I&od3*wush{8m z7{TN(STMRfVZGUpwoeX#XQCNq5QRbxC4~TwM_(F+BI!RBzrw?a#ZY7m8rX zILz)&7`>a3+PMXZ{;e>3yAZRp8}jz`S!{F;Wrb+eK3x)5UMLWrRYA%-r5 j7`hN*=t7903n9io#P=LqZ9hLT00000NkvXXu0mjfs4M~Q literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-use-element.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-inline-svg-use-element.windows.png new file mode 100644 index 0000000000000000000000000000000000000000..0a808cdfa15c160a6380c670e018e52372b3c92a GIT binary patch literal 1117 zcmV-j1fu(iP)i8xSv1Z+;cc{hdVRhVZQJC(2#*|v;oc2=t7903n7Lsgc!OIV(3DMp$j2~ zE`%7m5Mmh0lspfOLJ_q)vv987gfsmc>T?TdEUf?lV(l3i-JM7cY=bqp3+78bh~=^X zCw5J2a1N&8)rnUsW973iQTqA{>NE53*J{sRG!ci-gPH0!tO%5HNEhN&s#rRC9;J)l!K7 zOrUk&J7_<31l&r7(h`MQ4yi)CvV+AF=TQ3M8~C0lweWw8aE|oB5!fdWgSVuVXf}gl z4&g$)wHgY?$FX|hTV)}hnBg4h1Mk8(%0cM&~hGgJY3aLG>E7zr-L+F-e^ma*^k|FkD2h6^_a$V{<0v8hN zu+cI+AZ1DhYp5TwT$^%T>N$cRdpSe)*nSxKO)|cOF!GzwHu@pJBRHZG&mr`IDrRSq z8#@M;ObD9`3QH!CJ3We6gvV^kIfTC$T5k^^y>}>@2Q0>r-a7K{@Bj4HP~-3-`XeDCJNyN?I&od3*wush{8m z7{TN(STMRfVZGUpwoeX#XQCNq5QRbxC4~TwM_(F+BI!RBzrw?a#ZY7m8rX zILz)&7`>a3+PMXZ{;e>3yAZRp8}jz`S!{F;Wrb+eK3x)5UMLWrRYA%-r5 j7`hN*=t7903n9io#P=LqZ9hLT00000NkvXXu0mjfs4M~Q literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-nested-svg-viewport.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-nested-svg-viewport.linux.png new file mode 100644 index 0000000000000000000000000000000000000000..237422472471822e32e6468f08071762c7b2214f GIT binary patch literal 1669 zcmb_dc{J2p93Nr`vlwK{GGhoyrW!>ihB26G5GEdxB}>`O(`ZClYAi2|ZM-*4*%c`d zBYN4lagIziO^Gm;w$}(*67hcZ?>q0mch9-^d(ZcK&i8)q{eC{*o8dxsfXHdcfj}UL zlOy32V1s^4Sun6hPZzoaMk>zM$z2v0iLyZq5D0ALM8Lb#*y8bFXQeREz@ncwtB7K0 z)4;vGbjlY>gan<_H#Nr~kwmm{#_r`DGmJ2`0$(N(n7su-vlUPnpWYo(0k%GlRyA4D zBq_P@tEb-6!`3QBu}_~AB)m=dcw5tgv_p;BCsg{m%T!i-yin}W4!QahV@5>?Gx4$CLMS)ZTD{rAdo9OM)Y3#p~e{i0iqS6p$uW3 z&BaFQ- zPEVbucCkX-SGa{$gE@j=>KE_qLxNb3j8|5-|HQ8>{)jIOz zME6*83n{`fH*PhK7CXPw1S~H$^0MG7^|+wYWIk(~DeVANCA!hR&LeFz;6R9H>4++{ znLMH6o8V{E>Y%7Mp}0J}jLakPIVf)-zP~-&pBBR!x=b_(tHAtqB)egMU{m#AkwiXngd!$$%C|?7!uz<6pIil$aQU2I9b~&sHO~IP zDE?Sva&KBxO7AX|0cFoZ*oKl=oymh9Sa=CqMbOb)cti24p3ZU553k(x=;B#wSb<$kzNerGt+GPH2v>GfRHi9|3tE@^`^+0p7 z5~ruf!wY-iO1{%6OJnsW zq)Io|%E&WLUf!&3>yqJIh?T_Z2tGD11C7a0 zW~zXJMPIBbqtsR6kN{qCG3OekXY1y4PAK*lb`!he2jJ}iHzM;iN(73H>YQX?&_}p)WJ|;WI z!W2`;o!9|vXy;bT{h&9;j}l}|J$z8j>8#bE_#*@v;N1t4UQx@pz;a*Yox+jbCajpo zd4r!tI_2jB3u(;5>A!uj^Kw7r5sysIH1V=tiNpyrxib-@NLN8$W>zRpNTOd0_mWYhO16zq z4<_fhz3(#ubk*_U*b{AwS^;Or1unL{`i@L+*9kg_0^K|>5~cLe zgi8wILb3ZTTG(y8gNF=fYw=J>ho7wB@;;xeu9S&My8qB&|Dc$(p_gU*PoL?3)Bng~ z`$<)qD%7z8!SXDzS)(2{M`@C@R;%lzDa+<7(~0Zr>`Sx_J%Z`I2K6_+Gou5v$D6;u z8n~XhY#)kyojC?+iTma(SYw7{OL!HB7$Y)Wf{pF69!qPz(CX+N<5`!Ha_^@s z>H@^|+<9;6`&+t+lY@UMWJ?8$_B+p{VE08%>l{Qv*} literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-nested-svg-viewport.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-nested-svg-viewport.windows.png new file mode 100644 index 0000000000000000000000000000000000000000..237422472471822e32e6468f08071762c7b2214f GIT binary patch literal 1669 zcmb_dc{J2p93Nr`vlwK{GGhoyrW!>ihB26G5GEdxB}>`O(`ZClYAi2|ZM-*4*%c`d zBYN4lagIziO^Gm;w$}(*67hcZ?>q0mch9-^d(ZcK&i8)q{eC{*o8dxsfXHdcfj}UL zlOy32V1s^4Sun6hPZzoaMk>zM$z2v0iLyZq5D0ALM8Lb#*y8bFXQeREz@ncwtB7K0 z)4;vGbjlY>gan<_H#Nr~kwmm{#_r`DGmJ2`0$(N(n7su-vlUPnpWYo(0k%GlRyA4D zBq_P@tEb-6!`3QBu}_~AB)m=dcw5tgv_p;BCsg{m%T!i-yin}W4!QahV@5>?Gx4$CLMS)ZTD{rAdo9OM)Y3#p~e{i0iqS6p$uW3 z&BaFQ- zPEVbucCkX-SGa{$gE@j=>KE_qLxNb3j8|5-|HQ8>{)jIOz zME6*83n{`fH*PhK7CXPw1S~H$^0MG7^|+wYWIk(~DeVANCA!hR&LeFz;6R9H>4++{ znLMH6o8V{E>Y%7Mp}0J}jLakPIVf)-zP~-&pBBR!x=b_(tHAtqB)egMU{m#AkwiXngd!$$%C|?7!uz<6pIil$aQU2I9b~&sHO~IP zDE?Sva&KBxO7AX|0cFoZ*oKl=oymh9Sa=CqMbOb)cti24p3ZU553k(x=;B#wSb<$kzNerGt+GPH2v>GfRHi9|3tE@^`^+0p7 z5~ruf!wY-iO1{%6OJnsW zq)Io|%E&WLUf!&3>yqJIh?T_Z2tGD11C7a0 zW~zXJMPIBbqtsR6kN{qCG3OekXY1y4PAK*lb`!he2jJ}iHzM;iN(73H>YQX?&_}p)WJ|;WI z!W2`;o!9|vXy;bT{h&9;j}l}|J$z8j>8#bE_#*@v;N1t4UQx@pz;a*Yox+jbCajpo zd4r!tI_2jB3u(;5>A!uj^Kw7r5sysIH1V=tiNpyrxib-@NLN8$W>zRpNTOd0_mWYhO16zq z4<_fhz3(#ubk*_U*b{AwS^;Or1unL{`i@L+*9kg_0^K|>5~cLe zgi8wILb3ZTTG(y8gNF=fYw=J>ho7wB@;;xeu9S&My8qB&|Dc$(p_gU*PoL?3)Bng~ z`$<)qD%7z8!SXDzS)(2{M`@C@R;%lzDa+<7(~0Zr>`Sx_J%Z`I2K6_+Gou5v$D6;u z8n~XhY#)kyojC?+iTma(SYw7{OL!HB7$Y)Wf{pF69!qPz(CX+N<5`!Ha_^@s z>H@^|+<9;6`&+t+lY@UMWJ?8$_B+p{VE08%>l{Qv*} literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-positioned-circle-radial-gradient.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-positioned-circle-radial-gradient.linux.png new file mode 100644 index 0000000000000000000000000000000000000000..f5b5ef01c9d753fa633e8d30ff2db40923bd0f45 GIT binary patch literal 5346 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 A8UO$Q literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-positioned-circle-radial-gradient.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-positioned-circle-radial-gradient.windows.png new file mode 100644 index 0000000000000000000000000000000000000000..f5b5ef01c9d753fa633e8d30ff2db40923bd0f45 GIT binary patch literal 5346 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 A8UO$Q literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-conic-gradient.linux.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-repeating-conic-gradient.linux.png new file mode 100644 index 0000000000000000000000000000000000000000..155850bff1c89f42a3abeaa8daaace5435e3a36b GIT binary patch literal 1651 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 glu<93GtwDz4_NcPf{C{8g^gq)M6fzy> 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 nEV1Eakt zG3V`_gS-t691M;o-|idjTYJDWZ#mP6HvQ}8-pomPTl#)K`-yNz1J;8gEWrXk1IDoV rrT^t}iQEakt zG3V`_gS-t691M;o-|idjTYJDWZ#mP6HvQ}8-pomPTl#)K`-yNz1J;8gEWrXk1IDoV rrT^t}iQ7`+tVR`>otK(`ObJu_TSk)D! zSHAnUZqd9)Ki`TxKE0)S-E~Vd$3yFVdQ&MBb@0GUIIaR2}S literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-symbol-via-use.windows.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-symbol-via-use.windows.png new file mode 100644 index 0000000000000000000000000000000000000000..d6b8df21317567569a3fb771c92b74cd65c01367 GIT binary patch literal 345 zcmeAS@N?(olHy`uVBq!ia0vp^DL`z*!3HE(nbz$CQY^(zo*^7SP{WbZ!N9;M;OXKR zQZeW49Y?Pt4gwAryQT_r1U_Q4JwE5~7`+tVR`>otK(`ObJu_TSk)D! zSHAnUZqd9)Ki`TxKE0)S-E~Vd$3yFVdQ&MBb@0GUIIaR2}S literal 0 HcmV?d00001 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-with-colspan-and-rowspan.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/renders-table-with-colspan-and-rowspan.macos.png index 1b70e60ce962a0f9e1d6eeccf437d79fe6b77e10..57c46c493b5795b3c3c8a6bd1c0e131c99fb209d 100644 GIT binary patch literal 4116 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 literal 4117 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 SbOg}ngY>kFG)pubpZy1X((Bv+ diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/text-indent-and-vertical-align.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/text-indent-and-vertical-align.macos.png index 1990c773afa0e060db26a09586167de9a93ae496..c1309a5d3d014ff0dcd8a11815f2b1724afee359 100644 GIT binary patch literal 5375 zcmcIoXE>W}-w%qqOV!@=R;4YqVzg!lRkf=|ZK_ctcI=V%rj*jsVNKY2Vj)2!T+Y z0mq-t(t-C?66-ziamMG4_M@}l6>`=w8UkUj($={BC?I`(Dmm*fQ!6cAw~t?` zG`w24kZIfXa9G#a$oRearRiOv)P8>bMB?8pTiZ1yR!L;l8RF8>KL1jKH%WD=!5}$> zM_3q%Y7)CBtjJ9JBKpQ(XIQ-0&kFy!azUfb=wftq?yUD^*~vD01Oiv%!w(fz24s zCOGbKIc&H)gLH5B{FlDC2Gvz^)k#CW^ReRwD`)nZ7+fey@v}?tG{WfQ9$8jQb<{(D zoAYcrtXKHv&6Fd`?ohsgP>7mPLPCOWmRd$kObjyA3}vGibzWd&KO}^lHZw03zOOSN z!z`fGhNPm2e*L;_iJXv}+=)ju3wlpE59O7*jmbh16BF<2>V6L1CHy_i@N_zf7SSyR_r+3D1h$pKA%EHGheru^8ut4xQloJL? z)%N3ujK{Rfg9i_maVQ~(b)B!*T;tKsgzwrY3sOrc^nGXN{f`ol@9Yw$#u@^lQ*{Wk zRkd-xe#jBtR!m(z>-dPmCMc*M5D;)5(a=YEFg(0t(QJ^V_F70iGdY=CR4=vd6`PpV zI+smAi!s>zqk_DX?(3FSh87ms(b6Z*lQlB6wY3s94axq~fl-HtAt7qE_sNM$c8ZvH z25Qd8g{7r>_}!@U@)%iJ*~zNJL{=IJdHG>Ds1FpQcCdV@8veUAlFmFy(t5J$^Jfv7 zVl;XHPQf)FKSf>^5s9$!pYKedGY7T6STu}mDiQ4p3)d^dG&Li?eEGs-2d@1wHPr

&nxoPuaP+ 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 diff --git a/src/AngleSharp.Renderer.Tests/verification-assets/web-safe-font-families.macos.png b/src/AngleSharp.Renderer.Tests/verification-assets/web-safe-font-families.macos.png index 86bf5d617ec5dee1f43f0fa3654031cc381c0d92..32d6f10b3c246d6a183bb66b7136f96d2bde913f 100644 GIT binary patch literal 9919 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 From 518017eeadda32839cf0c2498cde4938dedf8756 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Mon, 7 Sep 2026 13:39:46 +0200 Subject: [PATCH 24/24] Improved tolerance for snapshots --- .../VisualConformanceTests.cs | 39 +++++++++++++------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs b/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs index 253a438..4d13e25 100644 --- a/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs +++ b/src/AngleSharp.Renderer.Tests/VisualConformanceTests.cs @@ -7,6 +7,21 @@ namespace AngleSharp.Renderer.Tests; [Trait("Category", "Visual")] public sealed class VisualConformanceTests { + // Text glyph rasterization is delegated to the OS's own font engine (CoreText on macOS, + // DirectWrite on Windows, FreeType on Linux - see AGENTS.md), and that engine's hinting and + // anti-aliasing can differ across OS versions even on the *same* platform: CI is pinned to a + // specific runner image (macos-14 et al.), but a developer's local machine runs whatever OS + // version they have, which can be materially newer. That produces a handful of glyph/border + // edge pixels differing by a few intensity levels - not a rendering regression, since the same + // input consistently produces the same *content*, just very slightly different anti-aliasing. + // These tolerances (measured: real CI-vs-local drift topped out at a per-channel delta of 6 + // across 9 pixels; doubled here for headroom) apply only to tests whose content is dominated + // by text. Every shape/gradient/SVG test keeps an exact 0/0 tolerance - that geometry is + // rendered by Skia's own rasterizer with no OS dependency, and has proven bit-for-bit + // reproducible across OS versions, so loosening it here would hide real regressions there. + private const byte TextRenderingToleranceChannel = 12; + private const int TextRenderingToleranceMaxPixels = 20; + [Fact] public async Task RenderToPng_PaintsBoxBackgroundAndBorderAtExpectedPixels() { @@ -315,8 +330,8 @@ public async Task RenderToPng_RendersSimpleTableLayout() VisualSnapshotVerifier.VerifyOrCreate( snapshotName: "renders-simple-table-layout.png", actualPng: image.Data, - perChannelTolerance: 0, - maxDifferentPixels: 0); + perChannelTolerance: TextRenderingToleranceChannel, + maxDifferentPixels: TextRenderingToleranceMaxPixels); } [Fact] @@ -387,8 +402,8 @@ public async Task RenderToPng_RendersTableWithColspanAndRowspan() VisualSnapshotVerifier.VerifyOrCreate( snapshotName: "renders-table-with-colspan-and-rowspan.png", actualPng: image.Data, - perChannelTolerance: 0, - maxDifferentPixels: 0); + perChannelTolerance: TextRenderingToleranceChannel, + maxDifferentPixels: TextRenderingToleranceMaxPixels); } [Fact] @@ -599,8 +614,8 @@ public async Task RenderToPng_RendersMixedTextSizesStylesAndDecorations() VisualSnapshotVerifier.VerifyOrCreate( snapshotName: "mixed-text-sizes-styles-decorations.png", actualPng: image.Data, - perChannelTolerance: 0, - maxDifferentPixels: 0); + perChannelTolerance: TextRenderingToleranceChannel, + maxDifferentPixels: TextRenderingToleranceMaxPixels); } [Fact] @@ -633,8 +648,8 @@ one two three four five six seven eight nine ten eleven twelve thirteen fourteen VisualSnapshotVerifier.VerifyOrCreate( snapshotName: "aligned-wrapped-text-with-line-height.png", actualPng: image.Data, - perChannelTolerance: 0, - maxDifferentPixels: 0); + perChannelTolerance: TextRenderingToleranceChannel, + maxDifferentPixels: TextRenderingToleranceMaxPixels); } [Fact] @@ -704,8 +719,8 @@ Indented text that wraps to a second line. VisualSnapshotVerifier.VerifyOrCreate( snapshotName: "text-indent-and-vertical-align.png", actualPng: image.Data, - perChannelTolerance: 0, - maxDifferentPixels: 0); + perChannelTolerance: TextRenderingToleranceChannel, + maxDifferentPixels: TextRenderingToleranceMaxPixels); } [Fact] @@ -1372,8 +1387,8 @@ public async Task RenderToPng_RendersWebSafeFontFamiliesDifferently() VisualSnapshotVerifier.VerifyOrCreate( snapshotName: "web-safe-font-families.png", actualPng: image.Data, - perChannelTolerance: 0, - maxDifferentPixels: 0); + perChannelTolerance: TextRenderingToleranceChannel, + maxDifferentPixels: TextRenderingToleranceMaxPixels); } private static async Task RenderCanvasSnapshotAsync(string html, Action draw)