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_warningsgetsunresolved 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
| Interpolated | Not interpolated |
|---|---|
paragraph value and span text | an image value (its source) |
table cell text, rich cell text items | link, bookmark, anchor, linkTo |
| header and footer paragraphs | colours, 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}" } ] }
whenis a data path, without${}. Inside arepeat, it resolves against the current item first.- With
equals, the condition is a string comparison. The value is converted to a string first, sotrueand"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:
| Token | Becomes |
|---|---|
#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.