HTML Editor
Build complex PDF layouts with HTML, CSS, and the Twig templating engine.
Overview
The HTML editor gives you full control over the PDF output by writing HTML and CSS. Dynamic data is injected using Twig syntax. pdfstork supports the subset of Twig described on this page, not the whole language: the tags, tests and filters listed below are the only ones available. The HTML is rendered to PDF using a headless browser, so modern CSS (flexbox, grid, etc.) is fully supported.
Editor layout
The editor is split into three panels:
- Twig editor (left): Monaco code editor with syntax highlighting for writing your HTML/Twig template
- Preview data (top-right): JSON editor for defining sample data used during preview
- Live preview (bottom-right): real-time HTML rendering that updates as you type (500ms debounce)
The header bar provides controls for page format, orientation, margins, and export.
Creating an HTML template
- Go to Templates and click Create Template
- Choose HTML Editor as the template type
- Pick a starter template or start with the default boilerplate
- Write your HTML with Twig variables in the code editor
Twig syntax
Variables
Use double curly braces to output variables in your HTML:
<h1>Invoice {{ invoice_number }}</h1>
<p>Dear {{ customer_name }},</p>
<p>Amount due: {{ amount }}</p>
<p>Due date: {{ due_date }}</p>Loops
Use for loops to iterate over arrays. This is the most common pattern for generating line items in invoices, rows in reports, etc.
<table>
<thead>
<tr>
<th>Description</th>
<th>Qty</th>
<th>Unit price</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.description }}</td>
<td>{{ item.quantity }}</td>
<td>{{ item.unit_price }}</td>
<td>{{ item.total }}</td>
</tr>
{% endfor %}
</tbody>
</table>Inside a loop you have access to the loop variable:
{% for item in items %}
{{ loop.index }} {# 1, 2, 3... #}
{{ loop.index0 }} {# 0, 1, 2... #}
{{ loop.first }} {# true on first iteration #}
{{ loop.last }} {# true on last iteration #}
{{ loop.length }} {# total number of items #}
{% endfor %}Conditionals
Show or hide sections based on your data:
{% if discount > 0 %}
<tr class="discount">
<td colspan="3">Discount ({{ discount }}%)</td>
<td>-{{ discount_amount }}</td>
</tr>
{% endif %}
{% if notes is not empty %}
<div class="notes">
<h3>Notes</h3>
<p>{{ notes }}</p>
</div>
{% endif %}
{% if status == 'paid' %}
<span class="badge paid">Paid</span>
{% elseif status == 'overdue' %}
<span class="badge overdue">Overdue</span>
{% else %}
<span class="badge pending">Pending</span>
{% endif %}Filters
Twig filters transform values. Chain them with the | (pipe) operator:
{{ name | upper }} {# JANE DOE #}
{{ name | lower }} {# jane doe #}
{{ name | capitalize }} {# Jane doe #}
{{ name | title }} {# Jane Doe #}
{{ price | number_format(2) }} {# 1,200.00 #}
{{ "2026-01-15" | date("d/m/Y") }} {# 15/01/2026 #}
{{ description | default("N/A") }}
{{ description ?? "N/A" }} {# same, with the ?? operator #}
{{ html_content | raw }} {# Output unescaped HTML #}
{{ items | length }} {# Number of items #}
{{ items|first.label }} {# label of the first item #}
{{ text | trim }} {# Remove whitespace #}Tags and filters available
Templates support if / elseif / else, for loops and set. Conditions can use the tests is defined, is null, is empty, is even and is odd (each with not), and the ?? operator gives a fallback value when a variable is missing or null.
The available filters are exactly these: abs, capitalize, date, default, e, escape, first, join, last, length, lower, nl2br, number_format, raw, round, title, trim and upper. Any other filter is an error, both when the editor validates the template and when the API generates it. Values are HTML-escaped unless you use raw. Includes, inheritance and macros are not supported: repeat the markup, or loop over your data.
Filters with arguments
number_format(decimals, decimal_point, thousands_separator): defaults to0,"."and",".1234.5|number_format(2)gives1,234.50, and1234.5|number_format(2, '.', "'")gives1'234.50.date(format): accepts a date as text (2026-01-15,2026-01-15T10:00:00Z,15.01.2026…) or a timestamp in seconds. The format uses the PHP date characters (d,j,m,n,M,F,Y,y,D,l,H,i,s, etc.). Without an argument the format isY-m-d."2026-01-15"|date("d/m/Y")gives15/01/2026.round(precision, method): precision defaults to0, method is"common"(default),"ceil"or"floor".absreturns the absolute value of a number.firstandlastreturn the first or last element of a list, so you can read its fields:items|first.label.default(value)replaces a missing or empty value;join(separator)joins a list into text, with", "as the default separator.
Page configuration
Configure these settings in the editor header bar:
| Setting | Options | Default |
|---|---|---|
| Format | A0–A6, Letter, Legal, Tabloid, Ledger, or custom dimensions (mm) | A4 |
| Orientation | Portrait, Landscape | Portrait |
| Margins | Top, Right, Bottom, Left (mm) | 10 mm each |
| Custom dimensions | Width and Height in mm | Only when format is "Custom" |
| Properties | Title, author, subject, keywords, creator | Empty. Written into the PDF's document properties; values can use {{ variables }}. Basic plan and above |
Headers and footers
Define custom headers and footers that repeat on every page. They are written in HTML with Twig support, and the live preview renders their Twig as well. They also have access to special page variables:
<div style="display: flex; justify-content: space-between; font-size: 9px; color: #999;">
<span>{{ company_name }}</span>
<span>{{ invoice_number }}</span>
</div><div style="text-align: center; font-size: 9px; color: #999;">
Page <span class="pageNumber"></span> of <span class="totalPages"></span>
</div><span class="pageNumber"></span> and <span class="totalPages"></span> in headers/footers. These are automatically replaced with the current page number and total page count during rendering.Preview data
The top-right panel is a JSON editor where you define sample data for live preview. This data is used only when previewing in the editor. It does not affect API calls.
{
"company_name": "Acme Corp",
"invoice_number": "INV-2026-042",
"customer_name": "Jane Doe",
"items": [
{ "description": "Web design", "quantity": 1, "unit_price": "€3,000", "total": "€3,000" },
{ "description": "Hosting (1 year)", "quantity": 1, "unit_price": "€120", "total": "€120" }
],
"subtotal": "€3,120",
"discount": 10,
"discount_amount": "€312",
"total": "€2,808",
"due_date": "30/04/2026",
"notes": "Payment due within 30 days.",
"status": "pending"
}Styling with CSS
Include CSS directly in your template with a <style> tag. The PDF renderer supports modern CSS including:
- Layout: flexbox, CSS Grid
- Typography: custom fonts via
@font-faceor Google Fonts@import - Variables: CSS custom properties (
var(--my-color)) - Print:
page-break-before,page-break-after,page-break-inside: avoid
<style>
:root {
--primary: #1a1a2e;
--accent: #e94560;
}
body {
font-family: 'Helvetica Neue', sans-serif;
color: var(--primary);
font-size: 12px;
line-height: 1.5;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 40px;
}
table {
width: 100%;
border-collapse: collapse;
}
th {
background: var(--primary);
color: white;
padding: 8px 12px;
text-align: left;
}
td {
padding: 8px 12px;
border-bottom: 1px solid #eee;
}
.total {
font-size: 18px;
font-weight: bold;
color: var(--accent);
}
</style>
<div class="header">
<div>
<h1>Invoice {{ invoice_number }}</h1>
<p>Date: {{ date }}</p>
</div>
<div style="text-align: right;">
<strong>{{ company_name }}</strong><br>
{{ company_address }}
</div>
</div>
<table>
<thead>
<tr>
<th>Description</th>
<th>Qty</th>
<th>Price</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.description }}</td>
<td>{{ item.quantity }}</td>
<td>{{ item.price }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div style="text-align: right; margin-top: 20px;">
<span class="total">Total: {{ total }}</span>
</div>Page breaks
Control where pages break in multi-page documents:
{# Force a page break before this section #}
<div style="page-break-before: always;">
<h2>Terms & Conditions</h2>
...
</div>
{# Prevent a table row from being split across pages #}
<tr style="page-break-inside: avoid;">
<td>{{ item.description }}</td>
</tr>Error handling
If your Twig template contains an error, the editor shows the message with its line number and points to the failing line. Common errors include:
- Unclosed tags:
{% for ... %}without{% endfor %} - A filter that is not in the list above
- A tag that is not supported (only
if,elseif,else,forandsetare)
An undefined variable is not an error: it prints nothing. Use default or ?? to show a fallback.
The API applies the same checks. Generating a PDF from a template with a Twig error is refused with a 422 that names the part and the line, and no credit is consumed:
{
"message": "Twig error in the template, line 2: missing endif (the if opened here is never closed)",
"part": "template",
"line": 2,
"errors": {
"template": [
"Twig error in the template, line 2: missing endif (the if opened here is never closed)"
]
}
}WYSIWYG vs. HTML editor
Choose the HTML editor when:
- You need multi-page documents with content that flows across pages
- Your template has complex conditional logic or loops
- You want pixel-perfect control with CSS
- You're building reports, contracts, or data-heavy documents
Consider the WYSIWYG editor instead for simple fixed-layout templates or when you need to overlay fields on an existing PDF.
