Tables
Tables organize content into rows and columns. Each cell accepts any content node — paragraphs, headings, lists, columns, images, canvas, barcodes, even nested tables — following HTML5 <td> flow-content semantics.
Edge-case gallery
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.
- Output
- Template
- Data
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
| Property | Type | Required | Description |
|---|---|---|---|
widths | number[] | Yes | Relative column width proportions |
rows | array[] | Yes | Array 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:
| Type | Purpose | Example |
|---|---|---|
th | Table header cell — typically bold with a dark background | { "th": "Product" } |
td | Table data cell — regular content | { "td": "$12,500" } |
p | General 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 inline tags 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" }
]
Inline tags in cells
All cell types (th, td, p, h1–h5) support the full range of inline tags — 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": "[fontcolor, #27AE60][b]Active[/b][/fontcolor]" }
]
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
:::tip 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 inline-tag that splits one paragraph into N weighted horizontal segments inside a single cell (or anywhere in body text). Inverse operations.
See Layout inline tags → 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" }]
]
}
}
]
]
}
}
Using inline formatting in cells
Cells support the full range of inline formatting tags.
[
{ "p": "[fontcolor, #27AE60][b]Delivered[/b][/fontcolor]" },
{ "p": "[fontcolor, #E74C3C][b]Delayed[/b][/fontcolor]" },
{ "p": "[fontcolor, #F39C12][b]In Transit[/b][/fontcolor]" }
]
Data-driven tables
Tables can be populated dynamically from data using source and map. See Data Source for full details.
Basic data injection
{
"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, "bold": true },
"th": {
"fontSize": 9, "bold": true, "fontColor": "#FFFFFF", "backgroundColor": "#1A1A2E",
"paddingTop": 5, "paddingRight": 8, "paddingBottom": 5, "paddingLeft": 8
},
"td": {
"fontSize": 9, "fontColor": "#333333",
"paddingTop": 4, "paddingRight": 8, "paddingBottom": 4, "paddingLeft": 8,
"borderBottom": { "width": 0.5, "color": "#E0E0E0", "type": "SOLID" }
},
"total": { "fontSize": 11, "bold": true, "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:
| Date | Description | Category | Amount |
|---|---|---|---|
| Jan 15 | Conference registration | Training | USD 850.00 |
| Jan 16 | Flight YYZ → SFO | Travel | USD 1,240.00 |
| Jan 17 | Hotel (3 nights) | Accommodation | USD 960.00 |
| Jan 18 | Team dinner | Meals | USD 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.