soli-pdf

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 } }
FieldMeaning
header_columnsThe header row: an array of cells. Repeated at the top of every page the table spans.
rowsThe body: an array of rows, each an array of cells.
dataBinds the table to an array in the data: the first row of rows is repeated once per item.
footer_columnsA closing row, also repeated above every page break inside the table.
optionspadding_x, padding_y, stripe, and the header style.
watermarkA 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 rows is 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.
  • data is a dotted path ("order.lines"), and inside a repeat it 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 width share 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_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:

OptionMeaning
header.fillColorBackground of the header row.
header.textColorText colour of the header cells.
header.borderColorBorder 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_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" }
ItemFields
{ "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

FieldDefaultMeaning
widthsharedColumn width in points (read from the header or first row).
fontSize10Text size.
fontWeight"normal"normal or bold.
alignment"left"left, center or right.
valign"middle"Vertical position in the row: top, middle or bottom.
borderSidesno bordersWhich sides get a border line (see below).
borderColorlight greyBorder colour.
fill—Cell background.
colspan1Merge the cell across several columns.
rowspan1Merge 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 borderSides key — no borders at all.
  • A borderSides object — 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:

OptionDefaultMeaning
padding_x0Horizontal padding inside every cell.
padding_y0Vertical 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.