Browse Source

Feature: layout tests (#718)

* Prototype implementation

* Layout testing: added support for z-depth

* Layout testing: drawing grid

* Code refactoring

* LayoutTestResultVisualization refactoring

* LayoutTestExecutor refactoring

* LayoutTestValidator refactoring

* Fixed LayoutTestValidator

* Refactored fluent extensions for layout tests

* LayoutTest: minor refactoring

* Layout test: added support for asserting infinite layouts

* Layout test: annotating places of invalid content

* Layout test: added support for drawing occluded mocks

* Layout test: improved lifecycle

* Added dotnet tools

* Update README.md

* Update SECURITY.md

* Fixed DetectSpanPositionExample example

* Update README.md

* fix(#704) + code refactoring of the layout issue page marker

* Fixed: checking SkiaSharp native dependency is not working on simplified Linux distribution (#707)

* Update README.md

* Fix(#457): text rendering fails due to incorrect cache usage (very rare)

* Fix #709: the Row element does not always correctly handle content that has conflicting layout constraints

* Update bug_report.md

* Disabled warning CS1591

* 2023.10.2
Marcin Ziąbek 2 years ago
parent
commit
b959897a95

+ 12 - 0
Source/.config/dotnet-tools.json

@@ -0,0 +1,12 @@
+{
+  "version": 1,
+  "isRoot": true,
+  "tools": {
+    "dotnet-stryker": {
+      "version": "3.11.1",
+      "commands": [
+        "dotnet-stryker"
+      ]
+    }
+  }
+}

+ 15 - 0
Source/.config/stryker-config.json

@@ -0,0 +1,15 @@
+{
+  "stryker-config":
+  {
+    "solution": "../QuestPDF.sln",
+    "project": "QuestPDF/QuestPDF.csproj",
+    "test-projects": ["../QuestPDF.LayoutTests/QuestPDF.LayoutTests.csproj"],
+    "mutate": ["**/*Column.cs"],
+    "reporters": [
+      "progress",
+      "html"
+    ],
+    "disable-mix-mutants": true,
+    "disable-bail": true
+  }
+}

+ 129 - 0
Source/QuestPDF.LayoutTests/LayoutTestResult.cs

@@ -0,0 +1,129 @@
+using QuestPDF.Fluent;
+using QuestPDF.Infrastructure;
+using QuestPDF.LayoutTests.TestEngine;
+
+namespace QuestPDF.LayoutTests;
+
+public class Tests
+{
+    [SetUp]
+    public void Setup()
+    {
+    }
+
+    [Test]
+    public void Test1()
+    {
+        return;
+        
+        LayoutTest
+            .HavingSpaceOfSize(200, 400)
+            .WithContent(content =>
+            {
+                content.Column(column =>
+                {
+                    column.Spacing(25);
+
+                    column.Item().Mock("a").Size(150, 200);
+                    column.Item().Mock("b").Size(150, 150);
+                    column.Item().Mock("c").Size(150, 100);
+                    column.Item().Mock("d").Size(150, 150);
+                    column.Item().Mock("e").Size(150, 300);
+                    column.Item().Mock("f").Size(150, 150);
+                    column.Item().Mock("g").Size(150, 100);
+                    column.Item().Mock("h").Size(150, 500);
+                });
+            })
+            .ExpectedDrawResult(document =>
+            {
+                document
+                    .Page()
+                    .TakenAreaSize(400, 300)
+                    .Content(page =>
+                    {
+                        page.Mock("a").Position(0, 0).Size(250, 200);
+                        page.Mock("b").Position(150, 50).Size(50, 150);
+                        page.Mock("c").Position(200, 100).Size(100, 50);
+                    });
+                
+                document
+                    .Page()
+                    .TakenAreaSize(400, 300)
+                    .Content(page =>
+                    {
+                        page.Mock("a").Position(0, 0).Size(150, 100);
+                        page.Mock("b").Position(250, 150).Size(50, 150);
+                        page.Mock("c").Position(300, 200).Size(100, 50);
+                    });
+            });
+            //.CompareVisually();
+    }
+    
+    [Test]
+    public void Test2()
+    {
+        LayoutTest
+            .HavingSpaceOfSize(200, 200)
+            .WithContent(content =>
+            {
+                content.Column(column =>
+                {
+                    column.Spacing(25);
+
+                    column.Item().Mock("a").Size(150, 150);
+                    column.Item().Mock("b").Size(125, 100);
+                });
+            })
+            .ExpectedDrawResult(document =>
+            {
+                document
+                    .Page()
+                    .TakenAreaSize(150, 200)
+                    .Content(page =>
+                    {
+                        page.Mock("a").Position(0, 0).Size(150, 150);
+                        page.Mock("b").Position(0, 175).Size(125, 25);
+                    });
+                
+                document
+                    .Page()
+                    .TakenAreaSize(125, 75)
+                    .Content(page =>
+                    {
+                        page.Mock("b").Position(0, 0).Size(125, 75);
+                    });
+            })
+            .Validate();
+    }
+    
+    [Test]
+    public void Test3()
+    {
+        LayoutTest
+            .HavingSpaceOfSize(200, 200)
+            .WithContent(content =>
+            {
+                content.Layers(layers =>
+                {
+                    layers.Layer().Mock("a").Size(100, 150);
+                    layers.PrimaryLayer().Mock("b").Size(150, 100);
+                });
+            })
+            .ExpectedDrawResult(document =>
+            {
+                document
+                    .Page()
+                    .TakenAreaSize(150, 100)
+                    .Content(page =>
+                    {
+                        page.Mock("b").Position(0, 0).Size(150, 100);
+                        page.Mock("a").Position(0, 0).Size(100, 150);
+                        
+                    });
+                
+                document.ExpectInfiniteLayoutException();
+            })
+           // .CompareVisually();
+           .Validate();
+    }
+}

+ 23 - 0
Source/QuestPDF.LayoutTests/QuestPDF.LayoutTests.csproj

@@ -0,0 +1,23 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+    <PropertyGroup>
+        <TargetFramework>net6.0</TargetFramework>
+        <ImplicitUsings>enable</ImplicitUsings>
+        <Nullable>enable</Nullable>
+
+        <IsPackable>false</IsPackable>
+    </PropertyGroup>
+
+    <ItemGroup>
+        <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0"/>
+        <PackageReference Include="NUnit" Version="3.13.3"/>
+        <PackageReference Include="NUnit3TestAdapter" Version="4.2.1"/>
+        <PackageReference Include="NUnit.Analyzers" Version="3.3.0"/>
+        <PackageReference Include="coverlet.collector" Version="3.1.2"/>
+    </ItemGroup>
+
+    <ItemGroup>
+      <ProjectReference Include="..\QuestPDF\QuestPDF.csproj" />
+    </ItemGroup>
+
+</Project>

+ 37 - 0
Source/QuestPDF.LayoutTests/TestEngine/AnnotateInvalidAreaHelper.cs

@@ -0,0 +1,37 @@
+using QuestPDF.Helpers;
+using SkiaSharp;
+
+namespace QuestPDF.LayoutTests.TestEngine;
+
+internal static class AnnotateInvalidAreaHelper
+{
+    private const float StripeThickness = 1f;
+    private const float StripeScale = 3f;
+    private const string LineColor = Colors.Red.Medium;
+    
+    public static void Annotate(SKCanvas canvas, SKPath area)
+    {
+        canvas.Save();
+        canvas.ClipPath(area);
+
+        DrawCheckerboardPattern();
+        
+        canvas.Restore();
+
+        void DrawCheckerboardPattern()
+        {
+            var matrix = SKMatrix.CreateScale(StripeScale, StripeScale).PostConcat(SKMatrix.CreateRotation((float)(Math.PI / 4)));
+
+            using var paint = new SKPaint
+            {
+                Color = SKColor.Parse(LineColor),
+                PathEffect = SKPathEffect.Create2DLine(StripeThickness, matrix),
+                IsAntialias = true
+            };
+
+            var stripeArea = area.Bounds;
+            stripeArea.Inflate(StripeScale * 2, StripeScale * 2);
+            canvas.DrawRect(stripeArea, paint);
+        }
+    }
+}

+ 44 - 0
Source/QuestPDF.LayoutTests/TestEngine/BoundingBox.cs

@@ -0,0 +1,44 @@
+using QuestPDF.Infrastructure;
+
+namespace QuestPDF.LayoutTests.TestEngine;
+
+internal class BoundingBox
+{
+    public double MinX { get; init; }
+    public double MinY { get; init; }
+    public double MaxX { get; init; }
+    public double MaxY { get; init; }
+    
+    public double Width => MaxX - MinX;
+    public double Height => MaxY - MinY;
+
+    public static BoundingBox From(Position position, Size size)
+    {
+        return new BoundingBox
+        {
+            MinX = position.X,
+            MinY = position.Y,
+            MaxX = position.X + size.Width,
+            MaxY = position.Y + size.Height
+        };
+    }
+}
+
+internal static class BoundingBoxExtensions
+{
+    public static BoundingBox? Intersection(BoundingBox first, BoundingBox second)
+    {
+        var common = new BoundingBox
+        {
+            MinX = Math.Max(first.MinX, second.MinX),
+            MinY = Math.Max(first.MinY, second.MinY),
+            MaxX = Math.Min(first.MaxX, second.MaxX),
+            MaxY = Math.Min(first.MaxY, second.MaxY),
+        };
+
+        if (common.Width < 0 || common.Height < 0)
+            return null;
+
+        return common;
+    }
+}

+ 112 - 0
Source/QuestPDF.LayoutTests/TestEngine/FluentExtensions.cs

@@ -0,0 +1,112 @@
+using QuestPDF.Fluent;
+using QuestPDF.Infrastructure;
+
+namespace QuestPDF.LayoutTests.TestEngine;
+
+internal class ExpectedDocumentLayoutDescriptor
+{
+    public LayoutTestResult.DocumentLayout DocumentLayout { get; } = new(); 
+    
+    public ExpectedPageLayoutDescriptor Page()
+    {
+        var page = new LayoutTestResult.PageLayout();
+        DocumentLayout.Pages.Add(page);
+        return new ExpectedPageLayoutDescriptor(page);
+    }
+    
+    public void ExpectInfiniteLayoutException()
+    {
+        DocumentLayout.GeneratesInfiniteLayout = true;
+    }
+}
+
+internal class ExpectedPageLayoutDescriptor
+{
+    private LayoutTestResult.PageLayout PageLayout { get; }
+
+    public ExpectedPageLayoutDescriptor(LayoutTestResult.PageLayout pageLayout)
+    {
+        PageLayout = pageLayout;
+    }
+    
+    public ExpectedPageLayoutDescriptor TakenAreaSize(float width, float height)
+    {
+        PageLayout.RequiredArea = new Size(width, height);
+        return this;
+    }
+    
+    public ExpectedPageLayoutDescriptor Content(Action<ExpectedPageContentDescriptor> content)
+    {
+        var pageContent = new ExpectedPageContentDescriptor();
+        content(pageContent);
+        
+        PageLayout.Mocks = pageContent.MockPositions;
+        return this;
+    }
+}
+
+internal class ExpectedPageContentDescriptor
+{
+    public List<LayoutTestResult.MockLayoutPosition> MockPositions { get;} = new();
+    
+    public ExpectedMockPositionDescriptor Mock(string mockId)
+    {
+        var child = new LayoutTestResult.MockLayoutPosition { MockId = mockId };
+        MockPositions.Add(child);
+        return new ExpectedMockPositionDescriptor(child);
+    }
+}
+
+internal class ExpectedMockPositionDescriptor
+{
+    private LayoutTestResult.MockLayoutPosition MockLayoutPosition { get; }
+
+    public ExpectedMockPositionDescriptor(LayoutTestResult.MockLayoutPosition mockLayoutPosition)
+    {
+        MockLayoutPosition = mockLayoutPosition;
+    }
+
+    public ExpectedMockPositionDescriptor Position(float x, float y)
+    {
+        MockLayoutPosition.Position = new Position(x, y);
+        return this;
+    }
+    
+    public ExpectedMockPositionDescriptor Size(float width, float height)
+    {
+        MockLayoutPosition.Size = new Size(width, height);
+        return this;
+    }
+}
+
+internal static class ElementExtensions
+{
+    public static MockDescriptor Mock(this IContainer element, string id)
+    {
+        var mock = new ElementMock
+        {
+            MockId = id
+        };
+        
+        element.Element(mock);
+        return new MockDescriptor(mock);
+    } 
+}
+
+internal class MockDescriptor
+{
+    private ElementMock Mock { get; }
+
+    public MockDescriptor(ElementMock mock)
+    {
+        Mock = mock;
+    }
+
+    public MockDescriptor Size(float width, float height)
+    {
+        Mock.TotalWidth = width;
+        Mock.TotalHeight = height;
+
+        return this;
+    }
+}

+ 69 - 0
Source/QuestPDF.LayoutTests/TestEngine/LayoutTest.cs

@@ -0,0 +1,69 @@
+using System.Diagnostics;
+using System.Runtime.CompilerServices;
+using QuestPDF.Elements;
+using QuestPDF.Fluent;
+using QuestPDF.Infrastructure;
+
+namespace QuestPDF.LayoutTests.TestEngine;
+
+internal sealed class LayoutTest
+{
+    private string TestIdentifier { get; set; }
+    private LayoutTestResult TestResult { get; } = new LayoutTestResult();
+  
+    public static LayoutTest HavingSpaceOfSize(float width, float height, [CallerMemberName] string testIdentifier = "test")
+    {
+        var layoutTest = new LayoutTest
+        {
+            TestIdentifier = testIdentifier,
+            
+            TestResult =
+            {
+                PageSize = new Size(width, height)
+            }
+        };
+
+        return layoutTest;
+    }
+
+    public LayoutTest WithContent(Action<IContainer> handler)
+    {
+        var container = new Container();
+        container.Element(handler);
+
+        TestResult.ActualLayout = LayoutTestExecutor.Execute(TestResult.PageSize, container);
+        
+        return this;
+    }
+
+    public void ExpectedDrawResult(Action<ExpectedDocumentLayoutDescriptor> handler)
+    {
+        var builder = new ExpectedDocumentLayoutDescriptor();
+        handler(builder);
+
+        TestResult.ExpectedLayout = builder.DocumentLayout;
+
+        GenerateTestPreview();
+        LayoutTestValidator.Validate(TestResult);
+    }
+
+    private void GenerateTestPreview()
+    {
+        if (!Debugger.IsAttached)
+        {
+            Console.WriteLine("Debugger is not attached. Skipping test preview generation");
+            return;
+        }
+        
+        var path = Path.Combine(Path.GetTempPath(), $"{TestIdentifier}.pdf");
+        
+        if (File.Exists(path))
+            File.Delete(path);
+        
+        var stream = new FileStream(path, FileMode.CreateNew);
+        LayoutTestResultVisualization.Visualize(TestResult, stream);
+        stream.Dispose();
+        
+        Console.WriteLine($"Generated test case preview: {path}");
+    }
+}

+ 14 - 0
Source/QuestPDF.LayoutTests/TestEngine/LayoutTestException.cs

@@ -0,0 +1,14 @@
+namespace QuestPDF.LayoutTests.TestEngine;
+
+public sealed class LayoutTestException : Exception
+{
+    internal LayoutTestException(string message) : base(message)
+    {
+            
+    }
+    
+    internal LayoutTestException(string message, Exception innerException) : base(message, innerException)
+    {
+            
+    }
+}

+ 99 - 0
Source/QuestPDF.LayoutTests/TestEngine/LayoutTestExecutor.cs

@@ -0,0 +1,99 @@
+using QuestPDF.Drawing;
+using QuestPDF.Drawing.Proxy;
+using QuestPDF.Elements;
+using QuestPDF.Helpers;
+using QuestPDF.Infrastructure;
+
+namespace QuestPDF.LayoutTests.TestEngine;
+
+internal static class LayoutTestExecutor
+{
+    public static LayoutTestResult.DocumentLayout Execute(Size pageSize, Container container)
+    {
+        var (pageSizes, generatesInfiniteLayout) = GenerateDocument();
+
+        return new LayoutTestResult.DocumentLayout
+        {
+            Pages = CollectMockInformation(pageSizes),
+            GeneratesInfiniteLayout = generatesInfiniteLayout
+        };
+
+        (List<Size> pageSizes, bool generatesInfiniteLayout) GenerateDocument()
+        {
+            // inject dependencies
+            var pageContext = new PageContext();
+            pageContext.ResetPageNumber();
+
+            var canvas = new PreviewerCanvas();
+        
+            container.InjectDependencies(pageContext, canvas);
+        
+            // distribute global state
+            container.ApplyInheritedAndGlobalTexStyle(TextStyle.Default);
+            container.ApplyContentDirection(ContentDirection.LeftToRight);
+            container.ApplyDefaultImageConfiguration(DocumentSettings.Default.ImageRasterDpi, DocumentSettings.Default.ImageCompressionQuality, true);
+        
+            // render
+            container.VisitChildren(x => (x as IStateResettable)?.ResetState());
+        
+            canvas.BeginDocument();
+            
+            var pageSizes = new List<Size>();
+        
+            while(true)
+            {
+                var spacePlan = container.Measure(pageSize);
+                pageSizes.Add(spacePlan);
+
+                if (spacePlan.Type == SpacePlanType.Wrap)
+                {
+                    canvas.EndDocument();
+                    return (pageSizes, true);
+                }
+
+                try
+                {
+                    canvas.BeginPage(pageSize);
+                    container.Draw(pageSize);
+                
+                    pageContext.IncrementPageNumber();
+                }
+                catch (Exception exception)
+                {
+                    canvas.EndDocument();
+                    throw new LayoutTestException("Exception occured during layout execution", exception);
+                }
+
+                canvas.EndPage();
+
+                if (spacePlan.Type == SpacePlanType.FullRender)
+                    break;
+            }
+
+            return (pageSizes, false);
+        }
+
+        ICollection<LayoutTestResult.PageLayout> CollectMockInformation(ICollection<Size> pageSizes)
+        {
+            // mock cannot contain another mock, flat structure
+            var mocks = container.ExtractElementsOfType<ElementMock>().Select(x => x.Value); 
+
+            return mocks
+                .SelectMany(x => x.DrawingCommands)
+                .GroupBy(x => x.PageNumber)
+                .Select(x => new LayoutTestResult.PageLayout
+                {
+                    RequiredArea = pageSizes.ElementAt(x.Key - 1),
+                    Mocks = x
+                        .Select(y => new LayoutTestResult.MockLayoutPosition
+                        {
+                            MockId = y.MockId,
+                            Size = y.Size,
+                            Position = y.Position
+                        })
+                        .ToList()
+                })
+                .ToList();
+        }
+    }
+}

+ 244 - 0
Source/QuestPDF.LayoutTests/TestEngine/LayoutTestOutputVisualization.cs

@@ -0,0 +1,244 @@
+using QuestPDF.Drawing;
+using QuestPDF.Fluent;
+using QuestPDF.Helpers;
+using QuestPDF.Infrastructure;
+using SkiaSharp;
+
+namespace QuestPDF.LayoutTests.TestEngine;
+
+internal static class LayoutTestResultVisualization
+{
+    // output settings
+    private const int OutputImageScale = 2;
+    private const int Padding = 10;
+    
+    // document colors
+    private const string DocumentBackgroundColor = Colors.Grey.Darken2;
+    private const string PageBackgroundColor = Colors.Grey.Lighten1;
+    private const string RequiredAreaBackgroundColor = Colors.White;
+    
+    // grid configuration
+    private const float GridSize = 10;
+    private const float GridLineThickness = 0.25f;
+    private const byte GridLineTransparency = 48;
+    
+    // mock drawing settings
+    private const byte OccludedMockBorderThickness = 5;
+
+    private static readonly string[] DefaultElementColors =
+    {
+        Colors.DeepPurple.Lighten2,
+        Colors.Blue.Lighten2,
+        Colors.Cyan.Lighten2,
+        Colors.Green.Lighten2,
+        Colors.Lime.Lighten2,
+        Colors.Amber.Lighten2,
+        Colors.Brown.Lighten2,
+        
+        Colors.DeepPurple.Medium,
+        Colors.Blue.Medium,
+        Colors.Cyan.Medium,
+        Colors.Green.Medium,
+        Colors.Lime.Medium,
+        Colors.Amber.Medium,
+        Colors.Brown.Medium,
+    };
+    
+    // implementations
+    public static void Visualize(LayoutTestResult result, Stream stream)
+    {
+        // determine output dimenstions
+        var numberOfPages = Math.Max(result.ActualLayout.Pages.Count, result.ExpectedLayout.Pages.Count);
+
+        var canvasWidth = result.PageSize.Width * 2 + Padding * 4;
+        var canvasHeight = result.PageSize.Height * numberOfPages + Padding * (numberOfPages + 2);
+
+        // create PDF
+        using var pdf = SKDocument.CreatePdf(stream);
+        using var canvas = pdf.BeginPage(canvasWidth * OutputImageScale, canvasHeight * OutputImageScale);
+        
+        canvas.Scale(OutputImageScale, OutputImageScale);
+        canvas.Clear(SKColor.Parse(DocumentBackgroundColor));
+
+        // draw content
+        var mockColors = AssignColorsToMocks();
+        DrawDocument();
+
+        // finish generation
+        pdf.EndPage();
+        pdf.Close();
+
+        IDictionary<string, string> AssignColorsToMocks()
+        {
+            var mocks = Enumerable
+                .Concat(result.ActualLayout.Pages, result.ExpectedLayout.Pages)
+                .SelectMany(x => x.Mocks)
+                .Select(x => x.MockId)
+                .Distinct()
+                .ToList();
+
+            return Enumerable
+                .Range(0, mocks.Count)
+                .ToDictionary(i => mocks[i], i => DefaultElementColors[i]);
+        }
+
+        void DrawDocument()
+        {
+            canvas.Translate(Padding, Padding);
+            
+            // draw title
+            using var textPaint = TextStyle.LibraryDefault.FontSize(8).FontColor(Colors.White).Bold().ToPaint().Clone();
+            textPaint.TextAlign = SKTextAlign.Center;
+
+            var actualHeaderPosition = new SKPoint(result.PageSize.Width / 2, textPaint.TextSize / 2);
+            canvas.DrawText("ACTUAL", actualHeaderPosition, textPaint);
+            
+            var expectedHeaderPosition = new SKPoint(Padding * 2 + result.PageSize.Width * 1.5f, textPaint.TextSize / 2);
+            canvas.DrawText("EXPECTED", expectedHeaderPosition, textPaint);
+            
+            // draw pages
+            canvas.Save();
+            canvas.Translate(0, Padding);
+            
+            foreach (var pageIndex in Enumerable.Range(0, numberOfPages))
+            {
+                var actualPage = result.ActualLayout.Pages.ElementAtOrDefault(pageIndex);
+                var expectedPage = result.ExpectedLayout.Pages.ElementAtOrDefault(pageIndex);
+                
+                DrawPage(actualPage);
+                DrawLayoutDifferences(actualPage, expectedPage);
+                
+                canvas.Translate(result.PageSize.Width + Padding, 0);
+                canvas.DrawText((pageIndex + 1).ToString(), 0, textPaint.TextSize, textPaint);
+                
+                canvas.Translate(Padding, 0);
+                DrawPage(expectedPage);
+                DrawLayoutDifferences(expectedPage, actualPage);
+                
+                canvas.Translate(-result.PageSize.Width - Padding * 2, result.PageSize.Height + Padding);
+            }
+
+            canvas.Restore();
+        }
+        
+        void DrawPage(LayoutTestResult.PageLayout? pageLayout)
+        {
+            // draw page
+            using var availableAreaPaint = new SKPaint
+            {
+                Color = SKColor.Parse(PageBackgroundColor)
+            };
+            
+            canvas.DrawRect(0, 0, result.PageSize.Width, result.PageSize.Height, availableAreaPaint);
+            
+            if (pageLayout == null)
+            {
+                DrawGridLines();
+                return;
+            }
+            
+            // draw required area
+            using var requiredAreaPaint = new SKPaint
+            {
+                Color = SKColor.Parse(RequiredAreaBackgroundColor)
+            };
+            
+            canvas.DrawRect(0, 0, pageLayout.RequiredArea.Width, pageLayout.RequiredArea.Height, requiredAreaPaint);
+            
+            // draw mocks
+            foreach (var mock in pageLayout.Mocks)
+                DrawMock(mock);
+            
+            foreach (var mock in pageLayout.Mocks.GetOverlappingItems())
+                DrawOccludedMock(mock.belowMockId);
+            
+            DrawGridLines();
+        }
+
+        void DrawMock(LayoutTestResult.MockLayoutPosition mock)
+        {
+            var color = mockColors[mock.MockId];
+                
+            using var mockAreaPaint = new SKPaint
+            {
+                Color = SKColor.Parse(color)
+            };
+            
+            canvas.Save();
+            
+            canvas.Translate(mock.Position.X, mock.Position.Y);
+            canvas.DrawRect(0, 0, mock.Size.Width, mock.Size.Height, mockAreaPaint);
+
+            canvas.Restore();
+        }
+        
+        void DrawOccludedMock(LayoutTestResult.MockLayoutPosition mock)
+        {
+            var color = mockColors[mock.MockId];
+                
+            using var mockBorderPaint = new SKPaint
+            {
+                Color = SKColor.Parse(color),
+                IsStroke = true,
+                StrokeWidth = OccludedMockBorderThickness
+            };
+
+            var borderPosition = new SKRect(0, 0, mock.Size.Width, mock.Size.Height);
+            borderPosition.Inflate(-OccludedMockBorderThickness / 2f, -OccludedMockBorderThickness / 2f);
+            
+            canvas.Save();
+            canvas.Translate(mock.Position.X, mock.Position.Y);
+            canvas.DrawRect(borderPosition, mockBorderPaint);
+            canvas.Restore();
+        }
+        
+        void DrawGridLines()
+        {
+            using var paint = new SKPaint
+            {
+                Color = SKColor.Parse(Colors.Black).WithAlpha(GridLineTransparency),
+                StrokeWidth = GridLineThickness
+            };
+
+            var verticalLineCount = (int)Math.Floor(result.PageSize.Width / GridSize);
+            var horizontalLineCount = (int)Math.Floor(result.PageSize.Height / GridSize);
+            
+            foreach (var i in Enumerable.Range(1, verticalLineCount))
+                canvas.DrawLine(new SKPoint(i * GridSize, 0), new SKPoint(i * GridSize, result.PageSize.Height), paint);
+            
+            foreach (var i in Enumerable.Range(1, horizontalLineCount))
+                canvas.DrawLine(new SKPoint(0, i * GridSize), new SKPoint(result.PageSize.Width, i * GridSize), paint);
+        }
+
+        void DrawLayoutDifferences(LayoutTestResult.PageLayout? target, LayoutTestResult.PageLayout? compareWith)
+        {
+            using var targetPath = BuildPathFromLayout(target);
+            using var compareWithPath = BuildPathFromLayout(compareWith);
+
+            using var differencePath = targetPath.Op(compareWithPath, SKPathOp.Difference);
+            
+            AnnotateInvalidAreaHelper.Annotate(canvas, differencePath);
+            
+            SKPath BuildPathFromLayout(LayoutTestResult.PageLayout? layout)
+            {
+                var resultPath = new SKPath();
+
+                if (layout == null)
+                    return resultPath;
+                
+                foreach (var mock in layout.Mocks)
+                {
+                    var position = new SKRect(
+                        mock.Position.X, 
+                        mock.Position.Y, 
+                        mock.Position.X + mock.Size.Width, 
+                        mock.Position.Y + mock.Size.Height);
+                    
+                    resultPath.AddRect(position);
+                }
+
+                return resultPath;
+            }
+        }
+    }
+}

+ 55 - 0
Source/QuestPDF.LayoutTests/TestEngine/LayoutTestResult.cs

@@ -0,0 +1,55 @@
+using QuestPDF.Infrastructure;
+
+namespace QuestPDF.LayoutTests.TestEngine;
+
+internal sealed class LayoutTestResult
+{
+    public Size PageSize { get; set; }
+    
+    public DocumentLayout ActualLayout { get; set; }
+    public DocumentLayout ExpectedLayout { get; set; }
+
+    public sealed class DocumentLayout
+    {
+        public ICollection<PageLayout> Pages { get; set; } = new List<PageLayout>();
+        public bool GeneratesInfiniteLayout { get; set; }
+    }
+    
+    public sealed class PageLayout
+    {
+        public Size RequiredArea { get; set; }
+        public ICollection<MockLayoutPosition> Mocks { get; set; }
+    }
+
+    public sealed class MockLayoutPosition
+    {
+        public string MockId { get; set; }
+        public Position Position { get; set; }
+        public Size Size { get; set; }
+    }
+}
+
+internal static class LayoutTestResultHelpers
+{
+    public static IEnumerable<(LayoutTestResult.MockLayoutPosition belowMockId, LayoutTestResult.MockLayoutPosition aboveMockId)> GetOverlappingItems(this ICollection<LayoutTestResult.MockLayoutPosition> items)
+    {
+        for (var i = 0; i < items.Count; i++)
+        {
+            for (var j = i + 1; j < items.Count; j++)
+            {
+                var beforeChild = items.ElementAt(i);
+                var afterChild = items.ElementAt(j);
+
+                var beforeBoundingBox = BoundingBox.From(beforeChild.Position, beforeChild.Size);
+                var afterBoundingBox = BoundingBox.From(afterChild.Position, afterChild.Size);
+
+                var intersection = BoundingBoxExtensions.Intersection(beforeBoundingBox, afterBoundingBox);
+                        
+                if (intersection == null)
+                    continue;
+
+                yield return (beforeChild, afterChild);
+            }
+        }
+    }
+}

+ 88 - 0
Source/QuestPDF.LayoutTests/TestEngine/LayoutTestValidator.cs

@@ -0,0 +1,88 @@
+using QuestPDF.Infrastructure;
+
+namespace QuestPDF.LayoutTests.TestEngine;
+
+internal static class LayoutTestValidator
+{
+    public static void Validate(LayoutTestResult result)
+    {
+        if (result.ActualLayout.GeneratesInfiniteLayout && !result.ExpectedLayout.GeneratesInfiniteLayout)
+            throw new LayoutTestException("Provided content generates unexpected infinite layout");
+        
+        if (!result.ActualLayout.GeneratesInfiniteLayout && result.ExpectedLayout.GeneratesInfiniteLayout)
+            throw new LayoutTestException("Provided content is expected to generate infinite layout but it does not");
+
+        var actualPages = result.ActualLayout.Pages;
+        var expectedPages = result.ExpectedLayout.Pages;
+        
+        if (actualPages.Count != expectedPages.Count)
+            throw new LayoutTestException($"Content return layout with {actualPages.Count} pages but expected {expectedPages.Count} pages");
+        
+        foreach (var i in Enumerable.Range(0, actualPages.Count))
+        {
+            try
+            {
+                var actualPage = actualPages.ElementAt(i);
+                var expectedPage = expectedPages.ElementAt(i);
+
+                ValidatePage(actualPage, expectedPage);
+            }
+            catch (LayoutTestException exception)
+            {
+                throw new LayoutTestException($"Found issue on page number {i + 1}: {exception.Message}");
+            }
+            catch (Exception exception)
+            {
+                throw new LayoutTestException($"Encountered exception during validating page number {i + 1}", exception);
+            }
+        }
+
+        static void ValidatePage(LayoutTestResult.PageLayout actualLayout, LayoutTestResult.PageLayout expectedLayout)
+        {
+            if (Math.Abs(actualLayout.RequiredArea.Width - expectedLayout.RequiredArea.Width) > Size.Epsilon)
+                throw new LayoutTestException($"Taken horizontal area is equal to {actualLayout.RequiredArea.Width} but expected {expectedLayout.RequiredArea.Width}");
+            
+            if (Math.Abs(actualLayout.RequiredArea.Height - expectedLayout.RequiredArea.Height) > Size.Epsilon)
+                throw new LayoutTestException($"Taken vertical area is equal to {actualLayout.RequiredArea.Height} but expected {expectedLayout.RequiredArea.Height}");
+            
+            if (actualLayout.Mocks.Count != expectedLayout.Mocks.Count)
+                throw new LayoutTestException($"Visible {actualLayout.Mocks.Count} mocks but expected {expectedLayout.Mocks.Count}");
+
+            ValidatePositionAndSizeOfMocks(actualLayout, expectedLayout);
+            ValidateDrawingOrder(actualLayout, expectedLayout);
+        }
+
+        static void ValidatePositionAndSizeOfMocks(LayoutTestResult.PageLayout actualLayout, LayoutTestResult.PageLayout expectedLayout)
+        {
+            foreach (var expectedMock in expectedLayout.Mocks)
+            {
+                var matchingActualMock = actualLayout
+                    .Mocks
+                    .Where(x => x.MockId == expectedMock.MockId)
+                    .Where(x => Position.Equal(x.Position, expectedMock.Position))
+                    .Where(x => Size.Equal(x.Size, expectedMock.Size))
+                    .Count();
+
+                if (matchingActualMock == 0)
+                    throw new Exception($"Cannot find '{expectedMock.MockId}' mock on position {expectedMock.Position} and size {expectedMock.Size}");
+                
+                if (matchingActualMock > 1)
+                    throw new Exception($"Found multiple '{expectedMock.MockId}' mocks on position {expectedMock.Position} and size {expectedMock.Size}");
+            }
+        }
+
+        static void ValidateDrawingOrder(LayoutTestResult.PageLayout actualLayout, LayoutTestResult.PageLayout expectedLayout)
+        {
+            var actualOverlaps = actualLayout.Mocks.GetOverlappingItems().ToList();
+            var expectedOverlaps = expectedLayout.Mocks.GetOverlappingItems().ToList();
+            
+            foreach (var expectedOverlap in expectedOverlaps)
+            {
+                var matchingActualElements = actualOverlaps.Count(actualOverlap => actualOverlap.belowMockId == expectedOverlap.belowMockId && actualOverlap.aboveMockId == expectedOverlap.aboveMockId);
+
+                if (matchingActualElements != 1)
+                    throw new Exception($"Mock '{expectedOverlap.belowMockId}' should be visible below '{expectedOverlap.aboveMockId}' mock");
+            }
+        }
+    }
+}

+ 67 - 0
Source/QuestPDF.LayoutTests/TestEngine/MockChild.cs

@@ -0,0 +1,67 @@
+using QuestPDF.Drawing;
+using QuestPDF.Helpers;
+using QuestPDF.Infrastructure;
+
+namespace QuestPDF.LayoutTests.TestEngine;
+
+internal class MockDrawingCommand
+{
+    public string MockId { get; set; }
+    public int PageNumber { get; set; }
+    public Position Position { get; set; }
+    public Size Size { get; set; }
+}
+
+internal class ElementMock : Element
+{
+    public string MockId { get; set; }
+    
+    public float TotalWidth { get; set; }
+    public float TotalHeight { get; set; }
+    
+    private float HeightOffset { get; set; }
+
+    internal List<MockDrawingCommand> DrawingCommands { get; } = new();
+    
+    internal override SpacePlan Measure(Size availableSpace)
+    {
+        if (TotalWidth > availableSpace.Width)
+            return SpacePlan.Wrap();
+        
+        if (availableSpace.Height < Size.Epsilon)
+            return SpacePlan.Wrap();
+
+        var remainingHeight = TotalHeight - HeightOffset;
+
+        if (remainingHeight == 0)
+            return SpacePlan.FullRender(Size.Zero);
+        
+        if (remainingHeight > availableSpace.Height)
+            return SpacePlan.PartialRender(TotalWidth, availableSpace.Height);
+        
+        return SpacePlan.FullRender(TotalWidth, remainingHeight);
+    }
+
+    internal override void Draw(Size availableSpace)
+    {
+        var height = Math.Min(TotalHeight - HeightOffset, availableSpace.Height);
+        var size = new Size(TotalWidth, height);
+        
+        HeightOffset += height;
+        
+        Canvas.DrawRectangle(Position.Zero, size, Colors.Grey.Medium);
+        
+        if (Canvas is not SkiaCanvasBase canvasBase)
+            return;
+
+        var matrix = canvasBase.Canvas.TotalMatrix;
+        
+        DrawingCommands.Add(new MockDrawingCommand
+        {
+            MockId = MockId,
+            PageNumber = PageContext.CurrentPage,
+            Position = new Position(matrix.TransX / matrix.ScaleX, matrix.TransY / matrix.ScaleY),
+            Size = size
+        });
+    }
+}

+ 1 - 0
Source/QuestPDF.LayoutTests/Usings.cs

@@ -0,0 +1 @@
+global using NUnit.Framework;

+ 6 - 0
Source/QuestPDF.sln

@@ -17,6 +17,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Configuration", "Configurat
 EndProject
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuestPDF.Previewer", "QuestPDF.Previewer\QuestPDF.Previewer.csproj", "{B2FF6003-3A45-4A78-A85D-B86C7F01D054}"
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuestPDF.Previewer", "QuestPDF.Previewer\QuestPDF.Previewer.csproj", "{B2FF6003-3A45-4A78-A85D-B86C7F01D054}"
 EndProject
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuestPDF.LayoutTests", "QuestPDF.LayoutTests\QuestPDF.LayoutTests.csproj", "{37DC7D64-CBAC-4C00-B4CE-CA50ABF764EB}"
+EndProject
 Global
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
 		Debug|Any CPU = Debug|Any CPU
@@ -47,5 +49,9 @@ Global
 		{B2FF6003-3A45-4A78-A85D-B86C7F01D054}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{B2FF6003-3A45-4A78-A85D-B86C7F01D054}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{B2FF6003-3A45-4A78-A85D-B86C7F01D054}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{B2FF6003-3A45-4A78-A85D-B86C7F01D054}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{B2FF6003-3A45-4A78-A85D-B86C7F01D054}.Release|Any CPU.Build.0 = Release|Any CPU
 		{B2FF6003-3A45-4A78-A85D-B86C7F01D054}.Release|Any CPU.Build.0 = Release|Any CPU
+		{37DC7D64-CBAC-4C00-B4CE-CA50ABF764EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{37DC7D64-CBAC-4C00-B4CE-CA50ABF764EB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{37DC7D64-CBAC-4C00-B4CE-CA50ABF764EB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{37DC7D64-CBAC-4C00-B4CE-CA50ABF764EB}.Release|Any CPU.Build.0 = Release|Any CPU
 	EndGlobalSection
 	EndGlobalSection
 EndGlobal
 EndGlobal

+ 14 - 1
Source/QuestPDF/Infrastructure/Position.cs

@@ -1,4 +1,6 @@
-namespace QuestPDF.Infrastructure
+using System;
+
+namespace QuestPDF.Infrastructure
 {
 {
     internal readonly struct Position
     internal readonly struct Position
     {
     {
@@ -18,6 +20,17 @@
             return new Position(-X, -Y);
             return new Position(-X, -Y);
         }
         }
         
         
+        public static bool Equal(Position first, Position second)
+        {
+            if (Math.Abs(first.X - second.X) > Size.Epsilon)
+                return false;
+            
+            if (Math.Abs(first.Y - second.Y) > Size.Epsilon)
+                return false;
+
+            return true;
+        }
+        
         public override string ToString() => $"(Left: {X:N3}, Top: {Y:N3})";
         public override string ToString() => $"(Left: {X:N3}, Top: {Y:N3})";
     }
     }
 }
 }

+ 14 - 1
Source/QuestPDF/Infrastructure/Size.cs

@@ -1,4 +1,6 @@
-namespace QuestPDF.Infrastructure
+using System;
+
+namespace QuestPDF.Infrastructure
 {
 {
     public readonly struct Size
     public readonly struct Size
     {
     {
@@ -17,6 +19,17 @@
             Height = height;
             Height = height;
         }
         }
         
         
+        internal static bool Equal(Size first, Size second)
+        {
+            if (Math.Abs(first.Width - second.Width) > Size.Epsilon)
+                return false;
+            
+            if (Math.Abs(first.Height - second.Height) > Size.Epsilon)
+                return false;
+
+            return true;
+        }
+        
         public override string ToString() => $"(Width: {Width:N3}, Height: {Height:N3})";
         public override string ToString() => $"(Width: {Width:N3}, Height: {Height:N3})";
     }
     }
 }
 }

+ 1 - 0
Source/QuestPDF/QuestPDF.csproj

@@ -34,6 +34,7 @@
         <InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
         <InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
         <InternalsVisibleTo Include="QuestPDF.ReportSample" />
         <InternalsVisibleTo Include="QuestPDF.ReportSample" />
         <InternalsVisibleTo Include="QuestPDF.UnitTests" />
         <InternalsVisibleTo Include="QuestPDF.UnitTests" />
+        <InternalsVisibleTo Include="QuestPDF.LayoutTests" />
         <InternalsVisibleTo Include="QuestPDF.Examples" />
         <InternalsVisibleTo Include="QuestPDF.Examples" />
         <InternalsVisibleTo Include="QuestPDF.Previewer" />
         <InternalsVisibleTo Include="QuestPDF.Previewer" />
     </ItemGroup>
     </ItemGroup>