Browse Source

Enhance dynamic component state management and add multi-column layout tests
Improve developer experience by automatically handling zero available space measurement in dynamic components

Marcin Ziąbek 4 days ago
parent
commit
c17cd5fa33

+ 118 - 0
Source/QuestPDF.LayoutTests/MultiColumnTests.cs

@@ -0,0 +1,118 @@
+using QuestPDF.Elements;
+using QuestPDF.Helpers;
+
+namespace QuestPDF.LayoutTests;
+
+public class MultiColumnTests
+{
+    [Test]
+    public void DynamicComponent()
+    {
+        LayoutTest
+            .HavingSpaceOfSize(400, 200)
+            .ForContent(content =>
+            {
+                content
+                    .Shrink()
+                    .MultiColumn(column =>
+                    {
+                        column.Content()
+                            .Mock("dynamic")
+                            .Dynamic(new CounterComponent());
+                    });
+            })
+            .ExpectDrawResult(document =>
+            {
+                document
+                    .Page()
+                    .RequiredAreaSize(400, 50)
+                    .Content(page =>
+                    {
+                        page.Mock("dynamic")
+                            .Position(0, 0)
+                            .Size(200, 50)
+                            .State(new DynamicHost.DynamicState()
+                            {
+                                IsRendered = false,
+                                ChildState = 2
+                            });
+                        
+                        page.Mock("dynamic")
+                            .Position(200, 0)
+                            .Size(200, 50)
+                            .State(new DynamicHost.DynamicState()
+                            {
+                                IsRendered = false,
+                                ChildState = 3
+                            });
+                    });
+                
+                document
+                    .Page()
+                    .RequiredAreaSize(400, 50)
+                    .Content(page =>
+                    {
+                        page.Mock("dynamic")
+                            .Position(0, 0)
+                            .Size(200, 50)
+                            .State(new DynamicHost.DynamicState()
+                            {
+                                IsRendered = false,
+                                ChildState = 4
+                            });
+                        
+                        page.Mock("dynamic")
+                            .Position(200, 0)
+                            .Size(200, 50)
+                            .State(new DynamicHost.DynamicState()
+                            {
+                                IsRendered = false,
+                                ChildState = 5
+                            });
+                    });
+                
+                document
+                    .Page()
+                    .RequiredAreaSize(400, 50)
+                    .Content(page =>
+                    {
+                        page.Mock("dynamic")
+                            .Position(0, 0)
+                            .Size(200, 50)
+                            .State(new DynamicHost.DynamicState()
+                            {
+                                IsRendered = true,
+                                ChildState = 6
+                            });
+                    });
+            });
+    }
+    
+    public class CounterComponent : IDynamicComponent<int>
+    {
+        public int State { get; set; } = 1;
+        
+        public DynamicComponentComposeResult Compose(DynamicContext context)
+        {
+            var content = context.CreateElement(element =>
+            {
+                element
+                    .Width(100)
+                    .Height(50)
+                    .Background(Colors.Grey.Lighten2)
+                    .AlignCenter()
+                    .AlignMiddle()
+                    .Text($"Item {State}")
+                    .SemiBold();
+            });
+
+            State++;
+
+            return new DynamicComponentComposeResult
+            {
+                Content = content,
+                HasMoreContent = State <= 5
+            };
+        }
+    }
+}

+ 40 - 2
Source/QuestPDF.LayoutTests/TestEngine/ElementObserver.cs

@@ -1,4 +1,7 @@
 using System.Diagnostics;
 using System.Diagnostics;
+using QuestPDF.Drawing.DrawingCanvases;
+using QuestPDF.Drawing.Proxy;
+using QuestPDF.Elements;
 
 
 namespace QuestPDF.LayoutTests.TestEngine;
 namespace QuestPDF.LayoutTests.TestEngine;
 
 
@@ -24,7 +27,8 @@ internal class ElementObserver : ContainerElement
             Size = ObserverId == "$document" ? Child.Measure(availableSpace) : availableSpace
             Size = ObserverId == "$document" ? Child.Measure(availableSpace) : availableSpace
         };
         };
         
         
-        DrawingRecorder?.Record(drawingEvent);
+        if (!IsDiscardDrawingCanvas())
+            DrawingRecorder?.Record(drawingEvent);
         
         
         var matrixBeforeDraw = Canvas.GetCurrentMatrix().ToMatrix4x4();
         var matrixBeforeDraw = Canvas.GetCurrentMatrix().ToMatrix4x4();
         base.Draw(availableSpace);
         base.Draw(availableSpace);
@@ -33,6 +37,40 @@ internal class ElementObserver : ContainerElement
         if (matrixAfterDraw != matrixBeforeDraw)
         if (matrixAfterDraw != matrixBeforeDraw)
             throw new InvalidOperationException("Canvas state was not restored after drawing operation.");
             throw new InvalidOperationException("Canvas state was not restored after drawing operation.");
 
 
-        drawingEvent.StateAfterDrawing = (Child as IStateful)?.GetState();
+        drawingEvent.StateAfterDrawing = (GetRealChild() as IStateful)?.GetState();
+    }
+
+    private Element GetRealChild()
+    {
+        var result = Child;
+
+        while (true)
+        {
+            if (result is ElementProxy proxy)
+            {
+                result = proxy.Child;
+                continue;
+            }
+
+            if (result is DebugPointer debugPointer)
+            {
+                result = debugPointer.Child;
+                continue;
+            }
+            
+            break;
+        }
+        
+        return result;
+    }
+
+    private bool IsDiscardDrawingCanvas()
+    {
+        var canvasUnderTest = Canvas;
+
+        while (canvasUnderTest is ProxyDrawingCanvas proxy)
+            canvasUnderTest = proxy.Target;
+
+        return canvasUnderTest is DiscardDrawingCanvas;
     }
     }
 }
 }

+ 13 - 2
Source/QuestPDF.LayoutTests/TestEngine/ElementObserverSetter.cs

@@ -1,3 +1,4 @@
+using QuestPDF.Drawing;
 using QuestPDF.Helpers;
 using QuestPDF.Helpers;
 
 
 namespace QuestPDF.LayoutTests.TestEngine;
 namespace QuestPDF.LayoutTests.TestEngine;
@@ -6,14 +7,24 @@ internal class ElementObserverSetter : ContainerElement
 {
 {
     public required DrawingRecorder Recorder { get; init; }
     public required DrawingRecorder Recorder { get; init; }
     
     
+    internal override SpacePlan Measure(Size availableSpace)
+    {
+        SetRecorderOnChildren();
+        return base.Measure(availableSpace);
+    }
+    
     internal override void Draw(Size availableSpace)
     internal override void Draw(Size availableSpace)
+    {
+        SetRecorderOnChildren();
+        base.Draw(availableSpace);
+    }
+    
+    private void SetRecorderOnChildren()
     {
     {
         this.VisitChildren(x =>
         this.VisitChildren(x =>
         {
         {
             if (x is ElementObserver observer)
             if (x is ElementObserver observer)
                 observer.DrawingRecorder = Recorder;
                 observer.DrawingRecorder = Recorder;
         });
         });
-        
-        base.Draw(availableSpace);
     }
     }
 }
 }

+ 1 - 1
Source/QuestPDF.LayoutTests/TestEngine/LayoutTest.cs

@@ -182,7 +182,7 @@ internal class LayoutTest
                     page.Content().Element(Content);
                     page.Content().Element(Content);
                 });
                 });
             })
             })
-            .GenerateAndDiscard();
+            .Generate(new LayoutTestDocumentCanvas());
     }
     }
 
 
     public LayoutTest VisualizeOutput()
     public LayoutTest VisualizeOutput()

+ 38 - 0
Source/QuestPDF.LayoutTests/TestEngine/LayoutTestDocumentCanvas.cs

@@ -0,0 +1,38 @@
+using QuestPDF.Drawing;
+
+namespace QuestPDF.LayoutTests.TestEngine;
+
+internal sealed class LayoutTestDocumentCanvas : IDocumentCanvas
+{
+    private LayoutTestDrawingCanvas DrawingCanvas { get; } = new();
+        
+    public void SetSemanticTree(SemanticTreeNode? semanticTree)
+    {
+            
+    }
+    
+    public void BeginDocument()
+    {
+            
+    }
+
+    public void EndDocument()
+    {
+            
+    }
+
+    public void BeginPage(Size size)
+    {
+            
+    }
+
+    public void EndPage()
+    {
+            
+    }
+
+    public IDrawingCanvas GetDrawingCanvas()
+    {
+        return DrawingCanvas;
+    }
+}

+ 153 - 0
Source/QuestPDF.LayoutTests/TestEngine/LayoutTestDrawingCanvas.cs

@@ -0,0 +1,153 @@
+using System.Numerics;
+using QuestPDF.Drawing;
+using QuestPDF.Skia;
+using QuestPDF.Skia.Text;
+
+namespace QuestPDF.LayoutTests.TestEngine;
+
+internal sealed class LayoutTestDrawingCanvas : IDrawingCanvas
+{
+    private Stack<Matrix4x4> MatrixStack { get; } = new();
+    private Matrix4x4 CurrentMatrix { get; set; } = Matrix4x4.Identity;
+    private int CurrentZIndex { get; set; } = 0;
+    
+    public DocumentPageSnapshot GetSnapshot()
+    {
+        return new DocumentPageSnapshot();
+    }
+
+    public void DrawSnapshot(DocumentPageSnapshot snapshot)
+    {
+        
+    }
+
+    public void Save()
+    {
+        MatrixStack.Push(CurrentMatrix);
+    }
+
+    public void Restore()
+    {
+        CurrentMatrix = MatrixStack.Pop();
+    }
+
+    public void SetZIndex(int index)
+    {
+        CurrentZIndex = index;
+    }
+
+    public int GetZIndex()
+    {
+        return CurrentZIndex;
+    }
+    
+    public SkCanvasMatrix GetCurrentMatrix()
+    {
+        return SkCanvasMatrix.FromMatrix4x4(CurrentMatrix);
+    }
+
+    public void SetMatrix(SkCanvasMatrix matrix)
+    {
+        CurrentMatrix = matrix.ToMatrix4x4();
+    }
+
+    public void Translate(Position vector)
+    {
+        CurrentMatrix = Matrix4x4.CreateTranslation(vector.X, vector.Y, 0) * CurrentMatrix;
+    }
+    
+    public void Scale(float scaleX, float scaleY)
+    {
+        CurrentMatrix = Matrix4x4.CreateScale(scaleX, scaleY, 1) * CurrentMatrix;
+    }
+    
+    public void Rotate(float angle)
+    {
+        CurrentMatrix = Matrix4x4.CreateRotationZ((float)Math.PI * angle / 180f) * CurrentMatrix;
+    }
+
+    public void DrawLine(Position start, Position end, SkPaint paint)
+    {
+        
+    }
+
+    public void DrawRectangle(Position vector, Size size, SkPaint paint)
+    {
+        
+    }
+
+    public void DrawComplexBorder(SkRoundedRect innerRect, SkRoundedRect outerRect, SkPaint paint)
+    {
+        
+    }
+    
+    public void DrawShadow(SkRoundedRect shadowRect, SkBoxShadow shadow)
+    {
+        
+    }
+    
+    public void DrawParagraph(SkParagraph paragraph, int lineFrom, int lineTo)
+    {
+        
+    }
+
+    public void DrawImage(SkImage image, Size size)
+    {
+        
+    }
+
+    public void DrawPicture(SkPicture picture)
+    {
+        
+    }
+
+    public void DrawSvgPath(string path, Color color)
+    {
+        
+    }
+
+    public void DrawSvg(SkSvgImage svgImage, Size size)
+    {
+        
+    }
+
+    public void DrawOverflowArea(SkRect area)
+    {
+        
+    }
+
+    public void ClipOverflowArea(SkRect availableSpace, SkRect requiredSpace)
+    {
+        
+    }
+
+    public void ClipRectangle(SkRect clipArea)
+    {
+        
+    }
+    
+    public void ClipRoundedRectangle(SkRoundedRect clipArea)
+    {
+        
+    }
+    
+    public void DrawHyperlink(Size size, string url, string? description)
+    {
+       
+    }
+
+    public void DrawSectionLink(Size size, string sectionName, string? description)
+    {
+        
+    }
+
+    public void DrawSection(string sectionName)
+    {
+        
+    }
+    
+    public void SetSemanticNodeId(int nodeId)
+    {
+        
+    }
+}

+ 1 - 1
Source/QuestPDF/Drawing/DocumentCanvases/FreeDocumentCanvas.cs

@@ -5,7 +5,7 @@ namespace QuestPDF.Drawing.DocumentCanvases;
 
 
 internal sealed class FreeDocumentCanvas : IDocumentCanvas
 internal sealed class FreeDocumentCanvas : IDocumentCanvas
 {
 {
-    private FreeDrawingCanvas DrawingCanvas { get; } = new();
+    private DiscardDrawingCanvas DrawingCanvas { get; } = new();
         
         
     public void SetSemanticTree(SemanticTreeNode? semanticTree)
     public void SetSemanticTree(SemanticTreeNode? semanticTree)
     {
     {

+ 0 - 5
Source/QuestPDF/Drawing/DocumentGenerator.cs

@@ -66,11 +66,6 @@ namespace QuestPDF.Drawing
             return canvas.Images;
             return canvas.Images;
         }
         }
         
         
-        internal static void GenerateAndDiscard(IDocument document)
-        {
-            RenderDocument(new FreeDocumentCanvas(), document, DocumentSettings.Default);
-        }
-
         internal static void ValidateLicense()
         internal static void ValidateLicense()
         {
         {
             if (Settings.License.HasValue)
             if (Settings.License.HasValue)

+ 1 - 1
Source/QuestPDF/Drawing/DrawingCanvases/FreeDrawingCanvas.cs → Source/QuestPDF/Drawing/DrawingCanvases/DiscardDrawingCanvas.cs

@@ -7,7 +7,7 @@ using QuestPDF.Skia.Text;
 
 
 namespace QuestPDF.Drawing.DrawingCanvases
 namespace QuestPDF.Drawing.DrawingCanvases
 {
 {
-    internal sealed class FreeDrawingCanvas : IDrawingCanvas
+    internal sealed class DiscardDrawingCanvas : IDrawingCanvas
     {
     {
         private Stack<Matrix4x4> MatrixStack { get; } = new();
         private Stack<Matrix4x4> MatrixStack { get; } = new();
         private Matrix4x4 CurrentMatrix { get; set; } = Matrix4x4.Identity;
         private Matrix4x4 CurrentMatrix { get; set; } = Matrix4x4.Identity;

+ 1 - 1
Source/QuestPDF/Drawing/Proxy/LayoutProxy.cs

@@ -25,7 +25,7 @@ internal sealed class LayoutProxy : ElementProxy
         
         
         base.Draw(availableSpace);
         base.Draw(availableSpace);
 
 
-        if (Canvas is FreeDrawingCanvas)
+        if (Canvas is DiscardDrawingCanvas)
             return;
             return;
         
         
         var matrix = Canvas.GetCurrentMatrix();
         var matrix = Canvas.GetCurrentMatrix();

+ 1 - 1
Source/QuestPDF/Drawing/Proxy/SnapshotCacheRecorderProxy.cs

@@ -51,7 +51,7 @@ internal sealed class SnapshotCacheRecorderProxy : ElementProxy, IDisposable
         if (MeasureCache.TryGetValue(cacheItem, out var measurement))
         if (MeasureCache.TryGetValue(cacheItem, out var measurement))
             return measurement;
             return measurement;
 
 
-        RecorderCanvas.Target = new FreeDrawingCanvas();
+        RecorderCanvas.Target = new DiscardDrawingCanvas();
         var result = base.Measure(availableSpace);
         var result = base.Measure(availableSpace);
         RecorderCanvas.Target = null;
         RecorderCanvas.Target = null;
         
         

+ 25 - 2
Source/QuestPDF/Elements/Dynamic.cs

@@ -31,6 +31,9 @@ namespace QuestPDF.Elements
         {
         {
             if (IsRendered)
             if (IsRendered)
                 return SpacePlan.Empty();
                 return SpacePlan.Empty();
+            
+            if (availableSpace.IsCloseToZero())
+                return SpacePlan.PartialRender(Size.Zero);
 
 
             var context = CreateContext(availableSpace);
             var context = CreateContext(availableSpace);
             var result = ComposeContent(context, acceptNewState: false);
             var result = ComposeContent(context, acceptNewState: false);
@@ -96,6 +99,12 @@ namespace QuestPDF.Elements
         }
         }
         
         
         #region IStateful
         #region IStateful
+
+        public struct DynamicState
+        {
+            public bool IsRendered;
+            public object ChildState;
+        }
         
         
         private bool IsRendered { get; set; }
         private bool IsRendered { get; set; }
     
     
@@ -105,8 +114,22 @@ namespace QuestPDF.Elements
             Child.SetState(InitialComponentState);
             Child.SetState(InitialComponentState);
         }
         }
 
 
-        public object GetState() => IsRendered;
-        public void SetState(object state) => IsRendered = (bool) state;
+        public object GetState()
+        {
+            return new DynamicState  
+            {
+                IsRendered = IsRendered,
+                ChildState = Child.GetState()
+            };
+        }
+
+        public void SetState(object state)
+        {
+            var dynamicState = (DynamicState) state;
+            
+            IsRendered = dynamicState.IsRendered;
+            Child.SetState(dynamicState.ChildState);
+        }
     
     
         #endregion
         #endregion
     }
     }

+ 1 - 1
Source/QuestPDF/Elements/MultiColumn.cs

@@ -106,7 +106,7 @@ internal sealed class MultiColumn : Element, IContentDirectionAware, IDisposable
         if (Content.Canvas != ChildrenCanvas)
         if (Content.Canvas != ChildrenCanvas)
             Content.InjectDependencies(PageContext, ChildrenCanvas);
             Content.InjectDependencies(PageContext, ChildrenCanvas);
         
         
-        ChildrenCanvas.Target = new FreeDrawingCanvas();
+        ChildrenCanvas.Target = new DiscardDrawingCanvas();
         
         
         return FindPerfectSpace();
         return FindPerfectSpace();
 
 

+ 1 - 1
Source/QuestPDF/Elements/StyledBox.cs

@@ -76,7 +76,7 @@ namespace QuestPDF.Elements
             AdjustBorderAlignment();
             AdjustBorderAlignment();
 
 
             // optimization: do not perform expensive calls
             // optimization: do not perform expensive calls
-            if (Canvas is FreeDrawingCanvas)
+            if (Canvas is DiscardDrawingCanvas)
             {
             {
                 base.Draw(availableSpace);
                 base.Draw(availableSpace);
                 return;
                 return;

+ 2 - 2
Source/QuestPDF/Fluent/GenerateExtensions.cs

@@ -17,9 +17,9 @@ namespace QuestPDF.Fluent
             ClearGenerateAndShowFiles();
             ClearGenerateAndShowFiles();
         }
         }
         
         
-        internal static void GenerateAndDiscard(this IDocument document)
+        internal static void Generate(this IDocument document, IDocumentCanvas documentCanvas)
         {
         {
-            DocumentGenerator.GenerateAndDiscard(document);
+            DocumentGenerator.RenderDocument(documentCanvas, document, DocumentSettings.Default);
         }
         }
         
         
         #region Genearate And Show Configuration
         #region Genearate And Show Configuration