---
title: "Document Engine"
description: "Design PDF documents with HTML, CSS, and Liquid — data objects, per-shipment tour detail, barcodes and scannable links, fonts and custom typography, multi-page labels, testing, and how generated Documents are versioned."
url: "https://support.pr-4.orbit.do/en/advanced-features/document-engine"
locale: "en"
lastReviewed: "2026-08-04"
---

# Document Engine

Design PDF documents with HTML, CSS, and Liquid — data objects, per-shipment tour detail, barcodes and scannable links, fonts and custom typography, multi-page labels, testing, and how generated Documents are versioned.

## Overview

The Orbit Document Engine allows you to generate PDF documents dynamically based on your operational data. It combines standard web technologies (HTML & CSS) with the Liquid templating language. Instead of relying on rigid, pre-defined templates, the Document Engine gives you control over the layout, styling, and content of your documents. Whether you need shipping labels, CMRs, invoices, or custom reports, you can design them exactly to your specifications.

## Getting Started

### Accessing the Document Engine

To start creating or editing templates:

1. Open **Orbit MissionControl**.
2. Navigate to **Settings > Document Templates**.
3. Click **Create Template** or select an existing one.

### Define Data Objects

Data Objects define the type of object the template can access. Each Data Object you add to a template must be provided when rendering. For example, if you define a template with a "Tour" Data Object, you must provide a tour ID when rendering the template.

In the editor toolbar, use the "Data Objects" dropdown to select your source data (e.g., Tour, Order). This enables the correct Liquid variables and loads the corresponding mock data for the preview.

### Template Content

Inside the template content editor, you can use standard HTML and CSS embedded in style tags to define the document layout. This includes all HTML properties supported by modern web browsers.

In addition to HTML, the template content uses the Liquid templating language to access specialized functions, data objects, and control flow primitives. The examples below demonstrate how to use Liquid. For a more detailed reference on the Liquid templating language, see [this link](https://liquidjs.com/).

To control document size and page setup for printing, we recommend using the CSS paged media module. This module provides specialized CSS directives to control page size, margins, and page breaks. The examples below cover these properties. For a more detailed reference, see [this link](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_paged_media).

### Live Preview

The Live Preview allows you to test your layout instantly using mock data. As you type, the preview updates in real-time.

## Walkthrough Part 1: Single Page Label

In this first step, we will create a simple shipping label with a physical label size of 100mm x 150mm.

**Copy this into the editor:**

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Shipping Label</title>
    <style>
      /* 1. Define Page Size */
      @page {
        size: 100mm 150mm;
        margin: 0;
      }

      body {
        font-family: Arial, sans-serif;
        margin: 0;
        padding: 0;
      }

      /* 2. Define the container for our content */
      .label-page {
        width: 100mm;
        height: 150mm;
        padding: 5mm;
        box-sizing: border-box;
        display: flex;
        flex-direction: column;
        border: 1px dashed #ccc;
      }

      .section {
        border-bottom: 2px solid black;
        padding-bottom: 5px;
        margin-bottom: 5px;
      }

      .barcode-container {
        text-align: center;
        margin-top: auto;
      }

      .barcode-container svg {
        width: 80mm;
        height: 15mm;
      }
    </style>
  </head>
  <body>
    <!-- 3. Label Content -->
    <div class="label-page">
      <!-- Inject Addresses -->
      {% assign sender = tour.stops | first %} {% assign recipient = tour.stops
      | last %}

      <div class="section">
        <strong>FROM:</strong><br />
        {{ sender.address.companyName }}<br />
        {{ sender.address.street }} {{ sender.address.houseNumber }}<br />
        {{ sender.address.zipCode }} {{ sender.address.city }}
      </div>

      <div class="section">
        <strong>TO:</strong><br />
        <h2 style="margin: 0;">{{ recipient.address.companyName }}</h2>
        {{ recipient.address.street }} {{ recipient.address.houseNumber }}<br />
        {{ recipient.address.zipCode }} {{ recipient.address.city }}
      </div>

      <!-- Barcode (Tour ID) -->
      <div class="barcode-container">{{ tour.id | code128 }}</div>
    </div>
  </body>
</html>
```

### Adding Barcodes

In the example above, we used `{{ tour.id | code128 }}` to generate a barcode. Barcodes are created using Liquid filters that convert any string into an SVG image.

**Available Barcode Types:**

| Filter       | Type        | Best For                           | Example                                   |
| ------------ | ----------- | ---------------------------------- | ----------------------------------------- |
| `code128`    | Code 128    | IDs, tracking numbers, labels      | `{{ tour.id \| code128 }}`                |
| `qrcode`     | QR Code     | URLs, longer text, mobile scanning | `{{ "<https://example.com>" \| qrcode }}` |
| `datamatrix` | Data Matrix | Compact data, industrial use       | `{{ load.id \| datamatrix }}`             |

**Usage Example:**

```html
<!-- Code 128 Barcode -->
<div class="barcode-container">{{ tour.id | code128 }}</div>

<!-- QR Code -->
<div class="qr-container">
  {{ "<https://track.example.com/>" | append: tour.id | qrcode }}
</div>

<!-- Data Matrix -->
<div class="matrix-container">{{ load.id | datamatrix }}</div>
```

> Tip: All barcode filters output inline SVG, so you can style them with CSS (width, height, etc.).

***

## Walkthrough Part 2: Multi-Page Labels

Now, let's say we want one label per load (e.g., 5 pallets = 5 pages).

**Important:** A `Load` object can have a count greater than one. This means multiple units of the same `Load` (with identical dimensions and weight as defined in the `Load` object) need to be handled. The example below does not account for load count. While it's possible to generate one label per unit, we don't recommend this approach for label generation. Instead, ensure the `count` is always one and create one label per `Load` object.

### 1. Enable Multi-Page Support

To create multiple pages in the PDF and preview, use the `page-break-after` CSS rule in combination with a Liquid loop.

**Update your CSS:**

Add `page-break-after: always;` to your `.label-page` class:

```css
.label-page {
  /* ... existing styles ... */

  /* IMPORTANT: This triggers the page break */
  page-break-after: always;
}

/* Optional: Prevent empty page at the end */
.label-page:last-child {
  page-break-after: auto;
}
```

> Understanding @page vs page-break-after:
>
> | CSS Rule                       | Purpose                                               |
> | ------------------------------ | ----------------------------------------------------- |
> | `@page { size: 100mm 150mm; }` | Defines the **page dimensions** for the PDF.          |
> | `page-break-after: always`     | Forces an **explicit page break** after each element. |
>
> Both rules work together:
>
> * `@page` tells the PDF engine the size of each page
> * `page-break-after` tells it **where** to start a new page
>
> Without `page-break-after`, content would only break when it overflows the page size. With loops (like `{% for load in tour.loads %}`), you need `page-break-after: always` to ensure each iteration starts on a new page.

> How the Preview Works:
>
> Since browsers only apply `@page` rules in print mode (not in normal screen rendering), the editor simulates multi-page layouts by scanning your `<style>` blocks for `page-break-after: always`. When found:
>
> 1. The body transforms into a flex container with gaps
> 2. Each matching element renders as a separate paper sheet with a shadow
> 3. Visual spacing (24px) appears between pages
>
> This detection happens automatically—you don't need to add any special classes or markup beyond the CSS rule.

### 2. Loop Through Loads

Now wrap your content in a Liquid loop to generate one page (and thus one label) for every load.

**Complete Multi-Page Example:**

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Shipping Labels - {{ tour.id }}</title>
    <style>
      @page {
        size: 100mm 150mm;
        margin: 0;
      }

      body {
        font-family: Arial, sans-serif;
        margin: 0;
        padding: 0;
      }

      .label-page {
        width: 100mm;
        height: 150mm;
        padding: 5mm;
        box-sizing: border-box;
        display: flex;
        flex-direction: column;
        border: 1px dashed #ccc;
        page-break-after: always;
      }

      .label-page:last-child {
        page-break-after: auto;
      }

      .header {
        display: flex;
        justify-content: space-between;
        align-items: center;
        border-bottom: 2px solid black;
        padding-bottom: 3mm;
        margin-bottom: 3mm;
      }

      .load-badge {
        background: #333;
        color: white;
        padding: 2mm 4mm;
        font-weight: bold;
        font-size: 14px;
      }

      .section {
        border-bottom: 1px solid #ccc;
        padding-bottom: 3mm;
        margin-bottom: 3mm;
      }

      .recipient {
        flex: 1;
      }

      .recipient h2 {
        margin: 0 0 2mm 0;
        font-size: 18px;
      }

      .load-info {
        background: #f5f5f5;
        padding: 3mm;
        margin-bottom: 3mm;
      }

      .barcode-container {
        text-align: center;
        margin-top: auto;
        padding-top: 3mm;
        border-top: 2px solid black;
      }

      .barcode-container svg {
        width: 80mm;
        height: 12mm;
      }

      .barcode-text {
        font-family: monospace;
        font-size: 10px;
        margin-top: 1mm;
      }
    </style>
  </head>
  <body>
    {% assign sender = tour.stops | first %} {% assign recipient = tour.stops |
    last %}

    <!-- Loop: One label per load -->
    {% for load in tour.loads %}
    <div class="label-page">
      <div class="header">
        <div class="load-badge">
          {{ forloop.index }} / {{ tour.loads.size }}
        </div>
        <div>{{ tour.latestStart | formatDate: "en" }}</div>
      </div>

      <div class="section">
        <strong>FROM:</strong><br />
        {{ sender.address.companyName }}<br />
        {{ sender.address.city }}
      </div>

      <div class="recipient">
        <strong>TO:</strong>
        <h2>{{ recipient.address.companyName }}</h2>
        {{ recipient.address.street }} {{ recipient.address.houseNumber }}<br />
        {{ recipient.address.zipCode }} {{ recipient.address.city }}
      </div>

      <div class="load-info">
        <strong>Load:</strong> {{ load.type }}<br />
        <strong>Quantity:</strong> {{ load.count }}<br />
        <strong>Weight:</strong> {{ load.totalWeight }} kg
      </div>

      <div class="barcode-container">{{ load.id | code128 }}</div>
    </div>
    {% endfor %}
  </body>
</html>
```

You should now see multiple labels in the preview, separated by a gap.

## Testing & Verification

Once you are satisfied with your layout, you can test your template with real world data from within your Orbit account.

### Test Render

1. Navigate to the **Document Templates** list in Settings.
2. Click the **Test Render** button (Play Icon) next to your template.
3. A dialog opens where you can **select real data** from your system (e.g., an actual Tour, Order, or Shipment).
4. Click **Generate** to render the template with the selected data.
5. The generated PDF will be **downloaded automatically** to your device.

This allows you to verify that your template works correctly with production data before using it in your workflows.

## Rendering Documents

The primary way to integrate document rendering into your workflow is through automations. While our upcoming **Orbit Automations** feature will soon provide native, first-class support for rendering documents directly within the platform, this functionality is currently in development.

Until Orbit Automations is released, the only way to render documents is programmatically via the [Orbit API](https://orbit-api.readme.io/reference/general-info).

To bridge this gap today, we recommend using a third-party automation provider (such as [n8n](https://n8n.io/)) to combine Orbit Webhooks with the **Document Templates API**. This allows you to react to events and trigger renders automatically. If you need assistance setting this up, or would prefer us to manage these automations for you, please reach out to Orbit support.

## Templates, Documents and Versions

It helps to keep two ideas separate. A **template** is the reusable recipe you design here; a `Document` is the actual file it produces.

* **Generating a template produces a versioned** `Document`**.** Each file Orbit files away keeps its history.
* **Regenerating does not create a duplicate.** Running the same document again — for example after a stop or address changes — adds a new version to the existing `Document` rather than leaving two competing files side by side. The latest version is what you'll see, with earlier ones retained.
* **A preview saves nothing.** The Live Preview and the Test Render let you check the layout with mock or real data, but they don't file anything against a `Tour`, `Order`, or `Shipment`. To keep a file, generate it as part of your workflow.
* **A blank field is normal.** If a placeholder has no value for the record you generated against, it simply renders empty — that's expected, not a broken template.

For how generated files are stored, pinned to Orders, Shipments, and Tours, and how proof of delivery is captured, see the **Documents & Proof of Delivery** article.

## Fonts & Typography

PDFs are rendered with a curated set of fonts that ships with the platform — nothing is loaded from the reader's device, so a document looks identical everywhere it is opened or printed.

**Web-safe font names resolve to metric-compatible equivalents.** The classic names keep working: text set in `Arial` or `Helvetica` renders as Liberation Sans, `Times New Roman` as Liberation Serif, and `Courier New` as Liberation Mono. These have identical character widths to their namesakes, so layouts designed against the originals do not shift.

| `font-family` value                  | Renders as                     | Best for                             |
| ------------------------------------ | ------------------------------ | ------------------------------------ |
| `Arial`, `Helvetica`                 | Liberation Sans                | General text                         |
| `Times New Roman`                    | Liberation Serif               | Serif and letter-style documents     |
| `Courier New`                        | Liberation Mono                | Reference numbers, tabular figures   |
| `Noto Sans`                          | Noto Sans                      | Modern sans with wide glyph coverage |
| `Roboto Condensed`                   | Roboto Condensed               | Dense tables and narrow columns      |
| `Open Sans`                          | Open Sans                      | The classic default sans             |
| `DejaVu Sans`                        | DejaVu Sans                    | Broad symbol coverage                |
| `sans-serif` / `serif` / `monospace` | Liberation Sans / Serif / Mono | Generic fallbacks                    |

Regular, **bold**, *italic*, and bold-italic are real font cuts for the Liberation and Noto Sans families (Roboto Condensed: regular and bold) — no artificial thickening or slanting.

**Symbols render reliably.** Glyphs such as ▲ ▼ → ❄ ✓ ● ★ fall back to symbol fonts automatically instead of disappearing. Not included by design: CJK scripts and colour emoji — load a custom font (below) if a document needs them.

> **Always set a** `font-family` **on** `body`**.** A template that sets none renders in a serif face — the standard behaviour of web browsers. The walkthrough examples on this page all start with `font-family: Arial, sans-serif`.

### Custom fonts with @font-face

Corporate fonts do not need to be pre-installed. Declare them with a publicly reachable URL and they are downloaded and embedded into the PDF as the document is generated:

```css
@font-face {
  font-family: "BrandFont";
  src: url("https://example.com/fonts/BrandFont-Regular.ttf");
}

body {
  font-family: "BrandFont", "Liberation Sans", sans-serif;
}
```

* The URL must be publicly accessible without authentication at the time the document is generated.
* TrueType and OpenType files are the recommended formats.
* Keep one of the provided families in the stack as a fallback — if the URL is unreachable, the text falls through to it instead of failing the render.
* Declare one `@font-face` per cut (regular, bold, italic) with matching `font-weight` / `font-style`; otherwise the styling is simulated.

## Reference

### Custom Filters

| Filter                     | Description                                                                                     | Max Length    | Example                                                               |
| -------------------------- | ----------------------------------------------------------------------------------------------- | ------------- | --------------------------------------------------------------------- |
| `code128`                  | Generates a Code 128 barcode (SVG).                                                             | \~2,000 chars | `{{ tour.id \| code128 }}`                                            |
| `qrcode`                   | Generates a QR Code (SVG).                                                                      | \~4,000 chars | `{{ "<https://orbit.com>" \| qrcode }}`                               |
| `datamatrix`               | Generates a Data Matrix code (SVG).                                                             | \~2,000 chars | `{{ load.id \| datamatrix }}`                                         |
| `formatDate`               | Formats a Unix timestamp. Locales: `'de'` (DD.MM.YYYY), `'en'` (YYYY-MM-DD).                    | —             | `{{ tour.latestStart \| formatDate: 'de' }}`                          |
| `generateLoadDeepLink`     | Builds a scannable link to one physical load. Takes the shipment id and tenant id as arguments. | —             | `{{ load.id \| generateLoadDeepLink: load.shipmentId, tour.tenant }}` |
| `generateShipmentDeepLink` | Builds a scannable link to a shipment. Takes the tenant id as an argument.                      | —             | `{{ load.shipmentId \| generateShipmentDeepLink: tour.tenant }}`      |
| `generateTourDeepLink`     | Builds a scannable link to a tour. Takes the tenant id as an argument.                          | —             | `{{ tour.id \| generateTourDeepLink: tour.tenant }}`                  |

> Note: If barcode content exceeds the maximum length, an error placeholder is rendered instead of crashing.

### Available Data Objects

| Data Object  | Template Variable | Description                                                                                                                                |
| ------------ | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| Tour         | `tour`            | Tour with stops, loads, timing, etc. Each load also carries a summary of the shipment it belongs to — see *Per-shipment detail on a tour*. |
| Order        | `order`           | Order details                                                                                                                              |
| Shipment     | `shipment`        | Shipment information                                                                                                                       |
| Carrier      | `carrier`         | Carrier company data                                                                                                                       |
| Carrier User | `carrierUser`     | Individual carrier user                                                                                                                    |
| Carrier Team | `carrierTeam`     | Carrier team information                                                                                                                   |

### Multi-Page CSS Properties

| Property                   | Description                                         |
| -------------------------- | --------------------------------------------------- |
| `page-break-after: always` | Forces a page break after the element               |
| `page-break-after: auto`   | Use on `:last-child` to prevent trailing empty page |

## Per-shipment detail on a tour

A tour carries many shipments, so you can't add `Shipment` as a second Data Object and expect it to line up with each load. Instead, every entry of `tour.loads` already carries a summary of the shipment that load belongs to:

| Field                              | Description                                                             |
| ---------------------------------- | ----------------------------------------------------------------------- |
| `load.shipment.shipperName`        | Name of the shipper who booked the shipment.                            |
| `load.shipment.extras`             | Handling requirements of the shipment, for example a temperature class. |
| `load.shipment.pickupCompanyName`  | Company at the shipment's pickup address.                               |
| `load.shipment.dropoffCompanyName` | Company at the shipment's dropoff address.                              |
| `load.shipment.displayName`        | The shipment's own label, when one is set.                              |
| `load.shipment.id`                 | The shipment id, for printing or encoding.                              |

This is what turns a loading list from a list of pallets into a document a driver and a warehouse team can act on — who the goods belong to, where they came from, where they go, and how they must be handled.

```html
{% for load in tour.loads %}
<tr>
  <td>{{ load.shipment.shipperName | default: "—" }}</td>
  <td>{{ load.shipment.dropoffCompanyName | default: "—" }}</td>
  <td>{{ load.count }}&times; {{ load.type }}</td>
  <td>{{ load.shipment.id | generateShipmentDeepLink: tour.tenant | qrcode }}</td>
</tr>
{% endfor %}
```

> These fields are filled in as the document is generated. If a shipment can't be resolved the fields simply render empty, so guard anything important with a `default` value as shown above.

## Scannable links

The three `generate…DeepLink` filters turn an id into a link that the Orbit driver app opens when scanned. Pipe the result into `qrcode` to put it on the page. Choose the level that matches what the row is: a row per pallet uses the load link, a row per shipment the shipment link, and the document footer the tour link.

> **Encode the link, not a bare id.** A plain id carries no resource reference, so scanning it opens nothing. This is the most common reason a document's codes turn out to be dead ends.
>
> **Give the code room.** A link encodes into a noticeably denser QR grid than a short id. Below roughly 12 mm the pattern gets too fine for phone cameras and office printers, so size the box accordingly and check a printed proof rather than trusting the on-screen preview.

## Troubleshooting

### Pages not separating in preview?

1. Ensure `page-break-after: always` is in a `<style>` block (not inline)
2. Check that the CSS selector matches your page elements
3. The rule must be exactly `page-break-after: always`

### Barcode showing error?

The content is too long. Code128 supports \~2,000 characters, QR codes \~4,000.

### Text renders in a serif font?

The template does not set a `font-family`, so the default serif face is used. Set one explicitly on `body` — for example `font-family: Arial, sans-serif;`. See **Fonts & Typography** above.

### Variables not rendering?

1. Check that you selected the correct Data Object in the toolbar
2. Verify the variable path exists (e.g., `tour.stops` not `tour.stop`)
3. Use `{% if variable %}` to handle optional fields

## External Documentation

* **LiquidJS:** For syntax, loops, and standard filters.

  [LiquidJS Documentation](https://liquidjs.com/)

* **CSS Paged Media:** For controlling page size and breaks.

  [Paged Media Docs (MDN)](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_paged_media)
