From b8ea30be2300eeac1f6986ae2df6bd8cd44fdd34 Mon Sep 17 00:00:00 2001 From: MarcinZiabek Date: Thu, 30 Dec 2021 20:29:32 +0100 Subject: [PATCH] Improved readibility of the table layout algorithm --- QuestPDF.Examples/TableExamples.cs | 107 +-------- QuestPDF.Examples/TablePerformanceTest.cs | 113 --------- QuestPDF/Elements/Table/DynamicDictionary.cs | 33 +++ QuestPDF/Elements/Table/Table.cs | 226 ++++++++---------- .../Elements/Table/TableColumnDefinition.cs | 2 +- 5 files changed, 141 insertions(+), 340 deletions(-) delete mode 100644 QuestPDF.Examples/TablePerformanceTest.cs create mode 100644 QuestPDF/Elements/Table/DynamicDictionary.cs diff --git a/QuestPDF.Examples/TableExamples.cs b/QuestPDF.Examples/TableExamples.cs index 3d23a9e..a2445d5 100644 --- a/QuestPDF.Examples/TableExamples.cs +++ b/QuestPDF.Examples/TableExamples.cs @@ -16,85 +16,6 @@ namespace QuestPDF.Examples { public static Random Random { get; } = new Random(); - [Test] - public void Example() - { - RenderingTest - .Create() - .ProduceImages() - .PageSize(PageSizes.A4) - .ShowResults() - .Render(container => - { - container - .Padding(25) - .Box() - .Border(2) - .Table(table => - { - table.ColumnsDefinition(columns => - { - columns.ConstantColumn(100); - columns.RelativeColumn(); - columns.ConstantColumn(100); - columns.ConstantColumn(200); - }); - - table.Cell().ColumnSpan(2).Element(CreateBox("A")); - table.Cell().Element(CreateBox("B")); - table.Cell().Element(CreateBox("C")); - - table.Cell().Element(CreateBox("D")); - table.Cell().RowSpan(2).Element(CreateBox("E")); - table.Cell().RowSpan(3).ColumnSpan(2).Element(CreateBox("F")); - - table.Cell().RowSpan(2).Element(CreateBox("G")); - table.Cell().RowSpan(2).Element(CreateBox("H")); - table.Cell().Element(CreateBox("I")); - table.Cell().Element(CreateBox("J")); - table.Cell().RowSpan(2).Element(CreateBox("K")); - table.Cell().ColumnSpan(2).Element(CreateBox("L")); - table.Cell().Element(CreateBox("M")); - }); - }); - } - - [Test] - public void TreeTable() - { - RenderingTest - .Create() - .ProducePdf() - .PageSize(PageSizes.A4) - .ShowResults() - .Render(container => - { - container - .Padding(25) - .Box() - .Border(2) - .Table(table => - { - table.ColumnsDefinition(columns => - { - columns.RelativeColumn(100); - columns.RelativeColumn(100); - columns.RelativeColumn(100); - }); - - table.Cell().RowSpan(4).Element(CreateBox("A")); - - table.Cell().RowSpan(2).Element(CreateBox("B")); - table.Cell().Element(CreateBox("C")); - table.Cell().Element(CreateBox("D")); - - table.Cell().RowSpan(2).Element(CreateBox("E")); - table.Cell().Element(CreateBox("F")); - table.Cell().Element(CreateBox("G")); - }); - }); - } - [Test] public void TemperatureReport() { @@ -102,18 +23,7 @@ namespace QuestPDF.Examples .Create() .ProducePdf() .PageSize(PageSizes.A4) - .ShowResults() - .Render(container => GeneratePerformanceStructure(container, 10)); - } - - [Test] - public void TemperatureReport_PerformanceTest() - { - RenderingTest - .Create() - .ProducePdf() - .PageSize(PageSizes.A4) - .MaxPages(10000) + .MaxPages(10_000) .EnableCaching() .EnableDebugging(false) .ShowResults() @@ -184,20 +94,5 @@ namespace QuestPDF.Examples } }); } - - private Action CreateBox(string label) - { - return container => - { - var height = Random.Next(2, 6) * 10; - - container - .Background(Placeholders.BackgroundColor()) - // .AlignCenter() - // .AlignMiddle() - .Height(height); - // .Text($"{label}: {height}px"); - }; - } } } \ No newline at end of file diff --git a/QuestPDF.Examples/TablePerformanceTest.cs b/QuestPDF.Examples/TablePerformanceTest.cs deleted file mode 100644 index e483ee1..0000000 --- a/QuestPDF.Examples/TablePerformanceTest.cs +++ /dev/null @@ -1,113 +0,0 @@ -using System; -using System.Diagnostics; -using System.Linq; -using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Configs; -using BenchmarkDotNet.Engines; -using BenchmarkDotNet.Running; -using NUnit.Framework; -using QuestPDF.Drawing; -using QuestPDF.Drawing.Proxy; -using QuestPDF.Elements; -using QuestPDF.Fluent; -using QuestPDF.Helpers; -using QuestPDF.Infrastructure; - -namespace QuestPDF.Examples -{ - [SimpleJob(RunStrategy.Monitoring, launchCount: 0, warmupCount: 1, targetCount: 16)] - [MinColumn, MaxColumn, MeanColumn, MedianColumn] - public class TablePerformanceTest - { - private static Random Random { get; } = new Random(); - - private PageContext PageContext { get; set; } - private DocumentMetadata Metadata { get; set; } - private Container Content { get; set; } - - [Test] - public void Run() - { - var configuration = ManualConfig - .Create(DefaultConfig.Instance) - .WithOptions(ConfigOptions.DisableOptimizationsValidator); - - BenchmarkRunner.Run(configuration); - } - - [IterationSetup] - [SetUp] - public void GenerateReportData() - { - Metadata = new DocumentMetadata() - { - DocumentLayoutExceptionThreshold = 1000 - }; - - var documentContainer = new DocumentContainer(); - - documentContainer.Page(page => - { - page.Size(PageSizes.A3); - GeneratePerformanceStructure(page.Content(), 10_000); - }); - - Content = documentContainer.Compose(); - - PageContext = new PageContext(); - DocumentGenerator.RenderPass(PageContext, new FreeCanvas(), Content, Metadata, null); - - Content.HandleVisitor(x => - { - if (x is ICacheable) - x.CreateProxy(y => new CacheProxy(y)); - }); - } - - [Benchmark] - [Test] - public void GenerationTest() - { - DocumentGenerator.RenderPass(PageContext, new FreeCanvas(), Content, Metadata, null); - } - - void GeneratePerformanceStructure(IContainer container, int itemsCount) - { - container - .Padding(25) - .Table(table => - { - table.ColumnsDefinition(columns => - { - foreach (var size in Enumerable.Range(0, 10)) - columns.ConstantColumn(100); - }); - - foreach (var i in Enumerable.Range(1, itemsCount)) - { - table - .Cell() - .RowSpan((uint)Random.Next(1, 5)) - .ColumnSpan((uint)Random.Next(1, 5)) - .Element(CreateBox(i.ToString())); - } - }); - } - - private Action CreateBox(string label) - { - return container => - { - var height = Random.Next(2, 6) * 20; - - container - .Border(2) - .Background(Placeholders.BackgroundColor()) - .AlignCenter() - .AlignMiddle() - .Height(height) - .Text($"{label}: {height}px"); - }; - } - } -} \ No newline at end of file diff --git a/QuestPDF/Elements/Table/DynamicDictionary.cs b/QuestPDF/Elements/Table/DynamicDictionary.cs new file mode 100644 index 0000000..d014d21 --- /dev/null +++ b/QuestPDF/Elements/Table/DynamicDictionary.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using System.Linq; + +namespace QuestPDF.Elements.Table +{ + /// + /// This dictionary allows to access key that does not exist. + /// Instead of throwing an exception, it returns a default value. + /// + internal class DynamicDictionary + { + private TValue Default { get; } + private IDictionary Dictionary { get; } = new Dictionary(); + + public DynamicDictionary() + { + + } + + public DynamicDictionary(TValue defaultValue) + { + Default = defaultValue; + } + + public TValue this[TKey key] + { + get => Dictionary.TryGetValue(key, out var value) ? value : Default; + set => Dictionary[key] = value; + } + + public List> Items => Dictionary.ToList(); + } +} \ No newline at end of file diff --git a/QuestPDF/Elements/Table/Table.cs b/QuestPDF/Elements/Table/Table.cs index 3ee1f53..0bc5166 100644 --- a/QuestPDF/Elements/Table/Table.cs +++ b/QuestPDF/Elements/Table/Table.cs @@ -7,41 +7,17 @@ using QuestPDF.Infrastructure; namespace QuestPDF.Elements.Table { - /// - /// This dictionary allows to access key that does not exist. - /// Instead of throwing an exception, it returns a default value. - /// - internal class DynamicDictionary - { - private TValue Default { get; } - private IDictionary Dictionary { get; } = new Dictionary(); - - public DynamicDictionary() - { - - } - - public DynamicDictionary(TValue defaultValue) - { - Default = defaultValue; - } - - public TValue this[TKey key] - { - get => Dictionary.TryGetValue(key, out var value) ? value : Default; - set => Dictionary[key] = value; - } - - public List> Items => Dictionary.ToList(); - } - internal class Table : Element, IStateResettable { public List Columns { get; } = new List(); public List Children { get; } = new List(); public float Spacing { get; set; } + // cache for efficient cell finding + // index of first array - number of row + // nested array - collection of all cells starting at given row private TableCell[][] OrderedChildren { get; set; } + private int RowsCount { get; set; } private int CurrentRow { get; set; } @@ -135,85 +111,112 @@ namespace QuestPDF.Elements.Table private ICollection PlanLayout(Size availableSpace) { - var cellOffsets = new float[Columns.Count + 1]; - cellOffsets[0] = 0; + var columnOffsets = GetColumnLeftOffsets(Columns); - Enumerable - .Range(1, cellOffsets.Length - 1) - .ToList() - .ForEach(x => cellOffsets[x] = Columns[x - 1].Width + cellOffsets[x - 1]); - - // update row heights - var rowBottomOffsets = new DynamicDictionary(); - var childrenToTry = Enumerable.Range(CurrentRow, RowsCount - CurrentRow + 1).SelectMany(x => OrderedChildren[x]); - - var maxRenderingRow = RowsCount; - var currentRow = CurrentRow; - - var commands = new List(); - - foreach (var cell in childrenToTry) - { - if (cell.Row > currentRow) - { - rowBottomOffsets[currentRow] = Math.Max(rowBottomOffsets[currentRow], rowBottomOffsets[currentRow - 1]); - - if (rowBottomOffsets[currentRow - 1] > availableSpace.Height + Size.Epsilon) - break; - - currentRow = cell.Row; - } - - if (cell.Row > maxRenderingRow) - break; - - if (cell.IsRendered) - continue; - - var topOffset = rowBottomOffsets[cell.Row - 1]; - var availableHeight = availableSpace.Height - topOffset + Size.Epsilon; - - var cellSize = GetCellSize(cell, availableHeight); - - if (cellSize.Type == SpacePlanType.PartialRender) - { - maxRenderingRow = Math.Min(maxRenderingRow, cell.Row + cell.RowSpan - 1); - } - - if (cellSize.Type == SpacePlanType.Wrap) - { - maxRenderingRow = Math.Min(maxRenderingRow, cell.Row - 1); - continue; - } - - var cellBottomOffset = cellSize.Height + topOffset; - - var targetRowId = cell.Row + cell.RowSpan - 1; // -1 because rowSpan starts at 1 - rowBottomOffsets[targetRowId] = Math.Max(rowBottomOffsets[targetRowId], cellBottomOffset); - - var width = GetCellWidth(cell); - - var command = new TableCellRenderingCommand() - { - Cell = cell, - Measurement = cellSize, - Size = new Size(width, cellSize.Height), - Offset = new Position(cellOffsets[cell.Column - 1], topOffset) - }; - - commands.Add(command); - } - + var commands = GetRenderingCommands(); var tableHeight = commands.Max(cell => cell.Offset.Y + cell.Size.Height); - - commands = commands.Where(x => x.Cell.Row <= maxRenderingRow).ToList(); - - - AdjustLastCellSizes(tableHeight, commands); + AdjustCellSizes(tableHeight, commands); + AdjustLastCellSizes(tableHeight, commands); return commands; + static float[] GetColumnLeftOffsets(IList columns) + { + var cellOffsets = new float[columns.Count + 1]; + cellOffsets[0] = 0; + + foreach (var column in Enumerable.Range(1, cellOffsets.Length - 1)) + cellOffsets[column] = columns[column - 1].Width + cellOffsets[column - 1]; + + return cellOffsets; + } + + ICollection GetRenderingCommands() + { + var rowBottomOffsets = new DynamicDictionary(); + + var childrenToTry = Enumerable + .Range(CurrentRow, RowsCount - CurrentRow + 1) + .SelectMany(x => OrderedChildren[x]); + + var currentRow = CurrentRow; + var maxRenderingRow = RowsCount; + + var commands = new List(); + + foreach (var cell in childrenToTry) + { + // update position of previous row + if (cell.Row > currentRow) + { + rowBottomOffsets[currentRow] = Math.Max(rowBottomOffsets[currentRow], rowBottomOffsets[currentRow - 1]); + + if (rowBottomOffsets[currentRow - 1] > availableSpace.Height + Size.Epsilon) + break; + + currentRow = cell.Row; + } + + // cell visibility optimizations + if (cell.Row > maxRenderingRow) + break; + + if (cell.IsRendered) + continue; + + // calculate cell position / size + var topOffset = rowBottomOffsets[cell.Row - 1]; + + var availableWidth = GetCellWidth(cell); + var availableHeight = availableSpace.Height - topOffset + Size.Epsilon; + var availableCellSize = new Size(availableWidth, availableHeight); + + var cellSize = cell.Measure(availableCellSize); + + // corner case: cell within the row is not fully rendered, do not attempt to render next row + if (cellSize.Type == SpacePlanType.PartialRender) + { + maxRenderingRow = Math.Min(maxRenderingRow, cell.Row + cell.RowSpan - 1); + } + + // corner case: cell within the row want to wrap to the next page, do not attempt to render this row + if (cellSize.Type == SpacePlanType.Wrap) + { + maxRenderingRow = Math.Min(maxRenderingRow, cell.Row - 1); + continue; + } + + // update position of the last row that cell occupies + var bottomRow = cell.Row + cell.RowSpan - 1; + rowBottomOffsets[bottomRow] = Math.Max(rowBottomOffsets[bottomRow], topOffset + cellSize.Height); + + // accept cell to be rendered + commands.Add(new TableCellRenderingCommand() + { + Cell = cell, + Measurement = cellSize, + Size = new Size(availableWidth, cellSize.Height), + Offset = new Position(columnOffsets[cell.Column - 1], topOffset) + }); + } + + // corner case: reject cell if other cells within the same rows are rejected + return commands.Where(x => x.Cell.Row <= maxRenderingRow).ToList(); + } + + // if two cells end up on the same row (a.Row + a.RowSpan = b.Row + b.RowSpan), + // bottom edges of their bounding boxes should be at the same level + static void AdjustCellSizes(float tableHeight, ICollection commands) + { + foreach (var command in commands) + { + var height = tableHeight - command.Offset.Y; + command.Size = new Size(command.Size.Width, height); + } + } + + // all cells, that are last ones in their respective columns, should take all remaining space static void AdjustLastCellSizes(float tableHeight, ICollection commands) { var columnsCount = commands.Select(x => x.Cell).Max(x => x.Column + x.ColumnSpan - 1); @@ -231,27 +234,10 @@ namespace QuestPDF.Elements.Table lastCellInColumn.Size = new Size(lastCellInColumn.Size.Width, tableHeight - lastCellInColumn.Offset.Y); } } - - static void AdjustCellSizes(float tableHeight, ICollection commands) - { - foreach (var command in commands) - { - var height = tableHeight - command.Offset.Y; - command.Size = new Size(command.Size.Width, height); - } - } - + float GetCellWidth(TableCell cell) { - return cellOffsets[cell.Column + cell.ColumnSpan - 1] - cellOffsets[cell.Column - 1]; - } - - SpacePlan GetCellSize(TableCell cell, float availableHeight) - { - var width = GetCellWidth(cell); - var cellSize = new Size(width, availableHeight); - - return cell.Measure(cellSize); + return columnOffsets[cell.Column + cell.ColumnSpan - 1] - columnOffsets[cell.Column - 1]; } } } diff --git a/QuestPDF/Elements/Table/TableColumnDefinition.cs b/QuestPDF/Elements/Table/TableColumnDefinition.cs index 516c6ed..744df31 100644 --- a/QuestPDF/Elements/Table/TableColumnDefinition.cs +++ b/QuestPDF/Elements/Table/TableColumnDefinition.cs @@ -2,7 +2,7 @@ namespace QuestPDF.Elements.Table { internal class TableColumnDefinition { - public float ConstantSize { get; } + public float ConstantSize { get; } public float RelativeSize { get; } internal float Width { get; set; }