Skip to main content

Tables

A PinataLayout table is a grid of columns and rows whose cells hold paragraphs, images and text frames. The renderer sizes each row to its content and breaks a long table across pages by itself, repeating the heading rows at the top of each new page. Use a table for data in rows and columns, such as the lines of an invoice or a report's figures. For a few aligned values, tab stops are lighter; see Paragraphs and text layout.

The table types are in the PinataLayout.DocumentObjectModel.Tables namespace.

Columns first, then rows

Section.AddTable() creates the table. Add every column before you add a row: each row gets one cell per column, and AddColumn throws an InvalidOperationException once the table has rows. AddColumn takes the column's width, and the column's Format sets the paragraph format for every cell in it, so alignment is set once:

src/SampleApp/Demos/TablesDemo.cs
var table = section.AddTable();
table.Borders.Width = 0.4;
table.Borders.Color = Colors.LightGray;
table.Rows.LeftIndent = 0;

// Columns are added with their widths, and each carries the alignment of the
// cells in it - set once here rather than on every cell below.
var region = table.AddColumn(Unit.FromCentimeter(3.4));
region.Format.Alignment = ParagraphAlignment.Left;

var quarter = table.AddColumn(Unit.FromCentimeter(2.2));
quarter.Format.Alignment = ParagraphAlignment.Left;

foreach (var _ in new[] { "units", "revenue", "margin" })
table.AddColumn(Unit.FromCentimeter(3)).Format.Alignment = ParagraphAlignment.Right;

AddRow() returns a Row, and row.Cells[index] is the cell in each column. Add content with cell.AddParagraph(text). Rows.LeftIndent moves the whole table from the margin, and Rows.Alignment centres it or sets it against the right margin.

Formatting cascades from the table to the column, the row and the cell. Table.Borders, Table.Shading and Table.Format apply to every cell; Row.Borders, Row.Shading and Row.Format override them for one row; the cell's own properties override both. A table and each cell can also take a Style.

Heading rows that repeat

Set Row.HeadingFormat = true on the rows at the top of the table. When the table runs onto another page, those rows are drawn again at the top of it. In a tagged document their cells also become header cells, so a screen reader can announce the column name before each value.

src/SampleApp/Demos/TablesDemo.cs
// A title band across the whole width. MergeRight is a count of the cells to
// swallow to the right, so the other four cells of this row are never filled in.
var band = table.AddRow();
band.Shading.Color = Colors.DarkSlateGray;
band.Cells[0].MergeRight = 4;
band.Cells[0].Format.Alignment = ParagraphAlignment.Center;
band.Cells[0].AddParagraph("Financial year to date").Style = "TableHeading";

// HeadingFormat is what makes a row repeat onto every page the table reaches.
//
// It has to be set on this band as well as on the row of column names below it,
// and that is not decoration. The renderer walks the rows from the first one and
// stops at the first row that does not carry the flag, so the heading is whatever
// unbroken run of rows starts the table. Marking only the second row would leave
// the run empty and nothing would repeat at all.
band.HeadingFormat = true;

var header = table.AddRow();
header.HeadingFormat = true;
header.Shading.Color = Colors.SlateGray;
header.VerticalAlignment = VerticalAlignment.Center;
header.Height = Unit.FromPoint(20);

string[] headings = { "Region", "Quarter", "Units", "Revenue", "Margin" };
for (var column = 0; column < headings.Length; column++)
header.Cells[column].AddParagraph(headings[column]).Style = "TableHeading";

The heading is the unbroken run of HeadingFormat rows that starts at row 0. A heading row after an ordinary row cannot repeat, and the renderer throws an InvalidOperationException that names the row. If every row of the table is a heading row, nothing repeats.

Merge cells

Cell.MergeRight is the number of cells to the right that the cell spans, and Cell.MergeDown is the number of rows below it. Put the content in the first cell and leave the cells it covers empty.

src/SampleApp/Demos/TablesDemo.cs
var row = table.AddRow();
row.VerticalAlignment = VerticalAlignment.Center;

// Banding by row rather than by border, which stays readable when the
// table is wide and the eye has to track across it.
if (rowIndex % 2 == 1)
row.Shading.Color = Colors.WhiteSmoke;

// The region is named once and its cell swallows the three rows below it,
// so the four quarters read as one block. MergeDown counts the rows taken.
if (q == 0)
{
row.Cells[0].MergeDown = quarters.Length - 1;
row.Cells[0].VerticalAlignment = VerticalAlignment.Center;
row.Cells[0].AddParagraph(name);
}

The invoice uses MergeRight for its totals, so the label spans the first four columns and the amount sits in the last:

src/SampleApp/Demos/InvoiceDemo.cs
void Total(string label, decimal amount, bool emphasis)
{
var row = table.AddRow();
row.TopPadding = Unit.FromPoint(4);
row.BottomPadding = Unit.FromPoint(4);
row.Cells[0].MergeRight = 3;
row.Cells[0].Format.Alignment = ParagraphAlignment.Right;
row.Cells[0].AddParagraph(label);
row.Cells[4].AddParagraph($"{amount:N2}");

if (emphasis)
{
row.Format.Font.Bold = true;
row.Borders.Top.Width = 0.8;
row.Borders.Top.Color = Colors.Black;
}
}

Total("Net", net, false);
Total("VAT at 20%", vat, false);
Total("Total due (GBP)", net + vat, true);

Borders and shading

Borders has Top, Bottom, Left and Right borders, each with a Width, Color and Style. Setting Borders.Width or Borders.Color sets all four. Shading.Color fills the background.

Table.SetEdge draws a border around, or along one edge of, a block of cells. It takes the first column, the first row, the number of columns and rows, which edges to draw (Edge.Box, Edge.Bottom, Edge.Interior and others), a line style, a width and an optional colour:

src/SampleApp/Demos/TablesDemo.cs
// A rule under each region's block, so the merged cell has a visible extent.
// SetEdge takes a column, a row, how many of each, and which edges to draw.
table.SetEdge(0, table.Rows.Count - 1, 5, 1, Edge.Bottom, BorderStyle.Single, 0.8,
Colors.Gainsboro);

Table.SetShading fills a block of cells the same way. The demo finishes with a total row that is shaded, bold and boxed:

src/SampleApp/Demos/TablesDemo.cs
var total = table.AddRow();
total.Shading.Color = Colors.Gainsboro;
total.Format.Font.Bold = true;
total.Cells[0].MergeRight = 1;
total.Cells[0].AddParagraph("All regions");
total.Cells[2].AddParagraph($"{totalUnits:N0}");
total.Cells[3].AddParagraph($"{totalRevenue:N2}");
// Weighted by units rather than a plain mean of the column, which is what a total
// row of percentages has to be if it is to mean anything.
total.Cells[4].AddParagraph($"{marginTimesUnits / totalUnits:P1}");

table.SetEdge(0, table.Rows.Count - 1, 5, 1, Edge.Box, BorderStyle.Single, 1, Colors.Black);

Row height and alignment inside cells

A row grows to fit its tallest cell. Row.Height with Row.HeightRule sets a minimum (RowHeightRule.AtLeast) or a fixed height (RowHeightRule.Exactly). Row.VerticalAlignment and Cell.VerticalAlignment place the content at the top, centre or bottom of the cell. TopPadding and BottomPadding on a row, and LeftPadding and RightPadding on a column or the table, set the space between the content and the cell's edges. Horizontal alignment is a paragraph setting: set it on the column's, row's or cell's Format.

Keep rows together

A row is never split across pages. To stop a page break between rows that belong together:

  • Row.KeepWith is the number of rows after this one that must stay on the same page as it.
  • Cells merged with MergeDown keep the rows they span together.
  • Table.KeepTogether keeps the whole table on one page, if it fits.

Things to know

  • Add all columns before the first row. A column added after a row throws.
  • HeadingFormat must start at the first row. Mark every row from row 0 down to the last heading row. A title band above the column names needs the flag too, as in the demo.
  • Merged cells are counts, not indexes. MergeRight = 4 spans five cells: the cell itself and four more.
  • Merged rows move as one block. Rows joined by MergeDown or KeepWith go to the next page together, so a large merged block can leave a gap at the foot of the page before it.
  • Describe data tables for screen readers. Table.Summary is written into the tagged output. See Accessibility.
  • A footnote cannot go in a cell. See Footnotes.

See it in action

The Tables demo builds an 80-row table of quarterly figures that runs over two pages. It has a two-row repeating heading, region cells merged down four rows, alternate row shading, a rule under each region and a boxed total row.

The full Tables demo
src/SampleApp/Demos/TablesDemo.cs
var document = new Document
{
Info =
{
Title = "Tables"
}
};

// Styles are named and inherited, so setting Normal here sets the font of
// everything that does not override it - including the table.
document.Styles["Normal"].Font.Name = "Liberation Sans";
document.Styles["Normal"].Font.Size = 9;

var heading = document.Styles.AddStyle("TableHeading", "Normal");
heading.Font.Bold = true;
heading.Font.Color = Colors.White;

var section = document.AddSection();
section.PageSetup.PageFormat = PageFormat.A4;
section.PageSetup.TopMargin = Unit.FromCentimeter(2);
section.PageSetup.BottomMargin = Unit.FromCentimeter(2);

// The footer is written once and appears on every page. The two fields are
// resolved at render time, when how many pages there are is finally known.
var footer = section.Footers.Primary.AddParagraph();
footer.Format.Alignment = ParagraphAlignment.Center;
footer.AddText("Page ");
footer.AddPageField();
footer.AddText(" of ");
footer.AddNumPagesField();

var title = section.AddParagraph("Quarterly returns by region");
title.Format.Font.Size = 16;
title.Format.Font.Bold = true;
title.Format.SpaceAfter = Unit.FromPoint(12);

var table = section.AddTable();
table.Borders.Width = 0.4;
table.Borders.Color = Colors.LightGray;
table.Rows.LeftIndent = 0;

// Columns are added with their widths, and each carries the alignment of the
// cells in it - set once here rather than on every cell below.
var region = table.AddColumn(Unit.FromCentimeter(3.4));
region.Format.Alignment = ParagraphAlignment.Left;

var quarter = table.AddColumn(Unit.FromCentimeter(2.2));
quarter.Format.Alignment = ParagraphAlignment.Left;

foreach (var _ in new[] { "units", "revenue", "margin" })
table.AddColumn(Unit.FromCentimeter(3)).Format.Alignment = ParagraphAlignment.Right;

// A title band across the whole width. MergeRight is a count of the cells to
// swallow to the right, so the other four cells of this row are never filled in.
var band = table.AddRow();
band.Shading.Color = Colors.DarkSlateGray;
band.Cells[0].MergeRight = 4;
band.Cells[0].Format.Alignment = ParagraphAlignment.Center;
band.Cells[0].AddParagraph("Financial year to date").Style = "TableHeading";

// HeadingFormat is what makes a row repeat onto every page the table reaches.
//
// It has to be set on this band as well as on the row of column names below it,
// and that is not decoration. The renderer walks the rows from the first one and
// stops at the first row that does not carry the flag, so the heading is whatever
// unbroken run of rows starts the table. Marking only the second row would leave
// the run empty and nothing would repeat at all.
band.HeadingFormat = true;

var header = table.AddRow();
header.HeadingFormat = true;
header.Shading.Color = Colors.SlateGray;
header.VerticalAlignment = VerticalAlignment.Center;
header.Height = Unit.FromPoint(20);

string[] headings = { "Region", "Quarter", "Units", "Revenue", "Margin" };
for (var column = 0; column < headings.Length; column++)
header.Cells[column].AddParagraph(headings[column]).Style = "TableHeading";

// Twenty regions of four quarters each is eighty rows, which is comfortably more
// than an A4 page holds. That is the point: a table that fits on one page has
// nothing to say about what happens to the heading when it does not.
string[] regions =
{
"North", "South", "East", "West", "Central", "Highlands", "Islands",
"Coastal", "Riverside", "Uplands", "Lowlands", "Borders", "Midlands",
"Fenland", "Weald", "Downs", "Moors", "Dales", "Marches", "Cinque Ports"
};
string[] quarters = { "Q1", "Q2", "Q3", "Q4" };

// Accumulated as the rows are built rather than worked out again afterwards, so the
// totals row cannot disagree with the column above it. A reader who adds the column
// up is exactly the reader a table demo has to survive.
var rowIndex = 0;
long totalUnits = 0;
double totalRevenue = 0;
double marginTimesUnits = 0;

foreach (var name in regions)
{
for (var q = 0; q < quarters.Length; q++)
{
var row = table.AddRow();
row.VerticalAlignment = VerticalAlignment.Center;

// Banding by row rather than by border, which stays readable when the
// table is wide and the eye has to track across it.
if (rowIndex % 2 == 1)
row.Shading.Color = Colors.WhiteSmoke;

// The region is named once and its cell swallows the three rows below it,
// so the four quarters read as one block. MergeDown counts the rows taken.
if (q == 0)
{
row.Cells[0].MergeDown = quarters.Length - 1;
row.Cells[0].VerticalAlignment = VerticalAlignment.Center;
row.Cells[0].AddParagraph(name);
}

var units = 400 + rowIndex * 37 % 900;
var revenue = units * 12.5;
var margin = (units % 17 + 8) / 100.0;

totalUnits += units;
totalRevenue += revenue;
marginTimesUnits += margin * units;

row.Cells[1].AddParagraph(quarters[q]);
row.Cells[2].AddParagraph($"{units:N0}");
row.Cells[3].AddParagraph($"{revenue:N2}");
row.Cells[4].AddParagraph($"{margin:P1}");

rowIndex++;
}

// A rule under each region's block, so the merged cell has a visible extent.
// SetEdge takes a column, a row, how many of each, and which edges to draw.
table.SetEdge(0, table.Rows.Count - 1, 5, 1, Edge.Bottom, BorderStyle.Single, 0.8,
Colors.Gainsboro);
}

var total = table.AddRow();
total.Shading.Color = Colors.Gainsboro;
total.Format.Font.Bold = true;
total.Cells[0].MergeRight = 1;
total.Cells[0].AddParagraph("All regions");
total.Cells[2].AddParagraph($"{totalUnits:N0}");
total.Cells[3].AddParagraph($"{totalRevenue:N2}");
// Weighted by units rather than a plain mean of the column, which is what a total
// row of percentages has to be if it is to mean anything.
total.Cells[4].AddParagraph($"{marginTimesUnits / totalUnits:P1}");

table.SetEdge(0, table.Rows.Count - 1, 5, 1, Edge.Box, BorderStyle.Single, 1, Colors.Black);

// Lists live here rather than in the Layout demo, because ListInfo is PinataLayout's
// and there is nothing like it on the PdfPinata side.
section.AddParagraph().Format.SpaceAfter = Unit.FromPoint(10);
section.AddParagraph("Notes").Format.Font.Bold = true;

string[] notes =
{
"Margin is gross and excludes carriage.",
"The heading row above repeats on every page this table reaches, which is what "
+ "HeadingFormat is for.",
"This list is a PinataLayout ListInfo - the marker, the indent and the hanging "
+ "alignment all come from the style rather than being drawn."
};

foreach (var note in notes)
{
var item = section.AddParagraph(note);
item.Format.ListInfo.ListType = ListType.BulletList1;
item.Format.LeftIndent = Unit.FromCentimeter(0.6);
}

// PinataLayout owns the PdfDocument: the renderer builds it, and it is handed back for
// the base class to save exactly as the hand-drawn demos hand theirs back.
var renderer = new PdfDocumentRenderer(true) { Document = document };
renderer.RenderDocument();