Tables
Tables carry most of a business document: line items, meta grids, totals, even the coloured band at the top of an invoice. This page covers data-bound and literal tables, headers and footers that repeat across pages, cell types and styling, and merged cells.
#A first table
A table has an optional header row (header_columns), body rows, and table-wide options. Each cell is an object; its width is in points.
{ "type": "table",
"header_columns": [
{ "text": "DESCRIPTION", "width": 280, "fontWeight": "bold", "borderSides": { "top": "false", "left": "false", "right": "false" } },
{ "text": "AMOUNT", "width": 201, "fontWeight": "bold", "alignment": "right", "borderSides": { "top": "false", "left": "false", "right": "false" } }
],
"rows": [
[ { "text": "Brand identity system" }, { "text": "€2,400", "alignment": "right" } ],
[ { "text": "On-site installation" }, { "text": "€520", "alignment": "right" } ]
],
"options": { "padding_x": 6, "padding_y": 7 } }
| Field | Meaning |
|---|---|
header_columns | The header row: an array of cells. Repeated at the top of every page the table spans. |
rows | The body: an array of rows, each an array of cells. |
data | Binds the table to an array in the data: the first row of rows is repeated once per item. |
footer_columns | A closing row, also repeated above every page break inside the table. |
options | padding_x, padding_y, stripe, and the header style. |
watermark | A stamp drawn over this table only. |
#Data-bound tables
Set data to the path of an array, and write one template row: it is repeated for each item, and ${field} placeholders in it read from that item first, then from the root of the data document.
{ "type": "table", "data": "items",
"header_columns": [
{ "text": "DESCRIPTION", "width": 280, "fontSize": 9, "fontWeight": "bold", "borderSides": { "top": "false", "left": "false", "right": "false" } },
{ "text": "QTY", "width": 50, "fontSize": 9, "fontWeight": "bold", "alignment": "center", "borderSides": { "top": "false", "left": "false", "right": "false" } },
{ "text": "RATE", "width": 70, "fontSize": 9, "fontWeight": "bold", "alignment": "right", "borderSides": { "top": "false", "left": "false", "right": "false" } },
{ "text": "AMOUNT", "width": 81, "fontSize": 9, "fontWeight": "bold", "alignment": "right", "borderSides": { "top": "false", "left": "false", "right": "false" } }
],
"rows": [ [
{ "text": "${name}", "fontSize": 10, "borderSides": { "top": "false", "left": "false", "right": "false" }, "borderColor": "EAECEF" },
{ "text": "${qty}", "fontSize": 10, "alignment": "center", "borderSides": { "top": "false", "left": "false", "right": "false" }, "borderColor": "EAECEF" },
{ "text": "${rate}", "fontSize": 10, "alignment": "right", "borderSides": { "top": "false", "left": "false", "right": "false" }, "borderColor": "EAECEF" },
{ "text": "${amount}", "fontSize": 10, "alignment": "right", "borderSides": { "top": "false", "left": "false", "right": "false" }, "borderColor": "EAECEF" }
] ],
"options": {
"header": { "fillColor": "E6F2F1", "textColor": "0F4F4A", "borderColor": "0F766E" },
"padding_x": 6, "padding_y": 7
} }
{
"items": [
{ "name": "Brand identity system", "qty": 1, "rate": "€2,400", "amount": "€2,400" },
{ "name": "Signage fabrication (6 panels)", "qty": 6, "rate": "€180", "amount": "€1,080" },
{ "name": "On-site installation", "qty": 1, "rate": "€520", "amount": "€520" }
]
}
That is the items table of the starter invoice.
- Only the first row of
rowsis used as the template; any other row is ignored. - A missing or empty array renders no body rows — the header, if any, is still drawn.
datais a dotted path ("order.lines"), and inside arepeatit is looked up on the current item first, which is how grouped line items are built.- Format numbers in Ruby before rendering (
"€2,400","1 080,00"): the renderer prints values as they are.
#Column widths
Column widths come from the header row — or, when there is no header, from the first body row. Widths set on later rows are ignored.
- Columns without a
widthshare the space left over. - If the widths add up to more than the available width, they are scaled down to fit.
- If they add up to less, the table is simply narrower, starting at the cursor.
With the default margins the content area of an A4 page is 481 pt wide, so full-width tables have widths that sum to 481 (a 301 + 100 + 80 totals stack, a 300 + 181 two-column header…). With other margins or page sizes, adjust — invoice_minimal uses "margins": 64 and sums to 467.
#Header and footer rows
#Header
header_columns is drawn at the top of the table and repeated at the top of every page the table runs onto. Its look comes from options.header:
| Option | Meaning |
|---|---|
header.fillColor | Background of the header row. |
header.textColor | Text colour of the header cells. |
header.borderColor | Border colour of header cells that have borderSides and no borderColor of their own. |
Body text is always black: colour in a body comes from fill, stripe and borders. White-on-colour text is only possible in the header row — or with an overlay.
A table with a header and no rows is a handy coloured band: the starter invoice opens with one.
{ "type": "table",
"header_columns": [
{ "content": [
{ "type": "text", "value": "${company.name}", "fontSize": 19, "fontWeight": "bold" },
{ "type": "text", "value": "${company.tagline}", "fontSize": 9 } ],
"width": 300, "borderSides": { "top": "false", "bottom": "false", "left": "false", "right": "false" } },
{ "text": "INVOICE", "width": 181, "fontSize": 26, "fontWeight": "bold", "alignment": "right",
"borderSides": { "top": "false", "bottom": "false", "left": "false", "right": "false" } }
],
"rows": [],
"options": { "header": { "fillColor": "0F766E", "textColor": "FFFFFF" }, "padding_x": 18, "padding_y": 18 } }
#Footer (carried forward)
footer_columns is a closing row drawn after the last body row. When the table breaks across pages, it is also drawn at the bottom of every page, just above the break — the classic "carried forward" band of a long statement.
{ "type": "table", "data": "lines",
"header_columns": [
{ "text": "DATE", "width": 90, "fontWeight": "bold" },
{ "text": "DESCRIPTION", "width": 291, "fontWeight": "bold" },
{ "text": "AMOUNT", "width": 100, "fontWeight": "bold", "alignment": "right" }
],
"rows": [ [ { "text": "${date}" }, { "text": "${desc}" }, { "text": "${amount}", "alignment": "right" } ] ],
"footer_columns": [
{ "text": "Balance", "colspan": 2, "alignment": "right", "fontWeight": "bold", "fill": "F1F5F9" },
{ "text": "${totals.balance}", "alignment": "right", "fontWeight": "bold", "fill": "F1F5F9" }
],
"options": { "stripe": "F8FAFC", "padding_x": 6, "padding_y": 4 } }
- Footer cells read from the root of the data, not from a row item.
- The footer is the same on every page: the renderer does not compute running subtotals. Print a total you computed in Ruby, or a label such as "continued overleaf".
- The height of the footer row is reserved on every page, so the band always fits above the break.
#Cells
#Text and rich cells
A text cell has a text string. It wraps within the column, and ${…} placeholders are filled in.
{ "text": "${name}", "width": 280, "fontSize": 10, "alignment": "left" }
A rich cell has a content array instead, stacking lines of text — each with its own size and weight — and images, top to bottom:
{ "content": [
{ "type": "text", "value": "BILLED TO", "fontSize": 9, "fontWeight": "bold" },
{ "type": "text", "value": "${customer.name}", "fontSize": 12, "fontWeight": "bold" },
{ "type": "text", "value": "${customer.address}", "fontSize": 10 },
{ "type": "image", "value": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='80' height='20'><rect width='80' height='20' fill='%230f766e'/></svg>", "width": 80 }
],
"width": 300, "alignment": "left" }
| Item | Fields |
|---|---|
{ "type": "text" } | value (interpolated), fontSize, fontWeight |
{ "type": "image" } | value, width, height — the same sources and sizing as the image element |
Rich-cell text has no colour of its own: it is black in the body and header.textColor in the header row.
#Cell styling
| Field | Default | Meaning |
|---|---|---|
width | shared | Column width in points (read from the header or first row). |
fontSize | 10 | Text size. |
fontWeight | "normal" | normal or bold. |
alignment | "left" | left, center or right. |
valign | "middle" | Vertical position in the row: top, middle or bottom. |
borderSides | no borders | Which sides get a border line (see below). |
borderColor | light grey | Border colour. |
fill | — | Cell background. |
colspan | 1 | Merge the cell across several columns. |
rowspan | 1 | Merge the cell down across several rows (literal rows only). |
link | — | External URL: the cell text becomes a clickable link. Text cells only. |
#Borders
borderSides is the one rule to remember:
- No
borderSideskey — no borders at all. - A
borderSidesobject — the sides it lists are on or off as given, and every side it omits is on.
So { "top": "false", "left": "false", "right": "false" } leaves just a bottom rule, and {} draws all four sides. Values can be booleans or the strings "true" and "false". Borders are 0.5 pt, in borderColor.
{ "text": "Amount due", "fontWeight": "bold", "borderSides": { "top": "true", "left": "false", "right": "false", "bottom": "false" }, "borderColor": "0F766E" }
#Padding and stripes
Table-wide options:
| Option | Default | Meaning |
|---|---|---|
padding_x | 0 | Horizontal padding inside every cell. |
padding_y | 0 | Vertical padding inside every cell. |
stripe | — | Zebra fill behind every second body row (the 2nd, 4th, …). The header is never striped. |
header | — | fillColor, textColor, borderColor of the header row. |
The padding defaults to zero, so text touches the borders until you set it; 6 / 4 is a comfortable start. Note the mixed naming: padding_x and padding_y are snake_case while fillColor and the cell fields are camelCase.
A cell's fill is painted over the stripe and the header band, so it is the way to highlight one cell or one row.
#Merged cells
#Colspan
colspan merges a cell across several column slots; the cells after it shift right. Define the column widths on the header (or on a first row without spans) and use colspan further down — typically for a totals row.
{ "type": "table",
"header_columns": [ { "text": "Item", "width": 281 }, { "text": "Qty", "width": 100 }, { "text": "Amount", "width": 100 } ],
"rows": [
[ { "text": "Consulting" }, { "text": "3" }, { "text": "1,800.00", "alignment": "right" } ],
[ { "text": "TOTAL DUE", "colspan": 2, "alignment": "right", "fontWeight": "bold" },
{ "text": "2,160.00 EUR", "alignment": "right", "fontWeight": "bold", "fill": "fef3c7" } ]
],
"options": { "padding_x": 6, "padding_y": 5 } }
#Rowspan
rowspan merges a cell downwards. The rows beneath it supply one cell fewer for each slot it covers, and the merged cell is drawn once, tall enough for all of its rows. Use valign to position its text.
{ "type": "table",
"header_columns": [
{ "text": "Region", "width": 120, "fontWeight": "bold", "borderSides": {} },
{ "text": "Quarter", "width": 120, "fontWeight": "bold", "borderSides": {} },
{ "text": "Revenue", "width": 120, "fontWeight": "bold", "alignment": "right", "borderSides": {} }
],
"rows": [
[ { "text": "North", "rowspan": 2, "valign": "middle", "fill": "E6F2F1", "borderSides": {} },
{ "text": "Q1", "borderSides": {} }, { "text": "12,400", "alignment": "right", "borderSides": {} } ],
[ { "text": "Q2", "borderSides": {} }, { "text": "15,100", "alignment": "right", "borderSides": {} } ],
[ { "text": "South", "borderSides": {} }, { "text": "Q1", "borderSides": {} }, { "text": "9,800", "alignment": "right", "borderSides": {} } ]
],
"options": { "padding_x": 6, "padding_y": 5 } }
rowspan only applies to literal rows. A data-bound table repeats one template row, so there is nothing to span.
#Per-table watermark
A table can carry its own watermark, with the same fields as the document watermark. It is stamped over that table's box, always on top of it — mark a single table PAID or VOID without touching the rest of the page. front and pages are ignored here: the stamp follows the table.
{ "type": "table",
"header_columns": [ { "text": "Invoice", "width": 240 }, { "text": "Amount", "width": 241, "alignment": "right" } ],
"rows": [ [ { "text": "AN-2025-0042" }, { "text": "€4,800", "alignment": "right" } ] ],
"watermark": { "text": "PAID", "fontSize": 48, "color": "e8c4c4" },
"options": { "padding_x": 6, "padding_y": 12 } }
When the table spans several pages, the stamp is placed on the page where it starts.
#Rows are still tables
There is no grid system: two-column headers, key/value meta grids and right-aligned totals stacks are all tables with their borders turned off. The pattern is always the same — cells whose widths add up to the content width, and borderSides set to "false" on every side you do not want.
A totals stack, pushed to the right by an empty 301 pt cell (301 + 100 + 80 = 481):
{ "type": "table",
"rows": [
[ { "text": "", "width": 301 },
{ "text": "Subtotal", "width": 100, "alignment": "right" },
{ "text": "${totals.subtotal}", "width": 80, "alignment": "right" } ],
[ { "text": "" },
{ "text": "VAT (20%)", "alignment": "right" },
{ "text": "${totals.vat}", "alignment": "right" } ],
[ { "text": "" },
{ "text": "Amount due", "fontSize": 14, "fontWeight": "bold", "alignment": "right",
"borderSides": { "top": "true", "left": "false", "right": "false", "bottom": "false" }, "borderColor": "0F766E" },
{ "text": "${totals.due}", "fontSize": 14, "fontWeight": "bold", "alignment": "right",
"borderSides": { "top": "true", "left": "false", "right": "false", "bottom": "false" }, "borderColor": "0F766E" } ]
],
"options": { "padding_x": 6, "padding_y": 6 } }
A meta grid — billed-to on the left, invoice details on the right — is one row of two rich cells:
{ "type": "table",
"rows": [ [
{ "content": [
{ "type": "text", "value": "BILLED TO", "fontSize": 9, "fontWeight": "bold" },
{ "type": "text", "value": "${customer.name}", "fontSize": 12, "fontWeight": "bold" },
{ "type": "text", "value": "${customer.city}", "fontSize": 10 } ],
"width": 300, "alignment": "left" },
{ "content": [
{ "type": "text", "value": "Invoice no. ${invoice.number}", "fontSize": 10, "fontWeight": "bold" },
{ "type": "text", "value": "Issued ${invoice.issued}", "fontSize": 10 },
{ "type": "text", "value": "Due ${invoice.due}", "fontSize": 10 } ],
"width": 181, "alignment": "right" }
] ],
"options": { "padding_x": 0, "padding_y": 2 } }
Remember that cells without borderSides have no borders at all, so borderless layout tables need no borderSides key. The samples spell out "false" on every side anyway, which makes the intent explicit and keeps a side from appearing if someone adds a borderSides later.
More in Recipes, and in the statement, compliant invoice and quote with options examples. Try any of these in the playground.