Template syntax

Variables, loops and conditions in a Tuix PDF template.

Templates are rendered with Liquid. The data object of a render request provides the variables, and each one is addressed by its plain name.

Variables

<h1>Invoice for {{ customerName }}</h1>
{ "customerName": "Jane Doe" }

Nested objects are addressed with dots:

<p>{{ customer.name }}, {{ customer.city }}</p>
{ "customer": { "name": "Jane Doe", "city": "Zürich" } }

No leading dot. {{ .customerName }} is not valid Liquid: the template fails to parse and the request answers 500. If you are migrating a template that used the old Go syntax, remove the dot from every placeholder.

Missing values

A variable that is not in data renders as an empty string, the request still succeeds. Same for null. Guard optional blocks explicitly if an empty line would look wrong:

{% if discount %}<p>Discount: {{ discount }}</p>{% endif %}

Conditions

{% if paid %}
    <span class="badge">PAID</span>
{% else %}
    <span class="badge">OPEN</span>
{% endif %}

Only null and false are false. Unlike most languages, 0, "" and an empty array are true in Liquid, so {% if count %} runs the block even when count is 0. Compare explicitly when that matters: {% if count > 0 %} or {% if name != "" %}.

Other comparisons work as expected, and and / or combine them:

{% if total > 100 and currency == "CHF" %}<p>Free shipping</p>{% endif %}

Loops

for iterates over an array, and you name the loop variable yourself:

<table>
    {% for item in items %}
    <tr>
        <td>{{ item.name }}</td>
        <td>{{ item.price }}</td>
    </tr>
    {% endfor %}
</table>
{
    "items": [
        { "name": "Consulting", "price": "200.00" },
        { "name": "Hosting", "price": "49.00" }
    ]
}

Top-level variables stay visible inside the loop, no special prefix needed:

{% for item in items %}
<tr>
    <td>{{ item.name }}</td>
    <td>{{ currency }} {{ item.price }}</td>
</tr>
{% endfor %}

forloop.index is the position of the current entry starting at 1, and {{ items.size }} gives the number of entries.

Filters

A filter transforms a value with |, and filters can be chained:

<p>{{ customerName | upcase }}</p>
<p>{{ description | truncate: 80 }}</p>
<p>{{ invoiceDate | date: "%d.%m.%Y" }}</p>

The full list is in the Liquid filter reference.

Formatting numbers

JSON numbers are rendered as they come in, so 249.5 prints as 249.5.

round: 2 caps the decimals but does not pad them: 249.5 still prints as 249.5, not 249.50. There is no filter that guarantees two decimal places, so pass amounts you want in a fixed format as pre-formatted strings in data ("249.50").

HTML in values

Values are not escaped. A data value of "<b>hi</b>" renders as bold text, and any markup you pass through reaches the PDF as markup. Apply the escape filter to values that come from untrusted input and should be shown verbatim:

<p>{{ userComment | escape }}</p>

Styling and page setup

  • Pages are rendered as A4 portrait at 300 DPI.
  • Put your CSS in a <style> block inside the template.
  • Images and fonts must be reachable over public URLs (or embedded as data URIs), since the renderer loads them from outside your network.
  • Use the live preview in the editor and Set variables to check the layout before rendering. See Create a template.

Page breaks

Content that does not fit on one page continues on the next one. To decide where that break falls, use page-break-before, page-break-after and page-break-inside. The renderer honours all three, and the editor preview shows each page as its own sheet.

Use those page-break-* names, not the modern break-before / break-after / break-inside. The renderer is an older WebKit build: it ignores the modern properties silently, so the PDF comes back as one long page with no error.

Start a new page

page-break-after: always breaks after the element, page-break-before: always breaks before it:

<div class="intro" style="page-break-after: always;">
    <h1>Introduction</h1>
    <p>This section fills page 1 on its own.</p>
</div>
<div class="rest">
    <h1>Details</h1>
    <p>This starts on page 2.</p>
</div>

page-break-before on the very first element of the document does nothing, so a repeated section can carry it without producing an empty first page. One chapter per page:

<style>
    .chapter { page-break-before: always; }
</style>
 
{% for chapter in chapters %}
<div class="chapter">
    <h1>{{ chapter.title }}</h1>
    <p>{{ chapter.body }}</p>
</div>
{% endfor %}

Keep a block together

page-break-inside: avoid moves an element to the next page instead of splitting it across the boundary:

<style>
    .card { page-break-inside: avoid; }
</style>
 
<div class="card">
    <h2>{{ product.name }}</h2>
    <p>{{ product.description }}</p>
</div>

A block taller than a single page is split anyway, because there is nowhere to move it to. One page holds roughly 1300 px of content at the renderer's scale.

A heading can end up alone at the bottom of a page, with its text starting on the next one. To keep them together, wrap the heading and its first paragraph in a block that avoids breaks:

<div style="page-break-inside: avoid;">
    <h2>{{ section.title }}</h2>
    <p>{{ section.intro }}</p>
</div>

Tables over several pages

A table that runs over several pages repeats its header on each page when the header rows are in a thead. Rows are never split: a row that does not fit moves whole to the next page, even when it holds several lines of text.

<table>
    <thead>
        <tr><th>Item</th><th>Amount</th></tr>
    </thead>
    <tbody>
        {% for item in items %}
        <tr><td>{{ item.name }}</td><td>{{ item.price }}</td></tr>
        {% endfor %}
    </tbody>
</table>

What is not available

  • @page rules. Margins and page size cannot be changed from the template. Every PDF is A4 portrait with a margin of about 10 mm on each side.
  • Headers, footers and page numbers. There is no counter(page) and no @bottom-center, so "Page 1 of 3" cannot be produced from a template.
  • JavaScript. It is disabled during rendering, so pagination cannot be computed by a script.

On this page