Tables
Tables are Papyra’s most capable element: fixed and relative column widths, column and row spans, styled cells, and headers/footers that repeat when a table breaks across pages.
Columns
Section titled “Columns”The simplest form distributes columns evenly:
doc.Table(table => table .Columns(3) .Row(row => row.Cell("A").Cell("B").Cell("C")));For real layouts, mix fixed widths (points) and relative weights:
table.Columns(columns => columns .FixedColumn(225f) // exactly 225 pt .FixedColumn(48f) .RelativeColumn(2f) // shares remaining width, weight 2 .RelativeColumn()); // weight 1table.Row(row => row .Cell("Description", cellStyle) .Cell("1,234.00 GBP", numericCell, textStyle: bold));Cell takes optional CellStyle (padding, border, background, alignment), columnSpan, rowSpan, and a TextStyle for the content:
table.Row(row => row .Cell("Spans two columns", style, columnSpan: 2) .Cell("Spans two rows", style, rowSpan: 2));Cell also accepts styling chained directly off the call, as an alternative to passing a CellStyle argument - only reachable right after a cell, same as text-run styling:
table.Row(row => row .Cell("Description") .Cell("1,234.00 GBP").Padding(new Thickness(4)).BackgroundColor(Colors.Grey.Lighten3));A whole CellStyle built elsewhere can be applied with WithStyle(preset), using the same fallback semantics as TextRunBuilder.WithStyle.
Rows grow with their content but never shrink below a minimum height - 24pt by default, configurable document-wide:
Document.Create(doc => doc .MinRowHeight(12f) // compact tables; content height + padding is the practical floor .Table(t => ...));The content height (text lines plus cell padding) always wins over a smaller floor, so rows never clip. In DOCX the row height maps to a native atLeast rule; XLSX sizes rows itself. For compact side-by-side blocks without any table floor, rows only reserve a single 12pt text line.
Cell borders
Section titled “Cell borders”CellBorder supports one uniform width or an independent width per side with a shared color and a shared stroke style (LineStyle.Solid, DASHED, or DOTTED); a side with width 0 is not drawn:
new CellStyle(Border: new CellBorder(1f, Colors.Grey.Lighten1)); // full gridnew CellStyle(Border: CellBorder.BottomOnly(1f)); // row separatorsnew CellStyle(Border: new CellBorder(3f, 0f, 1f, 2f)); // left, top, right, bottomnew CellStyle(Border: new CellBorder(1f, style: LineStyle.Dashed)); // dashed gridPer-format notes:
- PDF strokes each side at its exact width; where sides of different widths meet, the corners are butt-joined (no miter). Dashed strokes are 3× width on / 2× width off, dotted strokes are width on / width off (square dots); the pattern follows the rectangle path on uniform borders, so the dash phase at corners is not aligned.
- DOCX maps each side to the native
w:tcBorderselement (single,dashed, ordotted) and omits sides with width 0 - note that Word may still paint a neighboring cell’s border along a shared edge. - XLSX maps widths to Excel’s three border weights (thin ≤ 1pt, medium ≤ 2pt, thick above). Excel has a single style axis, so the mapping is lossy for non-solid styles: dashed becomes
dashed(≤ 1pt) ormediumDashed(above - there is no thick dashed), and dotted is alwaysdottedregardless of width. On merged ranges the border sides are applied to the range’s perimeter cells only. - HTML emits the CSS
border-stylekeyword. Tables useborder-collapse: collapse, so on a shared edge with conflicting styles the browser picks one by CSS precedence (dashed beats dotted beats solid at equal widths).
Rich cell content
Section titled “Rich cell content”Cells accept the full paragraph API - mixed styled runs, links, and images - via the lambda overload:
table.Row(row => row .Cell(p => p .Text("Papyra Pro", new TextStyle(FontWeight: FontWeight.Bold)) .Span(" - includes ") .SpanLink("all features", "https://papyra.rocks/features") .Image(logoBytes, ImageFit.Original), style: cellStyle) .Cell("beside"));The paragraph rules apply unchanged: Text/Link start fresh lines, Span/SpanLink continue the current one, and an image is a block - it ends the current line and sits on its own. Rows grow to fit the content, and in PDF each wrapped link line gets its own clickable area, even across page breaks.
Per-format notes:
- PDF and DOCX render runs, links, and images faithfully (DOCX emits native
w:hyperlinkand inline drawings inside the cell). - XLSX keeps one style per worksheet cell: run styling collapses to the cell-level
TextStyle, the cell’s first link becomes the cell hyperlink, and images float as anchored drawings near the cell. - Page fields (
PageNumber()etc.) work inside cells: PDF resolves the cell’s real page, DOCX emits native Word fields, XLSX/HTML drop the page fields and keep the render date as literal text.
Nested tables
Section titled “Nested tables”A cell can carry a table of its own - either as the cell’s only content or below a paragraph:
doc.Table(t => t .Columns(2) .Header(r => r.Cell("Section", th).Cell("Line items", th)) .Row(r => r .Cell("Hardware") .Cell(inner => inner // table-only cell .Columns(2) .Row(ir => ir.Cell("Laptop").Cell("1,200")) .Row(ir => ir.Cell("Dock").Cell("180")))) .Row(r => r .Cell("Services") .Cell(p => p.Text("Support contract:"), // paragraph above the nested table inner => inner .Columns(2) .Row(ir => ir.Cell("Year 1").Cell("450")))));The nested table gets the host cell’s inner width (its own fixed/relative columns distribute inside it), grows the host row to fit, and nests to any depth. Rows containing a nested table stay atomic in pagination: the whole row moves to the next page rather than splitting mid-nest, and a row taller than a page overflows past the boundary instead of losing content. Header/footer rows inside a nested table render in place and never repeat.
Per-format notes:
- PDF draws nested cells and borders exactly where the layout engine placed them.
- DOCX emits a native
w:tblinside the cell (Word edits it like any table). - XLSX flattens inner cells into the worksheet grid - they become ordinary cells on their own rows and columns inside the outer table’s region; the containment is visual only.
- HTML nests a native
<table>inside the<td>with column widths computed from the cell’s inner width.
One syntax note: an empty untyped lambda r.Cell(x => { }) is ambiguous between the paragraph and table overloads - type the parameter (r.Cell((ParagraphBuilder p) => { })) if you ever need an empty cell built from a callback.
Repeating headers and footers
Section titled “Repeating headers and footers”table.Header(row => row.Cell("Item", th).Cell("Total", th)); // repeats on every pagetable.Footer(row => row.Cell("Carried over", tf), TableRowRepeat.Once); // last page onlyWhen a table spans multiple pages, header rows repeat at the top of each page by default (TableRowRepeat.EveryPage); pass TableRowRepeat.Once to show them only once. The same applies to footer rows at the bottom of each page.
Format differences: PDF supports repeating headers and footers. DOCX has no OOXML concept of a repeating table footer, so footers appear once there. XLSX doesn’t paginate at all. See the feature support matrix.
Pagination
Section titled “Pagination”Row splitting is automatic in PDF: rows that don’t fit move to the next page, repeated headers/footers included. You don’t opt in - compose the table and the layout engine handles the breaks. Widow/orphan control (“keep at least n rows together”) is not yet supported - see the roadmap.
A complete example
Section titled “A complete example”The invoice walkthrough builds a realistic line-item table with mixed column types and styled header cells.
© 2026 Stefan Nikolei. Payments are processed by Paddle.com as merchant of record.