Template editor writing guide
A practical guide to customising your document templates in Phasio
You don't need to be a developer to customise your templates. This guide covers inserting data, showing or hiding fields, and building custom tables for parts and expenses.
š” The fastest way to get a custom template is to use the AI writing assistant at the bottom of this page.
Start from the default
Every template comes pre-filled with a working layout. Open the Source tab to edit the HTML directly, or use the Visual tab for click-to-edit formatting.
Custom Document, Group Traveller, and Order Traveller templates start with a fully annotated starter template ā it includes a page header, footer, logo, and a parts table with comments explaining each section. Edit it to match your layout and branding.
ā ļø Do not remove
xmlns:th="http://www.thymeleaf.org"from the<html>tag ā it enables all variable and conditional features. Standard HTML5 is supported; no strict XHTML syntax required.
Insert a variable
Click Variables in the toolbar to browse and insert any variable ā it is placed at your cursor automatically with the correct syntax.
To type a variable directly in the Source tab:
[(${VARIABLE_NAME})]Example:
<p>Order: [(${ORDER_NUMBER})]</p>
<p>Customer: [(${CUSTOMER_NAME})]</p>
<p>Total: [(${FINAL_PRICE})]</p>Variable reference
Your company
| Variable | What it inserts |
|---|---|
LOGO_IMG | Your company logo ā pre-rendered <img> tag on standard templates; raw base64 on custom templates (see Using the logo) |
OPERATOR_NAME | Your company name |
OPERATOR_EMAIL | Your company email |
OPERATOR_PHONE | Your company phone |
OPERATOR_LOCATION | Your company location |
GST_NUMBER | Your GST / VAT number |
PAYMENT_INFORMATION | Bank / payment details |
OPERATOR_NOTES | Notes added to the order |
Order
| Variable | What it inserts |
|---|---|
ORDER_NUMBER | Order number |
QUOTE_NUMBER | Quote number associated with the order |
ORDER_DATE | Date the order was placed |
ESTIMATE_DATE | Date the quote was sent |
CONFIRMATION_DATE | Date the customer confirmed |
PAYMENT_DATE | Date payment was received |
TARGET_DELIVERY_DATE | Estimated delivery date |
PURCHASE_ORDER_NUMBER | Customer's PO number |
Customer
| Variable | What it inserts |
|---|---|
CUSTOMER_NAME | Customer organisation name |
CUSTOMER_GST | Customer's GST / tax number |
CUSTOMER_ID | Customer contact name |
CUSTOMER | Raw customer object ā access individual fields (see below) |
BILLING_ADDRESS | Pre-formatted billing address block |
BILLING_ADDRESS_DATA | Raw billing address ā access individual fields (see below) |
SHIPPING_ADDRESS | Pre-formatted shipping address block |
SHIPPING_ADDRESS_DATA | Raw shipping address ā access individual fields (see below) |
CUSTOMER fields: CUSTOMER.organisationName, CUSTOMER.firstName, CUSTOMER.lastName, CUSTOMER.email, CUSTOMER.phone
BILLING_ADDRESS_DATA / SHIPPING_ADDRESS_DATA fields: street1, street2, city, state, country, zip, name, company, email, phone
<!-- Use the pre-formatted block for a quick address block -->
<td th:utext="${BILLING_ADDRESS}"></td>
<!-- Or access individual fields for a custom layout -->
<td>
<div th:text="${BILLING_ADDRESS_DATA.name}"></div>
<div th:text="${BILLING_ADDRESS_DATA.street1}"></div>
<div th:text="${BILLING_ADDRESS_DATA.city + ', ' + BILLING_ADDRESS_DATA.country}"></div>
</td>Pricing
| Variable | What it inserts |
|---|---|
SUB_TOTAL | Parts total before extras |
SHIPPING_FEE | Shipping charge |
DISCOUNT | Discount amount |
DISCOUNT_PERCENTAGE | Discount percentage |
TAXES | Tax breakdown |
PRICE_BEFORE_TAX | Total before tax |
FINAL_PRICE | Total amount due |
TOP_UP_PRICE | Top-up to reach minimum order |
LINE_ITEM_NAMES | Pricing line item names |
LINE_ITEM_PRICES | Pricing line item amounts |
Parts and expenses
| Variable | What it inserts |
|---|---|
PARTS_TABLE | Built-in parts table (ready to drop in) ā includes a Unit Price column |
PARTS | Parts list ā iterate to build your own table (see below). Each part has: name, technology, material, quantity, volume, surfaceArea, length, width, height, thumbnailImg, color, postProcessings, infill (nullable), precision (nullable), unitPrice (nullable), currency (nullable) |
EXPENSES_TABLE | Built-in expenses table (ready to drop in) |
EXPENSES | Expenses list ā iterate to build your own table (see below) |
Production (Group Traveller only)
| Variable | What it inserts |
|---|---|
COMPANY_LOGO | Company logo ā raw base64 string (see Using the logo) |
GROUP_NAME | Production group name |
GROUP_DATE | Date the group was created |
MACHINE | Machine used |
MATERIAL | Material used |
MATERIAL_BATCH | Batch identifier |
DUE_DATE | Group due date |
EXPORT_DATE | Date the document was generated |
LINE_ITEMS | Parts list ā iterate to list parts in the group. Each item has: partName, customerName, quantity, thumbnailImg, purchaseOrderNumber, orderNumber, unitPrice (nullable), currency (nullable) |
ALL_WORKFLOW_STEPS | List of all workflow step names |
LINE_ITEMS_WITH_MAPS | Parts paired with workflow step data ā use to build routing tables. Each entry exposes .lineItem (full part object), .stepMap, .unitPrice (nullable shortcut), .currency (nullable shortcut) |
Orders (Order Traveller only)
| Variable | What it inserts |
|---|---|
COMPANY_LOGO | Company logo ā raw base64 string (see Using the logo) |
EXPORT_DATE | Date the document was generated |
ORDERS | List of orders ā iterate over each order with its parts and totals. Each order has: orderNumber, orderDate, customerName, customerGst, billingAddress, shippingAddress, purchaseOrderNumber, finalPrice (nullable), currency, allWorkflowSteps, lineItemsWithMaps. Each entry in lineItemsWithMaps exposes .lineItem (with unitPrice and currency fields), .stepMap, .unitPrice (shortcut), .currency (shortcut) |
Using the logo
LOGO_IMG behaves differently depending on the template type:
Standard templates (Order Invoice, Order Estimate, Order Confirmation, Traveller Sheet, Consignment Label) ā LOGO_IMG is a pre-rendered <img> tag from the server. Output it directly:
<!-- In a table cell -->
<td th:utext="${LOGO_IMG}"></td>
<!-- Inline -->
[(${LOGO_IMG})]Custom templates (Custom Document, Group Traveller, Order Traveller) ā LOGO_IMG and COMPANY_LOGO are raw base64 strings. Use both th:src and th:attr for reliable rendering:
<img th:if="${LOGO_IMG != null and !#strings.isEmpty(LOGO_IMG)}"
th:src="${'data:image/png;base64,' + LOGO_IMG}"
th:attr="src=|data:image/png;base64,${LOGO_IMG}|"
style="max-height: 40px; width: auto;"
alt="" />Show a field only when it has a value
Use th:if to hide a block when the variable is empty. This is useful for optional fields like PO number or notes.
<p th:if="${PURCHASE_ORDER_NUMBER}">
PO: [(${PURCHASE_ORDER_NUMBER})]
</p>
<p th:if="${OPERATOR_NOTES}">
Notes: [(${OPERATOR_NOTES})]
</p>This works on any element ā <p>, <div>, <tr>, and so on.
Visual indicator: In the Visual tab, elements with th:if show a blue outline and label so you can see them at a glance without reading the source.
Build a custom parts table
Use PARTS to build your own layout. Each part has: name, technology, material, color, quantity, unitPrice, processingPrice, thumbnailImg (raw base64), postProcessings (a list with name and price), infill (nullable string ā e.g. "20%"), precision (nullable string ā e.g. "Standard"), and physical dimensions: volume (mm³), height, width, length (mm), area (mm²). shrinkWrapVolume and minimumWallThickness are available on Pro and Factory Floor plans.
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr>
<th style="text-align: left; padding: 6px;">Part</th>
<th style="text-align: left; padding: 6px;">Material</th>
<th style="text-align: left; padding: 6px;">Colour</th>
<th style="text-align: right; padding: 6px;">Qty</th>
<th style="text-align: right; padding: 6px;">Total</th>
</tr>
</thead>
<tbody>
<tr th:each="part : ${PARTS}">
<td style="padding: 6px;">[(${part.name})]</td>
<td style="padding: 6px;">[(${part.technology})], [(${part.material})]</td>
<td style="padding: 6px;" th:text="${part.color}"></td>
<td style="padding: 6px;">[(${part.width})] Ć [(${part.height})] Ć [(${part.length})] mm</td>
<td style="text-align: right; padding: 6px;">[(${part.quantity})]</td>
<td style="text-align: right; padding: 6px;">[(${part.processingPrice})]</td>
</tr>
</tbody>
</table>To include a thumbnail image:
<img th:if="${part.thumbnailImg != null and !#strings.isEmpty(part.thumbnailImg)}"
th:src="${'data:image/png;base64,' + part.thumbnailImg}"
th:attr="src=|data:image/png;base64,${part.thumbnailImg}|"
style="max-width: 40px; max-height: 40px;" alt="" />Visual indicator: In the Visual tab, rows with th:each show an amber outline and label so loop blocks are easy to spot.
Build a custom expenses table
Use EXPENSES to build your own layout. Each expense has name, description, price, and type (FIXED or HOURLY). Hourly expenses also have ratePerHour and hours.
<table style="width: 100%; border-collapse: collapse;">
<tr th:each="expense : ${EXPENSES}">
<td style="padding: 6px;">
[(${expense.name})]
<div th:if="${expense.description != null and !#strings.isEmpty(expense.description)}"
style="font-size: 0.9em; color: #666;">
[(${expense.description})]
</div>
</td>
<td style="padding: 6px;" th:if="${expense.type.name() == 'HOURLY'}">
[(${expense.hours})] hrs Ć [(${expense.ratePerHour})]
</td>
<td style="text-align: right; padding: 6px;">[(${expense.price})]</td>
</tr>
</table>Group Traveller ā parts and workflow routing
Use LINE_ITEMS to list parts in the group. Each item has: partName, customerName, quantity, thumbnailImg, purchaseOrderNumber, orderNumber, unitPrice (nullable), and currency (nullable).
Use LINE_ITEMS_WITH_MAPS and ALL_WORKFLOW_STEPS together to build a routing matrix ā rows are parts, columns are workflow steps. Each entry also exposes .unitPrice and .currency shortcuts for easy price rendering:
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr>
<th style="text-align: left; padding: 6px;">Part</th>
<th style="text-align: right; padding: 6px;">Unit Price</th>
<th th:each="step : ${ALL_WORKFLOW_STEPS}" style="padding: 6px; text-align: center;">
[(${step})]
</th>
</tr>
</thead>
<tbody>
<tr th:each="entry : ${LINE_ITEMS_WITH_MAPS}">
<td style="padding: 6px;">[(${entry['lineItem'].partName})]</td>
<td style="padding: 6px; text-align: right; white-space: nowrap;">
<span th:if="${entry.unitPrice != null}"
th:text="${(entry.currency != null and entry.currency != '') ? entry.currency + ' ' : ''} + ${#numbers.formatDecimal(entry.unitPrice, 1, 'COMMA', 2, 'POINT')}"></span>
<span th:if="${entry.unitPrice == null}">ā</span>
</td>
<td th:each="step : ${ALL_WORKFLOW_STEPS}" style="padding: 6px; text-align: center;">
<span th:if="${entry['stepMap'].containsKey(step) and entry['stepMap'].get(step).completed}">ā</span>
<span th:unless="${entry['stepMap'].containsKey(step) and entry['stepMap'].get(step).completed}">ā</span>
</td>
</tr>
</tbody>
</table>Order Traveller ā iterating orders
Use ORDERS to iterate over each order. Each order has: orderNumber, orderDate, customerName, customerGst, billingAddress, shippingAddress, purchaseOrderNumber, finalPrice (nullable), currency, allWorkflowSteps, and lineItemsWithMaps. Each entry in lineItemsWithMaps exposes .unitPrice and .currency shortcuts for per-part pricing.
<div th:each="order : ${ORDERS}">
<h3>Order [(${order.orderNumber})] ā [(${order.customerName})]</h3>
<p>[(${order.orderDate})]</p>
<p th:if="${order.purchaseOrderNumber != null and !#strings.isEmpty(order.purchaseOrderNumber)}">
PO: [(${order.purchaseOrderNumber})]
</p>
<!-- Parts with unit price -->
<table style="width: 100%; border-collapse: collapse;">
<tr th:each="entry : ${order.lineItemsWithMaps}">
<td>[(${entry['lineItem'].partName})]</td>
<td>Qty: [(${entry['lineItem'].quantity})]</td>
<td style="text-align: right;">
<span th:if="${entry.unitPrice != null}"
th:text="${(entry.currency != null and entry.currency != '') ? entry.currency + ' ' : ''} + ${#numbers.formatDecimal(entry.unitPrice, 1, 'COMMA', 2, 'POINT')}"></span>
<span th:if="${entry.unitPrice == null}">ā</span>
</td>
</tr>
</table>
<!-- Order total -->
<p th:if="${order.finalPrice != null}" style="text-align: right; font-weight: bold;">
Total:
<span th:text="${(order.currency != null and order.currency != '') ? order.currency + ' ' : ''} + ${#numbers.formatDecimal(order.finalPrice, 1, 'COMMA', 2, 'POINT')}"></span>
</p>
</div>Repeating page header and footer
Add id="page-header" or id="page-footer" to repeat an element on every page. Place the element anywhere in the <body> ā the renderer pulls it out of the normal flow automatically.
<div id="page-header">
[(${OPERATOR_NAME})] | Order [(${ORDER_NUMBER})]
</div>
<div id="page-footer" data-footer-height="40px">
Page <span class="page-current"></span> of <span class="page-total"></span>
</div>Use data-footer-height to control how much space is reserved at the bottom of each page. The default is 40px, which fits a single line. Increase it when your footer has more content:
<div id="page-footer" data-footer-height="60px">
<table width="100%">
<tr>
<td style="font-size: 8pt; color: #999;" th:text="${OPERATOR_NAME}"></td>
<td style="text-align: right; font-size: 8pt; color: #999;">
Page <span class="page-current"></span> of <span class="page-total"></span>
</td>
</tr>
<tr>
<td colspan="2" style="font-size: 7pt; color: #bbb;" th:text="${OPERATOR_EMAIL}"></td>
</tr>
</table>
</div>Page numbers ā use these two spans inside any footer or header content:
| Span | Renders as |
|---|---|
<span class="page-current"></span> | Current page number |
<span class="page-total"></span> | Total page count |
Side-by-side columns
The PDF renderer (OpenPDF) does not support flexbox or CSS Grid. Use <table> for multi-column layouts:
<table style="width: 100%;">
<tr>
<td style="vertical-align: top;">
<img th:if="${LOGO_IMG != null and !#strings.isEmpty(LOGO_IMG)}"
th:src="${'data:image/png;base64,' + LOGO_IMG}"
style="max-height: 40px;" alt="" />
</td>
<td style="vertical-align: top; text-align: right;">
[(${OPERATOR_NAME})]<br/>
[(${OPERATOR_EMAIL})]
</td>
</tr>
</table>Fonts
| Font | font-family value |
|---|---|
| Poppins (default) | Poppins |
| Arimo | Arimo |
| Gentium Plus | Gentium |
| Source Serif Pro | SourceSerif |
ā ļø For templates in Russian, Ukrainian, Bulgarian, or Greek use
Arimoto ensure all characters render correctly.
Page sizes
Standard templates default to A4. Custom Document, Group Traveller, and Order Traveller templates let you set any size in millimetres when creating the template.
| Size | Width Ć Height |
|---|---|
| A4 (default) | 210 Ć 297 mm |
| A3 | 297 Ć 420 mm |
| A5 | 148 Ć 210 mm |
| US Letter | 215.9 Ć 279.4 mm |
Write templates with AI
Describe what you want and an AI assistant will write the HTML for you. Use the buttons below to open a new chat with everything it needs already loaded.
Example prompts:
- "Create an invoice with logo on the left and company details on the right."
- "Build a parts table showing thumbnail, name, dimensions, and quantity."
- "Add a PO number row that only shows when a PO number is set."
- "Build a Group Traveller with a routing matrix showing which workflow steps each part goes through."
AI writing assistant
Opens a new chat and copies the context to your clipboard ā paste it in to get started.
Last updated on