Tables
Tables are the most involved element in the library: they have their own column-width algorithm, two different strategies for handling a row that doesn’t fit on a page, and a row-height cache that keeps pagination of a large table fast. This page covers all of it.
Building a table
c.AddTable(table =>
{
table.AddColumns("Product", "Qty", "Revenue"); // headers only, auto width
table.AddRow("Widget A", "120", "$2,400");
table.AddRow("Widget B", "85", "$1,275");
});
TableBuilder methods:
AddColumns(params string[] headers)- one auto-width column per header string.AddColumn(string header, double? widthPx = null)- a single column, optionally with a fixed width.AddRow(params string[] cells)- a row from plain strings, one per column, implicitly converted toTableCell.AddRow(IReadOnlyList<TableCell> cells)- a row with explicit cells, letting you override style per cell.
Every row must supply exactly one cell per column - mismatched counts throw
ArgumentException from the Table constructor (e.g. AddColumns("A", "B")
followed by AddRow("only one")).
AddTable(configure, style?) returns the ContentBuilder itself - tables
have no chainable post-add modifiers; pass a TableStyle directly as the
second argument instead.
Column width resolution
Each TableColumn either has an explicit WidthPx or is auto-width. Given
the table’s resolved content width:
- Sum the explicit widths of all fixed-width columns.
- Whatever’s left over (content width minus that sum, floored at 0) is divided equally among the auto-width columns.
table.AddColumn("Code", widthPx: 70); // fixed: 70px
table.AddColumn("Description"); // auto: shares what's left
table.AddColumn("Price", widthPx: 90); // fixed: 90px
If the fixed widths alone exceed the content width, auto columns are pinned
to 0 rather than going negative - there’s no shrink-to-fit or overflow
warning for this case today, so size fixed columns conservatively relative to
the page width you’re targeting.
The table renders with table-layout:fixed and an explicit <colgroup>
matching these resolved widths - see
Rendering: What the HTML looks like
for why the <table> itself is deliberately not pinned to width:100%.
TableStyle
TableStyle controls
everything visual about a table:
| Property | Default | Notes |
|---|---|---|
HeaderTextStyle |
Bold, white text, no bottom margin | Style for header-row cell text. |
CellTextStyle |
TextStyle.Default with no bottom margin |
Style for body-row cell text (per-cell overridable, see below). |
HeaderBackgroundColor |
"#2f4858" |
Header row background. |
EvenRowBackgroundColor / OddRowBackgroundColor |
"#ffffff" / "#f4f6f7" |
Body row backgrounds when StripedRows is on; 0-based, so row 0 is “even”. |
StripedRows |
true |
Whether alternating backgrounds are applied. |
CellPaddingPx |
6 |
Applied on all four sides of every cell. |
BorderColor |
"#d8dde0" |
Set empty for no visible border. |
BorderWidthPx |
1 |
Border thickness; also factored into row-height measurement (see below). |
RowSplitBehavior |
AllowSplitWithContinuedHeader |
See Row splitting below. |
ContinuedHeaderSuffix |
" (continued)" |
Appended to the header on a continuation page; empty string suppresses it. |
c.AddTable(
table => { ... },
TableStyle.Default.With(stripedRows: false, headerBackgroundColor: "#1c2b33"));
TableStyle.With(...) follows the same override-only-what-you-pass pattern
as TextStyle.With(...).
Per-cell style overrides
A TableCell carries an optional Style that overrides CellTextStyle for
that one cell - useful for right-aligning numeric columns, for example:
var rightAlign = TextStyle.Default.With(alignment: TextAlignment.Right, marginBottomPx: 0);
table.AddRow(new TableCell[]
{
"Website Redesign",
new TableCell("1", rightAlign),
new TableCell("$1,200.00", rightAlign),
new TableCell("$1,200.00", rightAlign),
});
Plain strings implicitly convert to a TableCell with no override
(Style = null), which is why AddRow("a", "b", "c") works without
constructing TableCell instances yourself.
Column and row spans
A TableCell can occupy more than one column or row via ColSpan/RowSpan
(both default 1), set through object-initializer syntax:
table.AddRow(new TableCell[]
{
new TableCell("Electronics") { RowSpan = 3 }, // spans this row and the next two
"Wireless Mouse", "2", "$39.98",
});
table.AddRow(new TableCell[] { "Mechanical Keyboard", "1", "$89.00" }); // omits the "Category" column - covered above
table.AddRow(new TableCell[] { "USB-C Dock", "1", "$64.50" });
table.AddRow(new TableCell[]
{
new TableCell("Grand Total") { ColSpan = 3 }, // spans "Category", "Item", and "Qty"
"$193.48",
});
A row’s cells must account for exactly every column: a cell covered by an
earlier row’s RowSpan is skipped when counting, and a ColSpan > 1 cell
counts for that many columns. Supplying too few or too many cells for what’s
left over throws ArgumentException from the Table constructor, as does a
ColSpan/RowSpan that would extend past the table’s last column/row.
Pagination treats a RowSpan group as atomic. Rows linked by an active
RowSpan either fit together on a page or move to the next page as a whole -
AllowSplitWithContinuedHeader’s mid-row line truncation (below) never
applies inside such a group, only to a lone row with no active rowspan. If a
group is taller than an entire empty page, it’s reported the same way any
other oversized, unsplittable content is - see
Pagination and Layout: LayoutWarning.
A RowSpan cell’s own required height is satisfied by the sum of the rows
it spans; if the rows’ own content wouldn’t naturally add up to enough, the
shortfall is added to the last row of the span (an approximation, consistent
with the library’s approximate text measurement elsewhere).
See Cookbook: Grouping rows with RowSpan
for a runnable example.
Row splitting (RowSplitBehavior)
When a table doesn’t entirely fit in the space remaining on a page, the
engine places as many whole rows as fit, then has to decide what to do with
the row that’s too tall for what’s left. TableStyle.RowSplitBehavior
controls this:
KeepRowIntact- the row that doesn’t fit moves whole to the next page (under a repeated header). Simple, but a single very tall row can waste a lot of trailing whitespace on the page it didn’t fit on.AllowSplitWithContinuedHeader(the default) - every cell in the offending row is truncated at a shared line budget (the narrowest cell’s line height determines how many lines fit across all cells in that row, so the visual split lines up across columns), and the remainder continues as the first row on the next page, under a freshly repeated header annotated withContinuedHeaderSuffix.
c.AddTable(
table => { ... },
TableStyle.Default.With(rowSplitBehavior: RowSplitBehavior.KeepRowIntact));
If a table’s header alone is taller than the entire available space (a
degenerate case - an oversized header style on a very small page), the table
is reported Unsplittable and the usual “doesn’t fit anywhere” handling in
Pagination and Layout
takes over instead.
The continuation banner (the “(continued)” text above the repeated header) is
rendered as its own full-width row above the header on continuation pages,
and its height is included in measurement - omitting ContinuedHeaderSuffix
entirely (empty string) removes both the banner and its height contribution.
How a row’s height is measured
A row’s height is the tallest of its cells’ wrapped-text heights (each cell
measured independently at its column’s resolved width, minus
2 * CellPaddingPx), plus one BorderWidthPx for that row’s own bottom
border line. The table’s total measured height adds one extra BorderWidthPx
for the outermost top edge that no row otherwise accounts for. This border
accounting exists specifically so the overflow:hidden container the table
renders inside isn’t sized a hair too short - see the inline remarks on
Table.MeasureRowHeight
if you’re curious about the exact reasoning (it was a real, since-fixed bug:
omitting it silently clipped the last row’s bottom border).
Performance: row heights are cached, not recomputed per page
A large table split across many pages does not re-measure its entire
remaining row list on every page transition. Table caches each row’s
measured height (keyed by content width) the first time it’s needed, and
Split slices that cached array into the head/tail fragments it produces
rather than recomputing - so an N-row table is measured in O(N) total work
across its entire pagination, however many pages it ends up spanning, not
O(N^2). You don’t need to do anything to get this - it’s automatic - but it’s
worth knowing if you’re profiling a very large table (tens of thousands of
rows) and wondering where the cache lives.
Where to go next
- Content Elements: Table for where
AddTablefits among the other element types. - Pagination and Layout for the general
measure/split contract every element (including
Table) implements. - Cookbook: Multi-page table and Cookbook: Table styling comparison for runnable examples.