Ver código fonte

Feat multicolumn layout (#945)

* Feature: multi-column layout

* MultiColumn: Added support for spacer decoration

* MultiColumn: right-to-left mode support

* MultiColumn: enhanced height accuracy measurement

* MultiColumn: fixed BalanceHeight behavior in certain layout scenarios

* MultiColumn: added more examples

* MultiColumn: renamed Decoration to Spacer

* Optimization: TextBlock within a repeating content (e.g. page footer/header, decoration before/after) better utilizes paragraph cache

* MultiColumn optimization: when rendering text, keep its internal cache to avoid performance issues
Marcin Ziąbek 1 ano atrás
pai
commit
9ec076cc58

+ 206 - 0
Source/QuestPDF.Examples/MultiColumnExamples.cs

@@ -0,0 +1,206 @@
+using System;
+using System.Linq;
+using NUnit.Framework;
+using QuestPDF.Examples.Engine;
+using QuestPDF.Fluent;
+using QuestPDF.Helpers;
+using QuestPDF.Infrastructure;
+
+namespace QuestPDF.Examples;
+
+public class MultiColumnExamples
+{
+    [Test]
+    public void TypicalCase()
+    {
+        RenderingTest
+            .Create()
+            .PageSize(PageSizes.A4)
+            .ProducePdf()
+            .ShowResults()
+            .Render(container =>
+            {
+                container
+                    .Padding(25)
+                    .DefaultTextStyle(x => x.FontSize(8))
+                    .MultiColumn(multiColumn =>
+                    {
+                        multiColumn.Columns(3);
+                        
+                        multiColumn
+                            .Content()
+                            .Column(column =>
+                            {
+                                column.Spacing(10);
+
+                                foreach (var sectionId in Enumerable.Range(0, 10))
+                                {
+                                    foreach (var textId in Enumerable.Range(0, Random.Shared.Next(5, 10)))
+                                        column.Item().Text(Placeholders.Paragraph());
+
+                                    foreach (var blockId in Enumerable.Range(0, Random.Shared.Next(5, 10)))
+                                        column.Item().Width(50 + blockId * 5).Height(50).Background(Placeholders.BackgroundColor());
+                                }
+                            });
+                    });
+            });
+    }
+    
+    [Test]
+    public void Decoration()
+    {
+        RenderingTest
+            .Create()
+            .PageSize(PageSizes.A4)
+            .ProducePdf()
+            .ShowResults()
+            .Render(container =>
+            {
+                container
+                    .Padding(25)
+                    .DefaultTextStyle(x => x.FontSize(8))
+                    .MultiColumn(multiColumn =>
+                    {
+                        multiColumn.Columns(3);
+                        multiColumn.Spacing(25);
+                        multiColumn.BalanceHeight();
+                        
+                        multiColumn.Spacer().AlignCenter().LineVertical(2).LineColor(Colors.Grey.Medium);
+                        
+                        multiColumn
+                            .Content()
+                            .Column(column =>
+                            {
+                                column.Spacing(10);
+
+                                foreach (var blockId in Enumerable.Range(0, 100))
+                                    column.Item().Height(50).Background(Placeholders.BackgroundColor());
+                            });
+                    });
+            });
+    }
+    
+    [Test]
+    public void Table()
+    {
+        Settings.EnableCaching = true;
+        
+        RenderingTest
+            .Create()
+            .PageSize(PageSizes.A4)
+            .ProducePdf()
+            .ShowResults()
+            .Render(container =>
+            {
+                container
+                    .Padding(25)
+                    .DefaultTextStyle(x => x.FontSize(8))
+                    .MultiColumn(multiColumn =>
+                    {
+                        multiColumn.Spacing(10);
+                        multiColumn.BalanceHeight(false);
+                        
+                        multiColumn
+                            .Content()
+                            .Border(1)
+                            .Table(table =>
+                            {
+                                table.ColumnsDefinition(columns =>
+                                {
+                                    columns.RelativeColumn(1);
+                                    columns.RelativeColumn(2);
+                                    columns.RelativeColumn(3);
+                                });
+
+                                table.Header(header =>
+                                {
+                                    header.Cell().Element(Style).Text("#").Bold();
+                                    header.Cell().Element(Style).Text("Label").Bold();
+                                    header.Cell().Element(Style).Text("Description").Bold();
+                            
+                                    IContainer Style(IContainer container) => container.Border(1).BorderColor(Colors.Grey.Medium).Background(Colors.Grey.Lighten2).Padding(2);
+                                });
+                        
+                                foreach (var i in Enumerable.Range(1, 1_000))
+                                {
+                                    table.Cell().Element(Style).ShowEntire().Text(i.ToString());
+                                    table.Cell().Element(Style).ShowEntire().Text(Placeholders.Label());
+                                    table.Cell().Element(Style).ShowEntire().Text(Placeholders.Sentence());
+                            
+                                    IContainer Style(IContainer container) => container.Border(1).BorderColor(Colors.Grey.Medium).Background(i % 2 == 0 ? Colors.White : Colors.Grey.Lighten4).Padding(2);
+                                }
+                            });
+                    });
+            });
+    }
+    
+    [Test]
+    public void BalanceHeight()
+    {
+        RenderingTest
+            .Create()
+            .PageSize(PageSizes.A4)
+            .ProducePdf()
+            .ShowResults()
+            .Render(container =>
+            {
+                container
+                    .Padding(25)
+                    .DefaultTextStyle(x => x.FontSize(8))
+                    .MultiColumn(multiColumn =>
+                    {
+                        multiColumn.Columns(4);
+                        multiColumn.BalanceHeight(true);
+                        multiColumn.Spacing(10);
+                        
+                        multiColumn
+                            .Content()
+                            .Column(column =>
+                            {
+                                column.Spacing(10);
+
+                                foreach (var sectionId in Enumerable.Range(0, 20))
+                                {
+                                    column.Item().Text(Placeholders.Paragraph());
+                                }
+                            });
+                    });
+            });
+    }
+    
+    [Test]
+    public void RightToLeft()
+    {
+        RenderingTest
+            .Create()
+            .PageSize(PageSizes.A4)
+            .ProducePdf()
+            .ShowResults()
+            .Render(container =>
+            {
+                container
+                    .Padding(25)
+                    .DefaultTextStyle(x => x.FontSize(8))
+                    .ShrinkVertical()
+                    .ContentFromRightToLeft()
+                    .MultiColumn(multiColumn =>
+                    {
+                        multiColumn.Columns(4);
+                        multiColumn.BalanceHeight(true);
+                        multiColumn.Spacing(10);
+                        
+                        multiColumn
+                            .Content()
+                            .Column(column =>
+                            {
+                                column.Spacing(10);
+
+                                foreach (var i in Enumerable.Range(0, 100))
+                                {
+                                    column.Item().Height(50).Background(Placeholders.BackgroundColor()).Text(i.ToString());
+                                }
+                            });
+                    });
+            });
+    }
+}

+ 100 - 0
Source/QuestPDF/Drawing/ProxyCanvas.cs

@@ -0,0 +1,100 @@
+using QuestPDF.Infrastructure;
+using QuestPDF.Skia;
+using QuestPDF.Skia.Text;
+
+namespace QuestPDF.Drawing;
+
+internal class ProxyCanvas : ICanvas
+{
+    public ICanvas Target { get; set; }
+
+    public void Save()
+    {
+        Target.Save();
+    }
+
+    public void Restore()
+    {
+        Target.Restore();
+    }
+
+    public void Translate(Position vector)
+    {
+        Target.Translate(vector);
+    }
+
+    public void DrawFilledRectangle(Position vector, Size size, Color color)
+    {
+        Target.DrawFilledRectangle(vector, size, color);
+    }
+
+    public void DrawStrokeRectangle(Position vector, Size size, float strokeWidth, Color color)
+    {
+        Target.DrawStrokeRectangle(vector, size, strokeWidth, color);
+    }
+
+    public void DrawParagraph(SkParagraph paragraph)
+    {
+        Target.DrawParagraph(paragraph);
+    }
+
+    public void DrawImage(SkImage image, Size size)
+    {
+        Target.DrawImage(image, size);
+    }
+
+    public void DrawPicture(SkPicture picture)
+    {
+        Target.DrawPicture(picture);
+    }
+
+    public void DrawSvgPath(string path, Color color)
+    {
+        Target.DrawSvgPath(path, color);
+    }
+
+    public void DrawSvg(SkSvgImage svgImage, Size size)
+    {
+        Target.DrawSvg(svgImage, size);
+    }
+
+    public void DrawOverflowArea(SkRect area)
+    {
+        Target.DrawOverflowArea(area);
+    }
+
+    public void ClipOverflowArea(SkRect availableSpace, SkRect requiredSpace)
+    {
+        Target.ClipOverflowArea(availableSpace, requiredSpace);
+    }
+
+    public void ClipRectangle(SkRect clipArea)
+    {
+        Target.ClipRectangle(clipArea);
+    }
+
+    public void DrawHyperlink(string url, Size size)
+    {
+        Target.DrawHyperlink(url, size);
+    }
+
+    public void DrawSectionLink(string sectionName, Size size)
+    {
+        Target.DrawSectionLink(sectionName, size);
+    }
+
+    public void DrawSection(string sectionName)
+    {
+        Target.DrawSection(sectionName);
+    }
+
+    public void Rotate(float angle)
+    {
+        Target.Rotate(angle);
+    }
+
+    public void Scale(float scaleX, float scaleY)
+    {
+        Target.Scale(scaleX, scaleY);
+    }
+}

+ 239 - 0
Source/QuestPDF/Elements/MultiColumn.cs

@@ -0,0 +1,239 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+using QuestPDF.Drawing;
+using QuestPDF.Drawing.Proxy;
+using QuestPDF.Elements.Text;
+using QuestPDF.Helpers;
+using QuestPDF.Infrastructure;
+
+namespace QuestPDF.Elements;
+
+internal class MultiColumnChildDrawingObserver : ContainerElement
+{
+    public bool HasBeenDrawn => ChildStateBeforeDrawingOperation != null;
+    public object? ChildStateBeforeDrawingOperation { get; private set; }
+
+    internal override void Draw(Size availableSpace)
+    {
+        ChildStateBeforeDrawingOperation ??= (Child as IStateful).GetState();
+        
+        Child.Draw(availableSpace);
+    }
+    
+    internal void ResetDrawingState()
+    {
+        ChildStateBeforeDrawingOperation = null;
+    }
+
+    internal void RestoreState()
+    {
+        (Child as IStateful)?.SetState(ChildStateBeforeDrawingOperation);
+    }
+}
+
+internal class MultiColumn : Element, IContentDirectionAware
+{
+    // items
+    internal Element Content { get; set; } = Empty.Instance;
+    internal Element Spacer { get; set; } = Empty.Instance;
+    
+    // configuration
+    public int ColumnCount { get; set; } = 2;
+    public bool BalanceHeight { get; set; } = false;
+    public float Spacing { get; set; }
+    
+    public ContentDirection ContentDirection { get; set; }
+
+    // cache
+    private ProxyCanvas ChildrenCanvas { get; } = new();
+    private TreeNode<MultiColumnChildDrawingObserver>[] State { get; set; }
+
+    internal override void CreateProxy(Func<Element?, Element?> create)
+    {
+        Content = create(Content);
+        Spacer = create(Spacer);
+    }
+    
+    internal override IEnumerable<Element?> GetChildren()
+    {
+        yield return Content;
+        yield return Spacer;
+    }
+    
+    private void BuildState()
+    {
+        if (State != null)
+            return;
+        
+        this.VisitChildren(child =>
+        {
+            child.CreateProxy(x => x is IStateful ? new MultiColumnChildDrawingObserver { Child = x } : x);
+        });
+        
+        State = this.ExtractElementsOfType<MultiColumnChildDrawingObserver>().ToArray();
+    }
+
+    internal override SpacePlan Measure(Size availableSpace)
+    {
+        BuildState();
+        OptimizeTextCacheBehavior();
+        
+        if (Content.Canvas != ChildrenCanvas)
+            Content.InjectDependencies(PageContext, ChildrenCanvas);
+        
+        ChildrenCanvas.Target = new FreeCanvas();
+        
+        return FindPerfectSpace();
+
+        IEnumerable<SpacePlan> MeasureColumns(Size availableSpace)
+        {
+            var columnAvailableSpace = GetAvailableSpaceForColumn(availableSpace);
+            
+            foreach (var _ in Enumerable.Range(0, ColumnCount))
+            {
+                yield return Content.Measure(columnAvailableSpace);
+                Content.Draw(columnAvailableSpace);
+            }
+            
+            ResetObserverState(restoreChildState: true);
+        }
+        
+        SpacePlan FindPerfectSpace()
+        {
+            var defaultMeasurement = MeasureColumns(availableSpace);
+
+            if (defaultMeasurement.First().Type is SpacePlanType.Wrap or SpacePlanType.Empty)
+                return defaultMeasurement.First();
+            
+            var maxHeight = defaultMeasurement.Max(x => x.Height);
+            
+            if (defaultMeasurement.Last().Type is SpacePlanType.PartialRender)
+                return SpacePlan.PartialRender(availableSpace.Width, maxHeight);
+            
+            if (!BalanceHeight)
+                return SpacePlan.FullRender(availableSpace.Width, maxHeight);
+
+            var minHeight = 0f;
+            maxHeight = availableSpace.Height;
+            
+            foreach (var _ in Enumerable.Range(0, 8))
+            {
+                var middleHeight = (minHeight + maxHeight) / 2;
+                var middleMeasurement = MeasureColumns(new Size(availableSpace.Width, middleHeight));
+                
+                if (middleMeasurement.Last().Type is SpacePlanType.Empty or SpacePlanType.FullRender)
+                    maxHeight = middleHeight;
+                
+                else
+                    minHeight = middleHeight;
+            }
+            
+            return SpacePlan.FullRender(new Size(availableSpace.Width, maxHeight));
+        }
+    }
+
+    Size GetAvailableSpaceForColumn(Size totalSpace)
+    {
+        var columnWidth = (totalSpace.Width - Spacing * (ColumnCount - 1)) / ColumnCount;
+        return new Size(columnWidth, totalSpace.Height);
+    }
+    
+    internal override void Draw(Size availableSpace)
+    {
+        var contentAvailableSpace = GetAvailableSpaceForColumn(availableSpace);
+        var spacerAvailableSpace = new Size(Spacing, availableSpace.Height);
+
+        var horizontalOffset = 0f;
+        ChildrenCanvas.Target = Canvas;
+
+        foreach (var i in Enumerable.Range(1, ColumnCount))
+        {
+            var contentMeasurement = Content.Measure(contentAvailableSpace);
+            var targetColumnSize = new Size(contentAvailableSpace.Width, contentMeasurement.Height);
+
+            var contentOffset = GetTargetOffset(targetColumnSize.Width);
+            
+            Canvas.Translate(contentOffset);
+            Content.Draw(targetColumnSize);
+            Canvas.Translate(contentOffset.Reverse());
+            
+            horizontalOffset += contentAvailableSpace.Width;
+            
+            if (contentMeasurement.Type is SpacePlanType.Empty or SpacePlanType.FullRender)
+                break;
+            
+            var spacerMeasurement = Spacer.Measure(spacerAvailableSpace);
+
+            if (i == ColumnCount || spacerMeasurement.Type is SpacePlanType.Wrap) 
+                continue;
+            
+            var spacerOffset = GetTargetOffset(Spacing);
+            
+            Canvas.Translate(spacerOffset);
+            Spacer.Draw(spacerAvailableSpace);
+            Canvas.Translate(spacerOffset.Reverse());
+                
+            horizontalOffset += Spacing;
+        }
+        
+        ResetObserverState(restoreChildState: false);
+
+        Position GetTargetOffset(float contentWidth)
+        {
+            return ContentDirection == ContentDirection.LeftToRight
+                ? new Position(horizontalOffset, 0)
+                : new Position(availableSpace.Width - horizontalOffset - contentWidth, 0);
+        }
+    }
+    
+    void ResetObserverState(bool restoreChildState)
+    {
+        foreach (var node in State)
+            Traverse(node);
+            
+        void Traverse(TreeNode<MultiColumnChildDrawingObserver> node)
+        {
+            var observer = node.Value;
+
+            if (!observer.HasBeenDrawn)
+                return;
+
+            if (restoreChildState)
+                observer.RestoreState();
+            
+            observer.ResetDrawingState();
+                
+            foreach (var child in node.Children)
+                Traverse(child);
+        }
+    }
+    
+    #region Text Optimization
+
+    private bool IsTextOptimizationExecuted { get; set; } = false;
+    
+    /// <summary>
+    /// The TextBlock element uses SkParagraph cache to enhance rendering speed.
+    /// This cache uses a significant amount of memory and is cleared after FullRender.
+    /// However, the MultiColumn element uses a sophisticated measuring algorithm,
+    /// and may force the Text element to measure/render multiple times per page.
+    /// To avoid performance issues, the TextBlock element should keep its cache.
+    /// </summary>
+    private void OptimizeTextCacheBehavior()
+    {
+        if (IsTextOptimizationExecuted)
+            return;
+        
+        IsTextOptimizationExecuted = true;
+        
+        Content.VisitChildren(x =>
+        {
+            if (x is TextBlock text)
+                text.ClearInternalCacheAfterFullRender = false;
+        });
+    }
+    
+    #endregion
+}

+ 62 - 0
Source/QuestPDF/Fluent/MultiColumnExtensions.cs

@@ -0,0 +1,62 @@
+using System;
+using QuestPDF.Drawing.Exceptions;
+using QuestPDF.Elements;
+using QuestPDF.Infrastructure;
+
+namespace QuestPDF.Fluent;
+
+public class MultiColumnDescriptor
+{
+    internal MultiColumn MultiColumn { get; } = new MultiColumn();
+        
+    public void Spacing(float value, Unit unit = Unit.Point)
+    {
+        MultiColumn.Spacing = value.ToPoints(unit);
+    }
+    
+    public void Columns(int value = 2)
+    {
+        MultiColumn.ColumnCount = value;
+    }
+        
+    public void BalanceHeight(bool enable = true)
+    {
+        MultiColumn.BalanceHeight = enable;
+    }
+
+    public IContainer Content()
+    {
+        if (MultiColumn.Content is not Empty)
+            throw new DocumentComposeException("The 'MultiColumn.Content' layer has already been defined. Please call this method only once.");
+        
+        var container = new Container();
+        MultiColumn.Content = container;
+        return container;
+    }
+    
+    public IContainer Spacer()
+    {
+        if (MultiColumn.Spacer is not Empty)
+            throw new DocumentComposeException("The 'MultiColumn.Spacer' layer has already been defined. Please call this method only once.");
+        
+        var container = new RepeatContent();
+        MultiColumn.Spacer = container;
+        return container;
+    }
+}
+
+public static class MultiColumnExtensions
+{
+    /// <summary>
+    /// Creates a multi-column layout within the current container element.
+    /// </summary>
+    public static void MultiColumn(this IContainer element, Action<MultiColumnDescriptor> handler)
+    {
+        var descriptor = new MultiColumnDescriptor();
+        handler(descriptor);
+
+        element
+            .Element(x => descriptor.MultiColumn.BalanceHeight ? x.ShrinkVertical() : x)
+            .Element(descriptor.MultiColumn);
+    }
+}