soli-pdf

Data binding

The template stays fixed and the data changes on every render. This page covers how the two meet: placeholders, repeated blocks, conditions, and page-number tokens.

#The data document

data: accepts a Hash (or an Array, or a JSON String). It can be wrapped in a data key or given bare. These two calls are equivalent:

Soli::PDF.render(template: template, data: { 'invoice' => { 'number' => 'F-42' } })
Soli::PDF.render(template: template, data: { 'data' => { 'invoice' => { 'number' => 'F-42' } } })

Values are printed as they are. Format numbers, money and dates in Ruby before rendering (number_to_currency, I18n.l, format('%.2f', x)). The template has no formatting functions, and that keeps presentation rules in code you can test. The one exception is charts, which need real numbers.

#Placeholders

${path} is replaced by the value at that dotted path:

{ "type": "paragraph", "value": "Invoice ${invoice.number}, due ${invoice.due_date}" }
  • Missing paths render empty. The render still succeeds, and Soli::PDF.last_warnings gets unresolved placeholder: ${invoice.due_date}.
  • Numbers and booleans are printed in their JSON form: 42, 18.5, true.
  • A literal ${: double the dollar sign. $${invoice.number} prints ${invoice.number} unchanged, which is useful for documentation or code samples.

#What gets interpolated

InterpolatedNot interpolated
paragraph value and span textan image value (its source)
table cell text, rich cell text itemslink, bookmark, anchor, linkTo
header and footer paragraphscolours, sizes and other options
qr fields, barcode value
if / unless when paths

For a per-document link or image, set the value on the template Hash in Ruby before rendering:

template = PdfTemplates::INVOICE.deep_dup     # the frozen original stays untouched
template['content'] << { 'type' => 'paragraph', 'value' => 'Pay online',
                         'options' => { 'link' => "https://pay.example/#{invoice.number}" } }

#Data-bound tables

A table with "data": "items" repeats its template row once per entry of the items array. Inside the row, ${name} first looks for name on the current item, then on the document root. That's how a row can print both ${qty} and ${invoice.currency}. See Tables.

#repeat

repeat lays out a block of elements once per item. It is the block-level counterpart of a data-bound table row: use it for anything that isn't a grid.

{ "type": "repeat", "data": "invoices", "content": [
  { "type": "paragraph", "spans": [
    { "text": "${number}", "fontWeight": "bold" },
    { "text": " — ${customer}" }
  ] },
  { "type": "hr", "color": "dddddd" }
] }
'invoices' => [
  { 'number' => 'F-40', 'customer' => 'Maison Lumière' },
  { 'number' => 'F-41', 'customer' => 'Café Lumière' }
]

A missing or empty array renders nothing. Placeholders resolve against the item first and the root second, as in tables.

#Nesting and grouping

Repeats nest. An inner repeat, table or chart resolves its own data against the current item first. Grouped line items with subtotals need no flattening:

{ "type": "repeat", "data": "sections", "content": [
  { "type": "paragraph", "value": "${title}", "options": { "fontWeight": "bold", "spacing": 4 } },
  { "type": "table", "data": "lines", "rows": [ [
    { "text": "${name}", "width": 361 },
    { "text": "${amount}", "width": 120, "alignment": "right" }
  ] ] },
  { "type": "paragraph", "value": "Subtotal ${subtotal}",
    "options": { "alignment": "right", "fontWeight": "bold", "spacing": 12 } }
] }
'sections' => [
  { 'title' => '01  Structural work', 'subtotal' => '1,402.00',
    'lines' => [{ 'name' => 'Demolition of partition wall', 'amount' => '850.00' },
                { 'name' => 'Floor levelling and screed', 'amount' => '552.00' }] },
  { 'title' => '02  Plumbing', 'subtotal' => '1,100.00',
    'lines' => [{ 'name' => 'Hot and cold supply lines', 'amount' => '620.00' },
                { 'name' => 'Grease trap installation', 'amount' => '480.00' }] }
]

The quote by trade section is built exactly like this.

#if / unless

Render a block only when a condition holds (if) or doesn't (unless). else holds the other branch.

{ "type": "if", "when": "invoice.paid", "equals": "true",
  "content": [ { "type": "paragraph", "value": "PAID IN FULL", "options": { "color": "15803d", "fontWeight": "bold" } } ],
  "else":    [ { "type": "paragraph", "value": "Balance due: ${invoice.balance}" } ] }
  • when is a data path, without ${}. Inside a repeat, it resolves against the current item first.
  • With equals, the condition is a string comparison. The value is converted to a string first, so true and "true" both match "equals": "true".
  • Without equals, the condition tests truthiness. A value is falsy when it is missing, null, false, 0, an empty string or an empty array. Everything else is truthy.
{ "type": "unless", "when": "items",
  "content": [ { "type": "paragraph", "value": "No line items this period." } ] }

Conditions nest and can sit inside a repeat. The conditional content example uses them to show a back-order note only for the items that need one.

#Page tokens

These tokens are replaced after pagination, so they are always correct:

TokenBecomes
#PAGE#The current page number.
#PAGES# (alias #TOTAL_PAGE#)The total number of pages.
#PAGE_OF:anchor#The page on which the paragraph with "anchor": "anchor" landed.

They work in paragraph values in the header, the footer and the body. In a spans paragraph they are printed literally.

{ "type": "paragraph", "value": "${company.name} · Statement ${statement.number} · Page #PAGE# of #PAGES#",
  "options": { "alignment": "center", "fontSize": 8 } }

#A table of contents with page numbers

Give each heading an anchor, and link to it from the contents with linkTo. #PAGE_OF:…# prints where it landed:

[
  { "type": "paragraph", "value": "Charts ........................ #PAGE_OF:sec-charts#",
    "options": { "linkTo": "sec-charts" } },
  { "type": "page_break" },
  { "type": "paragraph", "value": "Charts",
    "options": { "fontSize": 18, "fontWeight": "bold", "anchor": "sec-charts",
                 "bookmark": "Charts", "bookmarkLevel": 1 } }
]

The entry is clickable, and the page number is correct even when content before the section grows. The complete tour opens with a table of contents built this way. An unknown anchor renders empty, with a warning.