Skip to content

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.

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 1
table.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.

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 grid
new CellStyle(Border: CellBorder.BottomOnly(1f)); // row separators
new CellStyle(Border: new CellBorder(3f, 0f, 1f, 2f)); // left, top, right, bottom
new CellStyle(Border: new CellBorder(1f, style: LineStyle.Dashed)); // dashed grid

Per-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:tcBorders element (single, dashed, or dotted) 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) or mediumDashed (above - there is no thick dashed), and dotted is always dotted regardless of width. On merged ranges the border sides are applied to the range’s perimeter cells only.
  • HTML emits the CSS border-style keyword. Tables use border-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).

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:hyperlink and 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.

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:tbl inside 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.

table.Header(row => row.Cell("Item", th).Cell("Total", th)); // repeats on every page
table.Footer(row => row.Cell("Carried over", tf), TableRowRepeat.Once); // last page only

When 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.

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.

The invoice walkthrough builds a realistic line-item table with mixed column types and styled header cells.