Skip to main content

Tables

Tables organize content into rows and columns. Each cell accepts any content node — paragraphs, headings, lists, columns, images, SVG, barcodes, even nested tables — and can combine a text label with a co-located block (for example a caption above an image or nested table), following HTML5 <td> flow-content semantics. Tables can also be populated dynamically from data.

The fixture below walks through every common edge case in one place — auto-widths, proportional widths, float weights, cell-count vs. widths-length mismatches, variable row lengths, empty cells, colspan / rowspan, nesting, and long-content wrapping. Open it before reading the rest if you just want to see how tables behave.

Basic structure​

{
"table": {
"widths": [3, 2, 2],
"rows": [
[{ "p": "Column 1" }, { "p": "Column 2" }, { "p": "Column 3" }],
[{ "p": "Row 2, Col 1" }, { "p": "Row 2, Col 2" }, { "p": "Row 2, Col 3" }]
]
}
}

Properties​

PropertyTypeRequiredDescription
widthsnumber[]YesRelative column width proportions
rowsarray[]YesArray of rows, where each row is an array of cell content elements

Column widths​

The widths array defines relative column proportions, not absolute pixel values. The available page width is divided according to these ratios.

"widths": [1, 1, 1]

Three equal-width columns.

"widths": [3, 1, 1]

The first column is three times wider than the second and third.

"widths": [4, 1, 1, 1]

Four columns where the first takes up about 57% of the width.

Rows​

Each row is an array of content elements. The number of elements in each row should match the length of the widths array.

Cell types​

Each cell in a row is a content element. Tables support three cell types for semantic clarity:

TypePurposeExample
thTable header cell — typically bold with a dark background{ "th": "Product" }
tdTable data cell — regular content{ "td": "$12,500" }
pGeneral paragraph — also works as a cell, most flexible{ "p": "[b]Bold text[/b]" }

All three types accept the same style property and inline formatting tags. Use th and td for semantic structure, and p when you need Shortcodes like images or barcodes inside cells.

Header cells (th)​

[
{ "th": "Product", "style": "th" },
{ "th": "Qty", "style": "th" },
{ "th": "Price", "style": "th" }
]

Data cells (td)​

[
{ "td": "Cloud Platform License", "style": "td" },
{ "td": "5", "style": "td" },
{ "td": "$2,400", "style": "td" }
]

Shortcodes in cells​

All cell types (th, td, p, h1–h5) support the full range of Shortcodes — images, barcodes, bold, color, etc.:

[
{ "th": "[image, /assets/logo.png, 60|20] Company", "style": "th" },
{ "td": "[barcode, qrcode, SF-20260330, 50|50]", "style": "td" },
{ "p": "[color, #27AE60][b]Active[/b][/color]" }
]

List cells​

[
{ "p": "Features" },
{
"ul": {
"li": [
{ "p": "Real-time tracking" },
{ "p": "Multi-carrier support" }
]
}
}
]

Table style​

Apply a named style to the table container itself (separate from cell styles). This controls table-level properties like border:

{
"table": {
"style": "borderless",
"widths": [1, 1],
"rows": [...]
}
}

With the style defined as:

"borderless": { "border": "none" }

Styling table cells​

Apply named styles to individual cell elements for formatting control.

Header row styling​

{
"document": {
"styles": {
"tableHeader": {
"fontSize": 10,
"fontWeight": "bold",
"color": "#FFFFFF",
"backgroundColor": "#2D3A4A",
"padding": 6
},
"tableCell": {
"fontSize": 9,
"color": "#333333",
"padding": 4,
"borderBottom": "0.5pt solid #E0E0E0"
}
},
"content": [
{
"table": {
"widths": [3, 2, 2],
"rows": [
[
{ "p": "Route", "style": "tableHeader" },
{ "p": "Carrier", "style": "tableHeader" },
{ "p": "Transit Time", "style": "tableHeader" }
],
[
{ "p": "Dakar - Accra", "style": "tableCell" },
{ "p": "ShipForge Express", "style": "tableCell" },
{ "p": "3-5 days", "style": "tableCell" }
],
[
{ "p": "Lagos - Nairobi", "style": "tableCell" },
{ "p": "Pan-Africa Freight", "style": "tableCell" },
{ "p": "5-7 days", "style": "tableCell" }
]
]
}
}
]
}
}

Spanning cells — colspan and rowspan​

Not to be confused with [col, N]

colspan merges multiple table columns into one cell — same as HTML's <td colspan="3">. [col, N] is the Shortcode that splits one paragraph into N weighted horizontal segments inside a single cell (or anywhere in body text). Inverse operations. See Layout Shortcodes → Column splits for the segment splitter. The §11 example in the edge-case gallery shows the two used together — colspan: 4 merges a full row, and [col, 3][col, 1] splits its interior into a label and a right-flushed status badge.

These follow the HTML table model: colspan and rowspan are attributes on the cell itself, not entries in a style block. They control structure (how many columns or rows the cell occupies), so they live on the cell next to the cell's content — the same place HTML puts them on <td> and <th>.

{ "p": "FY Totals", "style": "totals", "colspan": 3 }

Default is 1 (no span). Anywhere outside a table cell context they're inert — putting colspan on a top-level paragraph has no effect.

Colspan — merge across columns​

{
"table": {
"widths": [1, 1, 1, 1],
"rows": [
[
{ "p": "Region", "style": "th" },
{ "p": "Q1", "style": "th" },
{ "p": "Q2", "style": "th" },
{ "p": "Q3", "style": "th" }
],
[
{ "p": "North", "style": "td" },
{ "p": "182", "style": "tdNum" },
{ "p": "201", "style": "tdNum" },
{ "p": "218", "style": "tdNum" }
],
[
{ "p": "FY Totals", "style": "tdTotal", "colspan": 3 },
{ "p": "601", "style": "tdNumTotal" }
]
]
}
}

The totals label occupies the first three columns; the numeric total fills the fourth.

Rowspan — merge across rows​

When a cell spans multiple rows, subsequent rows don't repeat that column. They declare only the remaining cells in the row; the engine slots them into the columns to the right of the merged cell.

{
"table": {
"widths": [1, 3],
"rows": [
[{ "p": "Status", "style": "th" }, { "p": "Description", "style": "th" }],
[{ "p": "ACTIVE", "style": "tdStatus", "rowspan": 3 }, { "p": "First entry — top of the merged group.", "style": "td" }],
[ { "p": "Second entry — same status, no repeat label.", "style": "td" }],
[ { "p": "Third entry — closes out the merged group.", "style": "td" }]
]
}
}

In DOCX the engine auto-inserts the vertical-merge continuation cells underneath the spanning cell, so the resulting Word document opens with a clean merge — you don't have to author placeholder cells yourself.

Both at once​

A header banner that's both wide and tall:

{ "p": "QUARTERLY REVIEW · Q1 2026", "style": "banner", "colspan": 4, "rowspan": 2 }

Why not in the style block?​

colspan / rowspan are structural — they decide the cell's grid footprint, not how the cell looks. Putting them on the cell keeps the style block focused on visual styling (and lets a single style be reused for both spanning and non-spanning cells without duplication). This mirrors HTML, where colspan="3" is an attribute on <td>, not a CSS property.

Nested tables​

A cell can contain another table, allowing for complex layouts.

{
"table": {
"widths": [1, 1],
"rows": [
[
{ "p": "[b]Sender[/b]" },
{ "p": "[b]Recipient[/b]" }
],
[
{
"table": {
"widths": [1, 2],
"rows": [
[{ "p": "Name:" }, { "p": "ShipForge Ltd" }],
[{ "p": "City:" }, { "p": "London, UK" }]
]
}
},
{
"table": {
"widths": [1, 2],
"rows": [
[{ "p": "Name:" }, { "p": "Meridian Logistics" }],
[{ "p": "City:" }, { "p": "Accra, Ghana" }]
]
}
}
]
]
}
}

Text label plus a block​

A cell can hold both a text label and a co-located block — the label renders first, the block directly below it. This works with any block: a nested table, a columns layout, a ul/ol, an image, or svg shapes.

{
"table": {
"widths": [1, 2],
"rows": [
[
{ "td": "Regional split", "style": "th" },
{
"p": "Revenue by region:",
"table": {
"widths": [2, 1],
"rows": [
[{ "td": "North" }, { "td": "$4,200" }],
[{ "td": "South" }, { "td": "$3,850" }]
]
}
}
],
[
{ "td": "Follow-ups", "style": "th" },
{
"p": "Before close:",
"ul": {
"li": [
{ "p": "Confirm Q4 targets" },
{ "p": "Send revised deck" }
]
}
}
]
]
}
}

Swap the nested table or ul for columns, an image, or an svg block to pair any block with a label. Containers nest to substantial depth — a table inside a columns inside a list item, and so on.

Using inline formatting in cells​

Cells support the full range of inline formatting tags.

[
{ "p": "[color, #27AE60][b]Delivered[/b][/color]" },
{ "p": "[color, #E74C3C][b]Delayed[/b][/color]" },
{ "p": "[color, #F39C12][b]In Transit[/b][/color]" }
]

Data-driven tables​

Tables can be populated dynamically from data using source and map. See Data Source for full details.

Basic Data Binding​

{
"table": {
"source": "$data.items",
"map": ["description", "qty", "amount"],
"widths": [3, 1, 1],
"rows": [
[
{ "th": "Description", "style": "th" },
{ "th": "Qty", "style": "th" },
{ "th": "Amount", "style": "th" }
]
]
}
}

The source path points to an array in your data object. The map array specifies which keys from each object become cell values, mapped left-to-right to columns. The header row is preserved; data rows are appended after it.

$item.* templating in map​

Instead of simple key names, map entries can be template strings that compose values from multiple fields:

{
"table": {
"source": "$data.lineItems",
"map": ["$item.sku", "$item.description", "$item.qty × $item.unit", "CAD $item.subtotal"],
"widths": [2, 3, 2, 2],
"rows": [
[
{ "th": "SKU", "style": "th" },
{ "th": "Description", "style": "th" },
{ "th": "Quantity", "style": "th" },
{ "th": "Subtotal", "style": "th" }
]
]
}
}

The third column composes "$item.qty × $item.unit" into values like "12 × box". The fourth column prepends a currency prefix: "CAD 1,450.00".

Complete example: expense report​

Template:

{
"document": {
"styles": {
"title": { "fontSize": 16, "fontWeight": "bold" },
"th": {
"fontSize": 9, "fontWeight": "bold", "color": "#FFFFFF", "backgroundColor": "#1A1A2E",
"paddingTop": 5, "paddingRight": 8, "paddingBottom": 5, "paddingLeft": 8
},
"td": {
"fontSize": 9, "color": "#333333",
"paddingTop": 4, "paddingRight": 8, "paddingBottom": 4, "paddingLeft": 8,
"borderBottom": "0.5pt solid #E0E0E0"
},
"total": { "fontSize": 11, "fontWeight": "bold", "marginTop": 8 }
},
"content": [
{ "p": "Expense Report — $data.report.period", "style": "title" },
{ "p": "Employee: $data.employee.name | Department: $data.employee.department" },
{
"table": {
"source": "$data.expenses",
"map": ["$item.date", "$item.description", "$item.category", "$data.currency $item.amount"],
"widths": [2, 3, 2, 2],
"rows": [
[
{ "th": "Date", "style": "th" },
{ "th": "Description", "style": "th" },
{ "th": "Category", "style": "th" },
{ "th": "Amount", "style": "th" }
]
]
}
},
{ "p": "Total: $data.currency $data.total", "style": "total" }
]
}
}

Data:

{
"report": { "period": "Q1 2026" },
"employee": { "name": "Sarah Mitchell", "department": "Engineering" },
"currency": "USD",
"expenses": [
{ "date": "Jan 15", "description": "Conference registration", "category": "Training", "amount": "850.00" },
{ "date": "Jan 16", "description": "Flight YYZ → SFO", "category": "Travel", "amount": "1,240.00" },
{ "date": "Jan 17", "description": "Hotel (3 nights)", "category": "Accommodation", "amount": "960.00" },
{ "date": "Jan 18", "description": "Team dinner", "category": "Meals", "amount": "185.00" }
],
"total": "3,235.00"
}

Expected result:

DateDescriptionCategoryAmount
Jan 15Conference registrationTrainingUSD 850.00
Jan 16Flight YYZ → SFOTravelUSD 1,240.00
Jan 17Hotel (3 nights)AccommodationUSD 960.00
Jan 18Team dinnerMealsUSD 185.00

Note how "$data.currency $item.amount" in the map mixes a document-level $data value with per-row $item values — the currency prefix comes from the root data, while the amount comes from each expense row.