diff --git a/QuestPDF.Examples/TableExamples.cs b/QuestPDF.Examples/TableExamples.cs new file mode 100644 index 0000000..4af7352 --- /dev/null +++ b/QuestPDF.Examples/TableExamples.cs @@ -0,0 +1,75 @@ +using System; +using NUnit.Framework; +using QuestPDF.Examples.Engine; +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; + +namespace QuestPDF.Examples +{ + public class TableExamples + { + public static Random Random { get; } = new Random(0); + + [Test] + public void Example() + { + RenderingTest + .Create() + .ProduceImages() + .PageSize(PageSizes.A4) + .ShowResults() + .Render(container => + { + container + .Padding(25) + .Box() + .Border(2) + .MaxHeight(500) + .Table(table => + { + table.ColumnsDefinition(columns => + { + columns.ConstantColumn(100); + columns.RelativeColumn(); + columns.ConstantColumn(100); + columns.ConstantColumn(200); + }); + + table.Cell().Row(1).Column(1).ColumnSpan(2).Element(CreateBox("A")); + table.Cell().Row(1).Column(3).Element(CreateBox("B")); + table.Cell().Row(1).Column(4).Element(CreateBox("C")); + + table.Cell().Row(2).Column(1).Element(CreateBox("D")); + table.Cell().Row(2).RowSpan(2).Column(2).Element(CreateBox("E")); + table.Cell().Row(2).RowSpan(3).Column(3).ColumnSpan(2).Element(CreateBox("F")); + + table.Cell().Row(3).RowSpan(2).Column(1).Element(CreateBox("G")); + table.Cell().Row(4).RowSpan(2).Column(2).Element(CreateBox("H")); + table.Cell().Row(5).Column(3).Element(CreateBox("I")); + table.Cell().Row(5).Column(4).Element(CreateBox("J")); + table.Cell().Row(5).RowSpan(2).Column(1).Element(CreateBox("K")); + table.Cell().Row(6).Column(2).ColumnSpan(2).Element(CreateBox("L")); + table.Cell().Row(6).Column(4).Element(CreateBox("M")); + }); + }); + + Action CreateBox(string label) + { + return container => + { + var height = Random.Next(2, 7) * 25; + + container + .Border(1) + .Background(Placeholders.BackgroundColor()) + .AlignCenter() + .AlignMiddle() + .Border(1) + .MinHeight(height) + .Text($"{label}: {height}"); + }; + } + } + } +} \ No newline at end of file diff --git a/QuestPDF/Elements/Row.cs b/QuestPDF/Elements/Row.cs index 3cc24f2..e077392 100644 --- a/QuestPDF/Elements/Row.cs +++ b/QuestPDF/Elements/Row.cs @@ -8,8 +8,8 @@ namespace QuestPDF.Elements { internal class RowElement : Constrained { - public float ConstantSize { get; set; } - public float RelativeSize { get; set; } + public float ConstantSize { get; } + public float RelativeSize { get; } public RowElement(float constantSize, float relativeSize) { @@ -138,7 +138,7 @@ namespace QuestPDF.Elements var constantWidth = Children.Sum(x => x.ConstantSize); var relativeWidth = Children.Sum(x => x.RelativeSize); - var widthPerRelativeUnit = (availableWidth - constantWidth) / relativeWidth; + var widthPerRelativeUnit = (relativeWidth > 0) ? (availableWidth - constantWidth) / relativeWidth : 0; foreach (var row in Children) { diff --git a/QuestPDF/Elements/Table.cs b/QuestPDF/Elements/Table.cs new file mode 100644 index 0000000..83d7b9a --- /dev/null +++ b/QuestPDF/Elements/Table.cs @@ -0,0 +1,235 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using QuestPDF.Drawing; +using QuestPDF.Fluent; +using QuestPDF.Infrastructure; + +namespace QuestPDF.Elements +{ + internal class TableColumnDefinition + { + public float ConstantSize { get; } + public float RelativeSize { get; } + + internal float Width { get; set; } + + public TableColumnDefinition(float constantSize, float relativeSize) + { + ConstantSize = constantSize; + RelativeSize = relativeSize; + } + } + + public interface ITableCellContainer : IContainer + { + + } + + internal class TableCell : Container, ITableCellContainer + { + public int Row { get; set; } = 1; + public int RowSpan { get; set; } = 1; + + public int Column { get; set; } = 1; + public int ColumnSpan { get; set; } = 1; + } + + internal class TableRenderingPlan + { + public Size Size { get; set; } + public List CellRenderingCommands { get; set; } + public int MaxRowRendered { get; set; } + } + + internal class TableCellRenderingCommand + { + public TableCell Cell { get; set; } + public Size Size { get; set; } + public Position Offset { get; set; } + } + + internal class Table : Element, IStateResettable + { + public ICollection Columns { get; } = new List(); + public ICollection Children { get; } = new List(); + public float Spacing { get; set; } + + public int CurrentRow { get; set; } + + internal override void HandleVisitor(Action visit) + { + Children.ToList().ForEach(x => x.HandleVisitor(visit)); + base.HandleVisitor(visit); + } + + public void ResetState() + { + CurrentRow = 1; + } + + internal override SpacePlan Measure(Size availableSpace) + { + UpdateColumnsWidth(availableSpace.Width); + + var layout = PlanLayout(availableSpace); + + return layout.MaxRowRendered < GetRowsCount() + ? SpacePlan.PartialRender(layout.Size) + : SpacePlan.FullRender(layout.Size); + } + + internal override void Draw(Size availableSpace) + { + UpdateColumnsWidth(availableSpace.Width); + var layout = PlanLayout(availableSpace); + CurrentRow = layout.MaxRowRendered; + + foreach (var command in layout.CellRenderingCommands) + { + Canvas.Translate(command.Offset); + command.Cell.Draw(command.Size); + Canvas.Translate(command.Offset.Reverse()); + } + } + + private void UpdateColumnsWidth(float availableWidth) + { + var constantWidth = Columns.Sum(x => x.ConstantSize); + var relativeWidth = Columns.Sum(x => x.RelativeSize); + + var widthPerRelativeUnit = (relativeWidth > 0) ? (availableWidth - constantWidth) / relativeWidth : 0; + + foreach (var column in Columns) + { + column.Width = column.ConstantSize + column.RelativeSize * widthPerRelativeUnit; + } + } + + private TableRenderingPlan PlanLayout(Size availableSpace) + { + var cellRenderingCommands = new List(); + + // update row heights + var rowsCount = GetRowsCount(); + var rowBottomOffsets = new float[rowsCount]; + var childrenToTry = Children.Where(x => x.Row >= CurrentRow).OrderBy(x => x.Row); + + foreach (var child in childrenToTry) + { + var rowIndex = child.Row - 1; + + var topOffset = 0f; + + if (rowIndex > 0) + topOffset = rowBottomOffsets[rowIndex - 1]; // look at previous row + + var height = GetCellSize(child).Height; + var cellBottomOffset = height + topOffset; + + var targetRowId = child.Row + child.RowSpan - 2; // -1 because indexing starts at 0, -1 because rowSpan starts at 1 + rowBottomOffsets[targetRowId] = Math.Max(rowBottomOffsets[targetRowId], cellBottomOffset); + } + + Enumerable + .Range(1, rowsCount - 1) + .ToList() + .ForEach(x => rowBottomOffsets[x] = Math.Max(rowBottomOffsets[x], rowBottomOffsets[x-1])); + + var rowHeights = new float[rowsCount]; + rowHeights[0] = rowBottomOffsets[0]; + + Enumerable + .Range(1, rowsCount - 1) + .ToList() + .ForEach(x => rowHeights[x] = rowBottomOffsets[x] - rowBottomOffsets[x-1]); + + // find rows count to render in this pass + var rowsToDisplay = rowHeights.Scan((x, y) => x + y).Count(x => x <= availableSpace.Height + Size.Epsilon); + rowHeights = rowHeights.Take(rowsToDisplay).ToArray(); + + var totalHeight = rowHeights.Sum(); + var totalWidth = Columns.Sum(x => x.Width); + + foreach (var cell in Children) + { + if (!IsCellVisible(cell, rowsToDisplay)) + continue; + + var leftOffset = GetCellWidthOffset(cell); + var topOffset = rowHeights.Take(cell.Row - 1).Sum(); + + var width = GetCellWidth(cell); + var height = rowHeights.Skip(cell.Row - 1).Take(cell.RowSpan).Sum(); + + cellRenderingCommands.Add(new TableCellRenderingCommand() + { + Cell = cell, + Size = new Size(width, height), + Offset = new Position(leftOffset, topOffset) + }); + } + + return new TableRenderingPlan + { + Size = new Size(totalWidth, totalHeight), + CellRenderingCommands = cellRenderingCommands, + MaxRowRendered = rowsToDisplay + }; + + float GetCellWidthOffset(TableCell cell) + { + return Columns.Take(cell.Column - 1).Select(x => x.Width).DefaultIfEmpty(0).Sum(); + } + + float GetCellWidth(TableCell cell) + { + return Columns.Skip(cell.Column - 1).Take(cell.ColumnSpan).Sum(x => x.Width); + } + + Size GetCellSize(TableCell cell) + { + var width = GetCellWidth(cell); + var cellSize = new Size(width, Size.Max.Height); + + var measurement = cell.Measure(cellSize); + + if (measurement.Type == SpacePlanType.Wrap) + return new Size(width, Size.Infinity); + + return measurement; + } + + bool IsCellVisible(TableCell cell, int maxRow) + { + return cell.Row <= maxRow; + } + } + + int GetRowsCount() + { + return Children.Max(x => x.Row + x.RowSpan); + } + } + + internal static class EnumerableExtensions + { + public static IEnumerable Scan(this IEnumerable input, Func accumulate) + { + using var enumerator = input.GetEnumerator(); + + if (!enumerator.MoveNext()) + yield break; + + var state = enumerator.Current; + yield return state; + + while (enumerator.MoveNext()) + { + state = accumulate(state, enumerator.Current); + yield return state; + } + } + } +} \ No newline at end of file diff --git a/QuestPDF/Fluent/TableExtensions.cs b/QuestPDF/Fluent/TableExtensions.cs new file mode 100644 index 0000000..7ec350b --- /dev/null +++ b/QuestPDF/Fluent/TableExtensions.cs @@ -0,0 +1,103 @@ +using System; +using QuestPDF.Elements; +using QuestPDF.Infrastructure; + +namespace QuestPDF.Fluent +{ + public class TableDefinitionDescriptor + { + private Table Table { get; } + + internal TableDefinitionDescriptor(Table table) + { + Table = table; + } + + public void ConstantColumn(float width) + { + ComplexColumn(constantWidth: width); + } + + public void RelativeColumn(float width = 1) + { + ComplexColumn(relativeWidth: width); + } + + public void ComplexColumn(float constantWidth = 0, float relativeWidth = 0) + { + var columnDefinition = new TableColumnDefinition(constantWidth, relativeWidth); + Table.Columns.Add(columnDefinition); + } + } + + public class TableDescriptor + { + private Table Table { get; } + + internal TableDescriptor(Table table) + { + Table = table; + } + + public void ColumnsDefinition(Action handler) + { + var descriptor = new TableDefinitionDescriptor(Table); + handler(descriptor); + } + + public void Spacing(float value) + { + Table.Spacing = value; + } + + public ITableCellContainer Cell() + { + var cell = new TableCell(); + Table.Children.Add(cell); + return cell; + } + } + + public static class TableExtensions + { + public static void Table(this IContainer element, Action handler) + { + var table = new Table(); + var descriptor = new TableDescriptor(table); + + handler(descriptor); + element.Element(table); + } + } + + public static class TableCellExtensions + { + private static ITableCellContainer TableCell(this ITableCellContainer element, Action handler) + { + if (element is TableCell tableCell) + handler(tableCell); + + return element; + } + + public static ITableCellContainer Column(this ITableCellContainer tableCellContainer, uint value) + { + return tableCellContainer.TableCell(x => x.Column = (int)value); + } + + public static ITableCellContainer ColumnSpan(this ITableCellContainer tableCellContainer, uint value) + { + return tableCellContainer.TableCell(x => x.ColumnSpan = (int)value); + } + + public static ITableCellContainer Row(this ITableCellContainer tableCellContainer, uint value) + { + return tableCellContainer.TableCell(x => x.Row = (int)value); + } + + public static ITableCellContainer RowSpan(this ITableCellContainer tableCellContainer, uint value) + { + return tableCellContainer.TableCell(x => x.RowSpan = (int)value); + } + } +} \ No newline at end of file diff --git a/QuestPDF/Infrastructure/Size.cs b/QuestPDF/Infrastructure/Size.cs index b8f847d..0636149 100644 --- a/QuestPDF/Infrastructure/Size.cs +++ b/QuestPDF/Infrastructure/Size.cs @@ -3,6 +3,7 @@ public readonly struct Size { public const float Epsilon = 0.001f; + public const float Infinity = float.PositiveInfinity; public readonly float Width; public readonly float Height;