Browse Source

Prototype implementation

Marcin Ziąbek 2 years ago
parent
commit
829ef33daf

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

+ 16 - 0
Source/QuestPDF.LayoutTests/TestEngine/DrawCommand.cs

@@ -0,0 +1,16 @@
+using QuestPDF.Infrastructure;
+
+namespace QuestPDF.LayoutTests.TestEngine;
+
+internal class PageDrawingCommand
+{
+    public Size RequiredArea { get; set; }
+    public ICollection<ChildDrawingCommand> Children { get; set; }
+}
+
+internal class ChildDrawingCommand
+{
+    public string ChildId { get; set; }
+    public Position Position { get; set; }
+    public Size Size { get; set; }
+}

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

@@ -0,0 +1,364 @@
+using QuestPDF.Drawing;
+using QuestPDF.Drawing.Proxy;
+using QuestPDF.Elements;
+using QuestPDF.Fluent;
+using QuestPDF.Helpers;
+using QuestPDF.Infrastructure;
+using SkiaSharp;
+
+namespace QuestPDF.LayoutTests.TestEngine;
+
+internal class LayoutBuilderDescriptor
+{
+    public void Compose(Action<IContainer> container)
+    {
+        
+    }
+}
+
+internal class DocumentLayoutBuilder
+{
+    public List<PageDrawingCommand> Commands { get; } = new(); 
+    
+    public PageLayoutDescriptor Page()
+    {
+        var page = new PageDrawingCommand();
+        Commands.Add(page);
+        return new PageLayoutDescriptor(page);
+    }
+}
+
+internal class PageLayoutDescriptor
+{
+    private PageDrawingCommand Command { get; }
+
+    public PageLayoutDescriptor(PageDrawingCommand command)
+    {
+        Command = command;
+    }
+
+    public PageLayoutDescriptor TakenAreaSize(float width, float height)
+    {
+        Command.RequiredArea = new Size(width, height);
+        return this;
+    }
+    
+    public PageLayoutDescriptor Content(Action<PageLayoutBuilder> content)
+    {
+        var pageContent = new PageLayoutBuilder();
+        content(pageContent);
+        Command.Children = pageContent.Commands;
+        return this;
+    }
+}
+
+internal class PageLayoutBuilder
+{
+    public List<ChildDrawingCommand> Commands { get;} = new();
+    
+    public ChildLayoutDescriptor Child(string childId)
+    {
+        var child = new ChildDrawingCommand { ChildId = childId };
+        Commands.Add(child);
+        return new ChildLayoutDescriptor(child);
+    }
+}
+
+internal class ChildLayoutDescriptor
+{
+    private ChildDrawingCommand Command { get; }
+
+    public ChildLayoutDescriptor(ChildDrawingCommand command)
+    {
+        Command = command;
+    }
+
+    public ChildLayoutDescriptor Position(float x, float y)
+    {
+        Command.Position = new Position(x, y);
+        return this;
+    }
+    
+    public ChildLayoutDescriptor Size(float width, float height)
+    {
+        Command.Size = new Size(width, height);
+        return this;
+    }
+}
+
+internal class ExpectedLayoutChildPosition
+{
+    public string ElementId { get; set; }
+    public int PageNumber { get; set; }
+    public int DepthIndex { get; set; }
+    public Position Position { get; set; }
+    public Size Size { get; set; }
+}
+
+public static class ElementExtensions
+{
+    public static void Mock(this IContainer element, string id, float width, float height)
+    {
+        var mock = new MockChild
+        {
+            Id = id,
+            TotalWidth = width,
+            TotalHeight = height
+        };
+        
+        element.Element(mock);
+    } 
+}
+
+internal class LayoutTest
+{
+    private const string DocumentColor = Colors.Grey.Lighten1;
+    private const string PageColor = Colors.Grey.Lighten3;
+    private const string TargetColor = Colors.White;
+    
+    public Size PageSize { get; set; }
+    public ICollection<PageDrawingCommand> ActualCommands { get; set; }
+    public ICollection<PageDrawingCommand> ExpectedCommands { get; set; }
+    
+    public static LayoutTest HavingSpaceOfSize(float width, float height)
+    {
+        return new LayoutTest
+        {
+            PageSize = new Size(width, height)
+        };
+    }
+
+    public LayoutTest WithContent(Action<IContainer> handler)
+    {
+        // compose content
+        var container = new Container();
+        container.Element(handler);
+
+        ActualCommands = GenerateResult(PageSize, container);
+        
+        return this;
+    }
+
+    private static ICollection<PageDrawingCommand> GenerateResult(Size pageSize, Container container)
+    {
+        // 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)
+            {
+                throw new Exception();
+            }
+
+            try
+            {
+                canvas.BeginPage(pageSize);
+                container.Draw(pageSize);
+                
+                pageContext.IncrementPageNumber();
+            }
+            catch (Exception exception)
+            {
+                canvas.EndDocument();
+                throw new Exception();
+            }
+
+            canvas.EndPage();
+
+            if (spacePlan.Type == SpacePlanType.FullRender)
+                break;
+        }
+        
+        // extract results
+        var mocks = container.ExtractElementsOfType<MockChild>().Select(x => x.Value); // mock cannot contain another mock, flat structure
+
+        return mocks
+            .SelectMany(x => x.DrawingCommands)
+            .GroupBy(x => x.PageNumber)
+            .Select(x => new PageDrawingCommand
+            {
+                RequiredArea = pageSizes[x.Key - 1],
+                Children = x
+                    .Select(y => new ChildDrawingCommand
+                    {
+                        ChildId = y.ChildId,
+                        Size = y.Size,
+                        Position = y.Position
+                    })
+                    .ToList()
+            })
+            .ToList();
+    }
+    
+    public void ExpectWrap()
+    {
+        
+    }
+    
+    public LayoutTest ExpectedDrawResult(Action<DocumentLayoutBuilder> handler)
+    {
+        var builder = new DocumentLayoutBuilder();
+        handler(builder);
+
+        ExpectedCommands = builder.Commands;
+        return this;
+    }
+
+    public void CompareVisually()
+    {
+        VisualizeExpectedResult(PageSize, ActualCommands, ExpectedCommands);
+    }
+    
+    private static void VisualizeExpectedResult(Size pageSize, ICollection<PageDrawingCommand> left, ICollection<PageDrawingCommand> right)
+    {
+        var path = "test.pdf";
+        
+        if (File.Exists(path))
+            File.Delete(path);
+        
+        // default colors
+        var defaultColors = new string[]
+        {
+            Colors.Red.Medium,
+            Colors.Green.Medium,
+            Colors.Blue.Medium,
+            Colors.Pink.Medium,
+            Colors.Orange.Medium,
+            Colors.Lime.Medium,
+            Colors.Cyan.Medium,
+            Colors.Indigo.Medium
+        };
+        
+        // determine children colors
+        var children = Enumerable
+            .Concat(left, right)
+            .SelectMany(x => x.Children)
+            .Select(x => x.ChildId)
+            .Distinct()
+            .ToList();
+
+        var colors = Enumerable
+            .Range(0, children.Count)
+            .ToDictionary(i => children[i], i => defaultColors[i]);
+
+        // create new pdf document output
+        var matrixHeight = Math.Max(left.Count, right.Count);
+        
+        const int pagePadding = 25;
+        var imageInfo = new SKImageInfo((int)pageSize.Width * 2 + pagePadding * 4, (int)(pageSize.Height * matrixHeight + pagePadding * (matrixHeight + 2)));
+
+        using var pdf = SKDocument.CreatePdf(path);
+        using var canvas = pdf.BeginPage(imageInfo.Width, imageInfo.Height);
+        
+        // page background
+        canvas.Clear(SKColor.Parse(DocumentColor));
+        
+        // chain titles
+        
+        // available area
+        using var titlePaint = TextStyle.LibraryDefault.FontSize(16).Bold().ToPaint().Clone();
+        titlePaint.TextAlign = SKTextAlign.Center;
+
+        canvas.Save();
+        
+        canvas.Translate(pagePadding + pageSize.Width / 2f, pagePadding + titlePaint.TextSize / 2);
+        canvas.DrawText("RESULT", 0, 0, titlePaint);
+        
+        canvas.Translate(pagePadding * 2 + pageSize.Width, 0);
+        canvas.DrawText("EXPECTED", 0, 0, titlePaint);
+        
+        canvas.Restore();
+
+        // side visualization
+        canvas.Translate(pagePadding, pagePadding * 2);
+        DrawSide(left);
+        
+        canvas.Translate(pageSize.Width + pagePadding * 2, 0);
+        DrawSide(right);
+
+        pdf.EndPage();
+        pdf.Close();
+        
+        void DrawSide(ICollection<PageDrawingCommand> commands)
+        {
+            canvas.Save();
+            
+            foreach (var pageDrawingCommand in commands)
+            {
+                DrawPage(pageDrawingCommand);
+                canvas.Translate(0, pageSize.Height + pagePadding);
+            }
+            
+            canvas.Restore();
+        }
+
+        void DrawPage(PageDrawingCommand command)
+        {
+            // available area
+            using var availableAreaPaint = new SKPaint
+            {
+                Color = SKColor.Parse(PageColor)
+            };
+            
+            canvas.DrawRect(0, 0, pageSize.Width, pageSize.Height, availableAreaPaint);
+            
+            // taken area
+            using var takenAreaPaint = new SKPaint
+            {
+                Color = SKColor.Parse(TargetColor)
+            };
+            
+            canvas.DrawRect(0, 0, command.RequiredArea.Width, command.RequiredArea.Height, takenAreaPaint);
+        
+            // draw children
+            foreach (var child in command.Children)
+            {
+                canvas.Save();
+
+                var color = colors[child.ChildId];
+            
+                using var childBorderPaint = new SKPaint
+                {
+                    Color = SKColor.Parse(color),
+                    IsStroke = true,
+                    StrokeWidth = 2
+                };
+            
+                using var childAreaPaint = new SKPaint
+                {
+                    Color = SKColor.Parse(color).WithAlpha(64)
+                };
+            
+                canvas.Translate(child.Position.X, child.Position.Y);
+                canvas.DrawRect(0, 0, child.Size.Width, child.Size.Height, childAreaPaint);
+                canvas.DrawRect(0, 0, child.Size.Width, child.Size.Height, childBorderPaint);
+            
+                canvas.Restore();
+            }
+        }
+        
+        // save
+        GenerateExtensions.OpenFileUsingDefaultProgram(path);
+    }
+}

+ 60 - 0
Source/QuestPDF.LayoutTests/TestEngine/MockCanvas.cs

@@ -0,0 +1,60 @@
+using QuestPDF.Infrastructure;
+using SkiaSharp;
+
+namespace QuestPDF.LayoutTests.TestEngine;
+
+internal class MockCanvas : ICanvas
+{
+    private SKPictureRecorder PictureRecorder { get; }
+    private SKCanvas Canvas { get; }
+    
+    public MockCanvas()
+    {
+        
+    }
+    
+    public void Translate(Position vector)
+    {
+        
+    }
+
+    public void DrawRectangle(Position vector, Size size, string color)
+    {
+        
+    }
+
+    public void DrawText(SKTextBlob skTextBlob, Position position, TextStyle style)
+    {
+        
+    }
+
+    public void DrawImage(SKImage image, Position position, Size size)
+    {
+        
+    }
+
+    public void DrawHyperlink(string url, Size size)
+    {
+        
+    }
+
+    public void DrawSectionLink(string sectionName, Size size)
+    {
+        
+    }
+
+    public void DrawSection(string sectionName)
+    {
+        
+    }
+
+    public void Rotate(float angle)
+    {
+        
+    }
+
+    public void Scale(float scaleX, float scaleY)
+    {
+        
+    }
+}

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

@@ -0,0 +1,68 @@
+using QuestPDF.Drawing;
+using QuestPDF.Helpers;
+using QuestPDF.Infrastructure;
+
+namespace QuestPDF.LayoutTests.TestEngine;
+
+internal class MockDrawingCommand
+{
+    public string ChildId { get; set; }
+    public int PageNumber { get; set; }
+    public Position Position { get; set; }
+    public Size Size { get; set; }
+}
+
+internal class MockChild : Element
+{
+    public string Id { get; set; }
+    public string Color { get; set; } = Placeholders.Color();
+    
+    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);
+        
+        Canvas.DrawRectangle(Position.Zero, size, Color);
+
+        HeightOffset += height;
+        
+        if (Canvas is not SkiaCanvasBase canvasBase)
+            return;
+
+        var matrix = canvasBase.Canvas.TotalMatrix;
+        
+        DrawingCommands.Add(new MockDrawingCommand
+        {
+            ChildId = Id,
+            PageNumber = PageContext.CurrentPage,
+            Position = new Position(matrix.TransX / matrix.ScaleX, matrix.TransY / matrix.ScaleY),
+            Size = size
+        });
+    }
+}

+ 21 - 0
Source/QuestPDF.LayoutTests/TestEngine/MockChildren.cs

@@ -0,0 +1,21 @@
+using QuestPDF.Helpers;
+
+namespace QuestPDF.LayoutTests.TestEngine;
+
+internal static class MockChildren
+{
+    private static MockChild Create(string id, string color, float width, float height)
+    {
+        return new MockChild
+        {
+            Id = id,
+            Color = color,
+            TotalWidth = width,
+            TotalHeight = height
+        };
+    }
+
+    public static MockChild Red(float width, float height) => Create("red", Colors.Red.Medium, width, height);
+    public static MockChild Green(float width, float height) => Create("green", Colors.Green.Medium, width, height);
+    public static MockChild Blue(float width, float height) => Create("blue", Colors.Blue.Medium, width, height);
+}

+ 96 - 0
Source/QuestPDF.LayoutTests/UnitTest1.cs

@@ -0,0 +1,96 @@
+using QuestPDF.Fluent;
+using QuestPDF.Infrastructure;
+using QuestPDF.LayoutTests.TestEngine;
+
+namespace QuestPDF.LayoutTests;
+
+public class Tests
+{
+    [SetUp]
+    public void Setup()
+    {
+    }
+
+    [Test]
+    public void Test1()
+    {
+        LayoutTest
+            .HavingSpaceOfSize(200, 400)
+            .WithContent(content =>
+            {
+                content.Column(column =>
+                {
+                    column.Spacing(25);
+
+                    column.Item().Mock("a", 150, 200);
+                    column.Item().Mock("b", 150, 150);
+                    column.Item().Mock("c", 150, 100);
+                    column.Item().Mock("d", 150, 150);
+                    column.Item().Mock("e", 150, 300);
+                    column.Item().Mock("f", 150, 150);
+                    column.Item().Mock("g", 150, 100);
+                    column.Item().Mock("h", 150, 500);
+                });
+            })
+            .ExpectedDrawResult(document =>
+            {
+                document
+                    .Page()
+                    .TakenAreaSize(400, 300)
+                    .Content(page =>
+                    {
+                        page.Child("a").Position(0, 0).Size(250, 200);
+                        page.Child("b").Position(150, 50).Size(50, 150);
+                        page.Child("c").Position(200, 100).Size(100, 50);
+                    });
+                
+                document
+                    .Page()
+                    .TakenAreaSize(400, 300)
+                    .Content(page =>
+                    {
+                        page.Child("a").Position(0, 0).Size(150, 100);
+                        page.Child("b").Position(250, 150).Size(50, 150);
+                        page.Child("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", 150, 150);
+                    column.Item().Mock("b", 125, 100);
+                });
+            })
+            .ExpectedDrawResult(document =>
+            {
+                document
+                    .Page()
+                    .TakenAreaSize(150, 200)
+                    .Content(page =>
+                    {
+                        page.Child("a").Position(0, 0).Size(150, 150);
+                        page.Child("b").Position(0, 175).Size(125, 25);
+                    });
+                
+                document
+                    .Page()
+                    .TakenAreaSize(125, 75)
+                    .Content(page =>
+                    {
+                        page.Child("b").Position(0, 0).Size(125, 75);
+                    });
+            })
+            .CompareVisually();
+    }
+}

+ 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

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