From 935c575221845898a9abc20e0ef90c56dab6fe4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcin=20Zi=C4=85bek?= Date: Wed, 5 Jan 2022 00:52:28 +0100 Subject: [PATCH] Table: added default style, fixed bugs, added examples --- QuestPDF.Examples/RowExamples.cs | 38 ++ QuestPDF.Examples/TableExamples.cs | 343 +++++++++++++++++- QuestPDF/Elements/Page.cs | 4 +- QuestPDF/Elements/Table/Table.cs | 55 ++- QuestPDF/Elements/Table/TableLayoutPlanner.cs | 4 +- .../Elements/Table/TableLayoutValidator.cs | 33 ++ QuestPDF/Fluent/ElementExtensions.cs | 8 + QuestPDF/Fluent/RowExtensions.cs | 6 +- QuestPDF/Fluent/TableExtensions.cs | 42 ++- 9 files changed, 496 insertions(+), 37 deletions(-) create mode 100644 QuestPDF/Elements/Table/TableLayoutValidator.cs diff --git a/QuestPDF.Examples/RowExamples.cs b/QuestPDF.Examples/RowExamples.cs index ffb6dfc..5792114 100644 --- a/QuestPDF.Examples/RowExamples.cs +++ b/QuestPDF.Examples/RowExamples.cs @@ -8,6 +8,44 @@ namespace QuestPDF.Examples { public class RowExamples { + [Test] + public void ColumnTypes() + { + RenderingTest + .Create() + .ProducePdf() + .PageSize(650, 300) + .ShowResults() + .Render(container => + { + container + .Padding(25) + .Box() + .Border(1) + .Stack(stack => + { + stack.Item().LabelCell("Total width: 600px"); + + stack.Item().Row(row => + { + row.ConstantColumn(150).ValueCell("150px"); + row.ConstantColumn(100).ValueCell("100px"); + row.RelativeColumn(4).ValueCell("200px"); + row.RelativeColumn(3).ValueCell("150px"); + }); + + stack.Item().Row(row => + { + row.ConstantColumn(100).ValueCell("100px"); + row.ConstantColumn(50).ValueCell("50px"); + row.Column(constantWidth: 100, relativeWidth: 4).ValueCell("350px"); + row.RelativeColumn(2).ValueCell("100px"); + row.RelativeColumn(1).ValueCell("50px"); + }); + }); + }); + } + [Test] public void Stability() { diff --git a/QuestPDF.Examples/TableExamples.cs b/QuestPDF.Examples/TableExamples.cs index e622e41..55d9347 100644 --- a/QuestPDF.Examples/TableExamples.cs +++ b/QuestPDF.Examples/TableExamples.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; +using System.Drawing; using System.Linq; using NUnit.Framework; using QuestPDF.Drawing; @@ -14,10 +16,330 @@ namespace QuestPDF.Examples { public class TableExamples { - public static Random Random { get; } = new Random(); + [Test] + public void Simple() + { + RenderingTest + .Create() + .ProduceImages() + .PageSize(320, 100) + .ShowResults() + .Render(container => + { + container + .Padding(10) + .Table(table => + { + table.ColumnsDefinition(columns => + { + columns.ConstantColumn(50); + columns.ConstantColumn(100); + columns.RelativeColumn(2); + columns.RelativeColumn(3); + }); + + table.Cell().ColumnSpan(4).LabelCell("Total width: 300px"); + table.Cell().ValueCell("50px"); + table.Cell().ValueCell("100px"); + table.Cell().ValueCell("100px"); + table.Cell().ValueCell("150px"); + }); + }); + } + + [Test] + public void BasicPlacement() + { + RenderingTest + .Create() + .ProduceImages() + .PageSize(220, 220) + .ShowResults() + .Render(container => + { + container + .Padding(10) + .Box() + .Border(1) + .Table(table => + { + table.ColumnsDefinition(columns => + { + columns.RelativeColumn(); + columns.RelativeColumn(); + columns.RelativeColumn(); + columns.RelativeColumn(); + }); + + table.Cell().Row(1).Column(4).TextBox("A"); + table.Cell().Row(2).Column(2).TextBox("B"); + table.Cell().Row(3).Column(3).TextBox("C"); + table.Cell().Row(4).Column(1).TextBox("D"); + }); + }); + } + + [Test] + public void PartialAutoPlacement() + { + RenderingTest + .Create() + .ProduceImages() + .PageSize(220, 220) + .ShowResults() + .Render(container => + { + container + .Padding(10) + .Box() + .Border(1) + .Table(table => + { + table.ColumnsDefinition(columns => + { + columns.RelativeColumn(); + columns.RelativeColumn(); + columns.RelativeColumn(); + columns.RelativeColumn(); + }); + + table.Cell().TextBox("A"); + table.Cell().Row(2).Column(2).TextBox("B"); + table.Cell().TextBox("C"); + table.Cell().Row(3).Column(3).TextBox("D"); + table.Cell().ColumnSpan(2).TextBox("E"); + }); + }); + } + + [Test] + public void ExtendLastCellsToTableBottom() + { + RenderingTest + .Create() + .ProduceImages() + .PageSize(170, 300) + .ShowResults() + .Render(container => + { + container + .Padding(10) + .Box() + .Border(1) + .Table(table => + { + table.ColumnsDefinition(columns => + { + columns.RelativeColumn(); + columns.RelativeColumn(); + columns.RelativeColumn(); + }); + + table.ExtendLastCellsToTableBottom(); + + table.Cell().Row(1).Column(1).TextBox("A"); + table.Cell().Row(3).Column(1).TextBox("B"); + table.Cell().Row(2).Column(2).TextBox("C"); + table.Cell().Row(3).Column(3).TextBox("D"); + }); + }); + } + + [Test] + public void Overlapping() + { + RenderingTest + .Create() + .ProduceImages() + .PageSize(170, 300) + .ShowResults() + .Render(container => + { + container + .Padding(10) + .Box() + .Border(1) + .Table(table => + { + table.ColumnsDefinition(columns => + { + columns.RelativeColumn(); + columns.RelativeColumn(); + columns.RelativeColumn(); + }); + + table.Cell().RowSpan(2).ColumnSpan(2).Background(Colors.Green.Lighten3); + table.Cell().Background(Colors.Blue.Lighten3).MinHeight(50); + table.Cell().Row(2).Column(2).ColumnSpan(2).Background(Colors.Red.Lighten3).MinHeight(50); + }); + }); + } + + [Test] + public void Spans() + { + RenderingTest + .Create() + .ProduceImages() + .PageSize(220, 220) + .ShowResults() + .Render(container => + { + container + .Padding(10) + .Table(table => + { + table.ColumnsDefinition(columns => + { + columns.RelativeColumn(); + columns.RelativeColumn(); + columns.RelativeColumn(); + columns.RelativeColumn(); + }); + + table.Cell().RowSpan(2).ColumnSpan(2).TextBox("1"); + table.Cell().ColumnSpan(2).TextBox("2"); + table.Cell().TextBox("3"); + table.Cell().TextBox("4"); + table.Cell().RowSpan(2).TextBox("5"); + table.Cell().ColumnSpan(2).TextBox("6"); + table.Cell().RowSpan(2).TextBox("7"); + table.Cell().TextBox("8"); + table.Cell().TextBox("9"); + }); + }); + } [Test] - public void TemperatureReport() + public void Stability() + { + RenderingTest + .Create() + .ProduceImages() + .PageSize(300, 300) + .ShowResults() + .Render(container => + { + container + .Padding(10) + .Table(table => + { + table.ColumnsDefinition(columns => + { + columns.RelativeColumn(); + columns.RelativeColumn(); + columns.RelativeColumn(); + columns.RelativeColumn(); + }); + + table.Cell().RowSpan(4).TextBox("1"); + + table.Cell().RowSpan(2).TextBox("2"); + table.Cell().RowSpan(1).TextBox("3"); + table.Cell().RowSpan(1).TextBox("4"); + + table.Cell().RowSpan(2).TextBox("5"); + table.Cell().RowSpan(1).TextBox("6"); + table.Cell().RowSpan(1).TextBox("7"); + }); + }); + } + + [Test] + public void TableHeader() + { + RenderingTest + .Create() + .ProduceImages() + .PageSize(500, 300) + .ShowResults() + .Render(container => + { + var pageSizes = new List<(string name, float width, float height)>() + { + ("Letter", 8.5f, 11), + ("Legal", 8.5f, 14), + ("Ledger", 11, 17), + ("Tabloid", 17, 11), + }; + + const int inchesToPoints = 72; + + container + .Padding(10) + .Box() + .Border(1) + .Decoration(decoration => + { + decoration + .Header() + .DefaultTextStyle(TextStyle.Default.SemiBold()) + .Table(table => + { + table.ColumnsDefinition(DefineTableColumns); + table.DefaultCellStyle(cell => DefineDefaultCellStyle(cell, Colors.Grey.Lighten3)); + + table.Cell().RowSpan(2).ExtendHorizontal().AlignLeft().Text("Document type"); + + table.Cell().ColumnSpan(2).Text("Inches"); + table.Cell().ColumnSpan(2).Text("Points"); + + table.Cell().Text("Width"); + table.Cell().Text("Height"); + + table.Cell().Text("Width"); + table.Cell().Text("Height"); + }); + + decoration + .Content() + .Table(table => + { + table.ColumnsDefinition(DefineTableColumns); + table.DefaultCellStyle(cell => DefineDefaultCellStyle(cell, Colors.White)); + + foreach (var page in pageSizes) + { + table.Cell().ExtendHorizontal().AlignLeft().Text(page.name); + + // inches + table.Cell().Text(page.width); + table.Cell().Text(page.height); + + // points + table.Cell().Text(page.width * inchesToPoints); + table.Cell().Text(page.height * inchesToPoints); + } + }); + + void DefineTableColumns(TableColumnsDefinitionDescriptor columns) + { + columns.RelativeColumn(); + + columns.ConstantColumn(75); + columns.ConstantColumn(75); + + columns.ConstantColumn(75); + columns.ConstantColumn(75); + } + + IContainer DefineDefaultCellStyle(IContainer container, string backgroundColor) + { + return container + .Border(1) + .BorderColor(Colors.Grey.Darken1) + .Background(backgroundColor) + .PaddingVertical(5) + .PaddingHorizontal(10) + .AlignCenter() + .AlignMiddle(); + } + }); + }); + } + + [Test] + public void PerformanceText_TemperatureReport() { RenderingTest .Create() @@ -27,7 +349,7 @@ namespace QuestPDF.Examples .EnableCaching() .EnableDebugging(false) .ShowResults() - .Render(container => GeneratePerformanceStructure(container, 1000)); + .Render(container => GeneratePerformanceStructure(container, 100)); } public static void GeneratePerformanceStructure(IContainer container, int repeats) @@ -95,4 +417,19 @@ namespace QuestPDF.Examples }); } } + + public static class TableTestsExtensions + { + public static void TextBox(this IContainer container, string text) + { + container + .Border(1) + .Background(Colors.Grey.Lighten3) + .MinWidth(50) + .MinHeight(50) + .AlignCenter() + .AlignMiddle() + .Text(text, TextStyle.Default.Size(16)); + } + } } \ No newline at end of file diff --git a/QuestPDF/Elements/Page.cs b/QuestPDF/Elements/Page.cs index 27e6946..0368409 100644 --- a/QuestPDF/Elements/Page.cs +++ b/QuestPDF/Elements/Page.cs @@ -40,6 +40,8 @@ namespace QuestPDF.Elements .PaddingTop(MarginTop) .PaddingBottom(MarginBottom) + .DefaultTextStyle(DefaultTextStyle) + .Decoration(decoration => { decoration @@ -60,8 +62,6 @@ namespace QuestPDF.Elements .Element(Footer); }); - (container as Element).ApplyDefaultTextStyle(DefaultTextStyle); - bool IsClose(float x, float y) { return Math.Abs(x - y) < Size.Epsilon; diff --git a/QuestPDF/Elements/Table/Table.cs b/QuestPDF/Elements/Table/Table.cs index 26f4f64..7cb4f39 100644 --- a/QuestPDF/Elements/Table/Table.cs +++ b/QuestPDF/Elements/Table/Table.cs @@ -1,8 +1,10 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using QuestPDF.Drawing; +using QuestPDF.Fluent; using QuestPDF.Infrastructure; namespace QuestPDF.Elements.Table @@ -11,13 +13,14 @@ namespace QuestPDF.Elements.Table { public List Columns { get; } = new List(); public List Children { get; } = new List(); - public float Spacing { get; set; } + public bool ExtendLastCellsToTableBottom { 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 StartingRowsCount { get; set; } private int RowsCount { get; set; } private int CurrentRow { get; set; } @@ -29,8 +32,11 @@ namespace QuestPDF.Elements.Table public void ResetState() { + if (StartingRowsCount == default) + StartingRowsCount = Children.Select(x => x.Row).DefaultIfEmpty(0).Max(); + if (RowsCount == default) - RowsCount = Children.Max(x => x.Row + x.RowSpan - 1); + RowsCount = Children.Select(x => x.Row + x.RowSpan - 1).DefaultIfEmpty(0).Max(); if (OrderedChildren == default) { @@ -50,6 +56,9 @@ namespace QuestPDF.Elements.Table internal override SpacePlan Measure(Size availableSpace) { + if (!Children.Any()) + return SpacePlan.FullRender(Size.Zero); + UpdateColumnsWidth(availableSpace.Width); var renderingCommands = PlanLayout(availableSpace); @@ -63,7 +72,7 @@ namespace QuestPDF.Elements.Table if (tableSize.Width > availableSpace.Width + Size.Epsilon) return SpacePlan.Wrap(); - return FindLastRenderedRow(renderingCommands) == RowsCount + return FindLastRenderedRow(renderingCommands) == StartingRowsCount ? SpacePlan.FullRender(tableSize) : SpacePlan.PartialRender(tableSize); } @@ -86,7 +95,7 @@ namespace QuestPDF.Elements.Table CurrentRow = FindLastRenderedRow(renderingCommands) + 1; } - private static int FindLastRenderedRow(ICollection commands) + private int FindLastRenderedRow(ICollection commands) { return commands .GroupBy(x => x.Cell.Row) @@ -114,10 +123,14 @@ namespace QuestPDF.Elements.Table var columnOffsets = GetColumnLeftOffsets(Columns); var commands = GetRenderingCommands(); + + if (!commands.Any()) + return commands; + var tableHeight = commands.Max(cell => cell.Offset.Y + cell.Size.Height); - AdjustCellSizes(commands); - AdjustLastCellSizes(tableHeight, commands); + if (ExtendLastCellsToTableBottom) + AdjustLastCellSizes(tableHeight, commands); return commands; @@ -154,6 +167,9 @@ namespace QuestPDF.Elements.Table if (rowBottomOffsets[currentRow - 1] > availableSpace.Height + Size.Epsilon) break; + + foreach (var row in Enumerable.Range(cell.Row, cell.Row - currentRow)) + rowBottomOffsets[row] = rowBottomOffsets[row - 1]; currentRow = cell.Row; } @@ -201,23 +217,30 @@ namespace QuestPDF.Elements.Table }); } + if (!commands.Any()) + return commands; + + var maxRow = commands.Select(x => x.Cell).Max(x => x.Row + x.RowSpan); + + foreach (var row in Enumerable.Range(currentRow + 1, maxRow - currentRow)) + rowBottomOffsets[row] = rowBottomOffsets[row - 1]; + + AdjustCellSizes(commands, rowBottomOffsets); + // corner case: reject cell if other cells within the same row are rejected return commands.Where(x => x.Cell.Row <= maxRenderingRow).ToList(); } // corner sase: 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(ICollection commands) + static void AdjustCellSizes(ICollection commands, DynamicDictionary rowBottomOffsets) { - commands - .GroupBy(x => x.Cell.Row + x.Cell.RowSpan) - .ToList() - .ForEach(group => - { - var groupCells = group.ToList(); - var bottomBorderOffset = groupCells.Max(x => x.Offset.Y + x.Size.Height); - groupCells.ForEach(x => x.Size = new Size(x.Size.Width, bottomBorderOffset - x.Offset.Y)); - }); + foreach (var command in commands) + { + var lastRow = command.Cell.Row + command.Cell.RowSpan - 1; + var height = rowBottomOffsets[lastRow] - command.Offset.Y; + command.Size = new Size(command.Size.Width, height); + } } // corner sase: all cells, that are last ones in their respective columns, should take all remaining space diff --git a/QuestPDF/Elements/Table/TableLayoutPlanner.cs b/QuestPDF/Elements/Table/TableLayoutPlanner.cs index f45bf50..d9a79d6 100644 --- a/QuestPDF/Elements/Table/TableLayoutPlanner.cs +++ b/QuestPDF/Elements/Table/TableLayoutPlanner.cs @@ -5,7 +5,7 @@ using QuestPDF.Fluent; namespace QuestPDF.Elements.Table { - static class TableLayoutPlanner + static class TableLayoutValidator { public static void PlanCellPositions(this Table table) { @@ -79,7 +79,7 @@ namespace QuestPDF.Elements.Table private static bool HasLocation(this TableCell cell) { - return cell.Row != 1 && cell.Column != 1; + return cell.Row != 1 || cell.Column != 1; } } } \ No newline at end of file diff --git a/QuestPDF/Elements/Table/TableLayoutValidator.cs b/QuestPDF/Elements/Table/TableLayoutValidator.cs new file mode 100644 index 0000000..5425851 --- /dev/null +++ b/QuestPDF/Elements/Table/TableLayoutValidator.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using QuestPDF.Drawing.Exceptions; +using QuestPDF.Fluent; + +namespace QuestPDF.Elements.Table +{ + static class TableLayoutPlanner + { + public static void ValidateCellPositions(this Table table) + { + ValidateCellPositions(table.Columns.Count, table.Children); + } + + private static void ValidateCellPositions(int columnsCount, ICollection cells) + { + foreach (var cell in cells) + { + if (cell.Column > columnsCount) + throw new DocumentLayoutException($"Cell location is incorrect. Cell starts at column that does not exist. Cell details: {GetCellDetails(cell)}."); + + if (cell.Column + cell.ColumnSpan - 1 > columnsCount) + throw new DocumentLayoutException($"Cell location is incorrect. Cell spans over columns that do not exist. Cell details: {GetCellDetails(cell)}."); + } + + string GetCellDetails(TableCell cell) + { + return $"Row {cell.Row}, Column {cell.Column}, RowSpan {cell.RowSpan}, ColumnSpan {cell.ColumnSpan}"; + } + } + } +} \ No newline at end of file diff --git a/QuestPDF/Fluent/ElementExtensions.cs b/QuestPDF/Fluent/ElementExtensions.cs index 895cf6e..884e115 100644 --- a/QuestPDF/Fluent/ElementExtensions.cs +++ b/QuestPDF/Fluent/ElementExtensions.cs @@ -145,5 +145,13 @@ namespace QuestPDF.Fluent { return element.Element(new Unconstrained()); } + + public static IContainer DefaultTextStyle(this IContainer element, TextStyle textStyle) + { + return element.Element(new DefaultTextStyle + { + TextStyle = textStyle + }); + } } } diff --git a/QuestPDF/Fluent/RowExtensions.cs b/QuestPDF/Fluent/RowExtensions.cs index 6b9ac37..55fda7f 100644 --- a/QuestPDF/Fluent/RowExtensions.cs +++ b/QuestPDF/Fluent/RowExtensions.cs @@ -15,15 +15,15 @@ namespace QuestPDF.Fluent public IContainer ConstantColumn(float width) { - return ComplexColumn(constantWidth: width); + return Column(constantWidth: width); } public IContainer RelativeColumn(float width = 1) { - return ComplexColumn(relativeWidth: width); + return Column(relativeWidth: width); } - public IContainer ComplexColumn(float constantWidth = 0, float relativeWidth = 0) + public IContainer Column(float constantWidth = 0, float relativeWidth = 0) { var element = new RowElement(constantWidth, relativeWidth); diff --git a/QuestPDF/Fluent/TableExtensions.cs b/QuestPDF/Fluent/TableExtensions.cs index 5b48d7e..bf33d94 100644 --- a/QuestPDF/Fluent/TableExtensions.cs +++ b/QuestPDF/Fluent/TableExtensions.cs @@ -6,11 +6,11 @@ using QuestPDF.Infrastructure; namespace QuestPDF.Fluent { - public class TableDefinitionDescriptor + public class TableColumnsDefinitionDescriptor { private Table Table { get; } - internal TableDefinitionDescriptor(Table table) + internal TableColumnsDefinitionDescriptor(Table table) { Table = table; } @@ -35,29 +35,46 @@ namespace QuestPDF.Fluent public class TableDescriptor { private Table Table { get; } + private Func DefaultCellStyleFunc { get; set; } = x => x; internal TableDescriptor(Table table) { Table = table; } - public void ColumnsDefinition(Action handler) + public void ColumnsDefinition(Action handler) { - var descriptor = new TableDefinitionDescriptor(Table); + var descriptor = new TableColumnsDefinitionDescriptor(Table); handler(descriptor); } - public void Spacing(float value) + public void ExtendLastCellsToTableBottom() { - Table.Spacing = value; + Table.ExtendLastCellsToTableBottom = true; } - public ITableCellContainer Cell(int row = 1, int column = 1, int rowSpan = 1, int columnsSpan = 1) + public void DefaultCellStyle(Func mapper) + { + DefaultCellStyleFunc = mapper; + } + + public ITableCellContainer Cell() { var cell = new TableCell(); Table.Children.Add(cell); return cell; } + + internal void ApplyDefaultCellStyle() + { + foreach (var cell in Table.Children) + { + var container = new Container(); + DefaultCellStyleFunc(container).Element(cell.Child); + + cell.Child = container; + } + } } public static class TableExtensions @@ -65,11 +82,14 @@ namespace QuestPDF.Fluent public static void Table(this IContainer element, Action handler) { var table = new Table(); - var descriptor = new TableDescriptor(table); - - handler(descriptor); - table.PlanCellPositions(); + var descriptor = new TableDescriptor(table); + handler(descriptor); + descriptor.ApplyDefaultCellStyle(); + + table.PlanCellPositions(); + table.ValidateCellPositions(); + element.Element(table); } }