LiquidPDF
Kassenbon auf einer 80-mm-Rolle
Ein Kassenbon für Thermodrucker, dessen Seite genau so lang ist wie der Bon: Die Vorlage schreibt ihre @page-Größe selbst, aus der Zahl der Artikel und Steuersätze. Die Preise sind Bruttopreise, und die Aufschlüsselung je Steuersatz entsteht in der Vorlage mit where und sum.
Kostenloser Plan, keine Kreditkarte. Sie erhalten eine eigene Kopie im Editor, veröffentlicht und bereit für die API.
Gerendertes Ergebnis
Was dieses Beispiel zeigt
- Das Papier ist 80 mm breit und so lang wie der Bon: Die Vorlage schreibt
@page { size: 80mm {{ length }}mm; }aus der Zahl der Artikel und Steuersätze, undpreferCssPageSize: truelässt diese Regel entscheiden. Vier Artikel und zwei Sätze ergeben 160 mm auf einer Seite. - Jede Zeile hat eine feste Höhe, und ein zu langer Artikelname endet mit Auslassungspunkten, statt umzubrechen; erst das macht die Länge berechenbar.
- Die Preise sind brutto, die Aufschlüsselung rechnet also rückwärts:
where: 'vat', rate.code | sum: 'total'sammelt den Bruttobetrag eines Satzes, undtimes: rate.rate | divided_by: divisorzieht die Steuer heraus,3.13von18.80bei 20 %. barcode: type: 'code128'druckt die Bonnummer für den Umtausch,qrcodeverweist auf die digitale Kopie, unddateAdd: 30, 'days'ergibt den letzten Umtauschtag.- Uhrzeiten stehen in der Zeitzone
Europe/Londonder Vorlage:07:41:00Zaus den Daten wird zu08:41.
Verwendete Helfer
Vorlagenordner
{% comment %} The roll is as long as the receipt: fixed blocks plus 5 mm per item and 4.5 mm per VAT rate. {% endcomment %}
{% assign items = receipt.lines | size %}
{% assign rates = vat_rates | size | times: 4.5 %}
{% assign length = items | times: 5 | plus: 131 | plus: rates %}
<style>@page { size: 80mm {{ length }}mm; }</style>
<header class="shop">
<h1>{{ shop.name }}</h1>
<p>{{ shop.street }}, {{ shop.city }}</p>
<p>VAT {{ shop.vat_id }}</p>
</header>
<dl class="meta">
<div><dt>Date</dt><dd>{{ receipt.issued | date: 'd MMM yyyy, HH:mm' }}</dd></div>
<div><dt>Receipt</dt><dd>{{ receipt.number }}</dd></div>
<div><dt>Till</dt><dd>{{ receipt.till }} · {{ receipt.served_by }}</dd></div>
</dl>
<ul class="items">
{% for line in receipt.lines %}
<li>
<span class="name">{{ line.qty }} × {{ line.name }}{% if line.qty > 1 %} @ {{ line.price | number: 2 }}{% endif %}</span>
<span>{{ line.total | number: 2 }}</span>
<span class="code">{{ line.vat }}</span>
</li>
{% endfor %}
</ul>
<div class="totals">
<p class="grand"><span>Total</span><span>{{ receipt.lines | sum: 'total' | money }}</span></p>
<p><span>{{ receipt.payment.method }} {{ receipt.payment.card }}</span><span>{{ receipt.payment.amount | money }}</span></p>
</div>
<table class="vat">
<tr><th>VAT rate</th><th>Net</th><th>VAT</th><th>Gross</th></tr>
{% for rate in vat_rates %}
{% assign gross = receipt.lines | where: 'vat', rate.code | sum: 'total' %}
{% assign divisor = rate.rate | plus: 100 %}
{% assign vat = gross | times: rate.rate | divided_by: divisor %}
<tr>
<td>{{ rate.code }} {{ rate.rate }}%</td>
<td>{{ gross | minus: vat | number: 2 }}</td>
<td>{{ vat | number: 2 }}</td>
<td>{{ gross | number: 2 }}</td>
</tr>
{% endfor %}
</table>
<div class="codes">
<img class="barcode" src="{{ receipt.number | barcode: type: 'code128', height: 9 }}" alt="Receipt number">
<div class="returns">
<img src="{{ receipt.url | qrcode: size: 80 }}" alt="Digital receipt">
<p>Returns and exchanges until {{ receipt.issued | dateAdd: 30, 'days' | date: 'd MMMM yyyy' }} with this receipt. Scan the code for a digital copy.</p>
</div>
</div>
<p class="thanks">Thank you for visiting</p>body { margin: 0; font-family: 'IBM Plex Mono', monospace; font-size: 7.5pt; color: #111827; }
.shop { height: 22mm; box-sizing: border-box; padding-top: 3mm; border-bottom: 1px dashed #111827; text-align: center; }
.shop h1 { margin: 0 0 2mm; font-size: 12pt; font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; }
.shop p { margin: 0; line-height: 4mm; }
.meta { height: 17mm; box-sizing: border-box; margin: 0; padding: 2mm 0; border-bottom: 1px dashed #111827; }
.meta div { display: flex; justify-content: space-between; line-height: 4.3mm; }
.meta dt, .meta dd { margin: 0; }
.items { margin: 0; padding: 2mm 0; list-style: none; border-bottom: 1px dashed #111827; }
.items li { display: flex; gap: 2mm; height: 5mm; line-height: 5mm; }
.items .name { flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.items .code { width: 3mm; }
.totals { height: 16mm; box-sizing: border-box; padding: 1.5mm 0; border-bottom: 1px dashed #111827; }
.totals p { display: flex; justify-content: space-between; margin: 0; line-height: 5mm; }
.totals .grand { font-size: 10.5pt; font-weight: 600; line-height: 7mm; }
.vat { width: 100%; margin: 2mm 0; border-collapse: collapse; }
.vat th, .vat td { height: 4.5mm; padding: 0; text-align: right; font-weight: 400; }
.vat th:first-child, .vat td:first-child { text-align: left; }
.vat th { color: #6b7280; }
.codes { height: 44mm; box-sizing: border-box; padding-top: 2mm; border-top: 1px dashed #111827; }
.barcode { display: block; height: 13mm; margin: 0 auto 3mm; }
.returns { display: flex; align-items: center; gap: 3mm; }
.returns img { width: 22mm; height: 22mm; }
.returns p { margin: 0; font-size: 6.5pt; line-height: 3.4mm; }
.thanks { height: 8mm; margin: 0; line-height: 8mm; text-align: center; font-weight: 600; }<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&display=swap">{
"pdf": {
"metadata": {
"title": "Receipt · {{ shop.name }}"
}
},
"paper": {
"width": "80mm",
"height": "200mm"
},
"preferCssPageSize": true,
"margin": {
"top": "4mm",
"right": "4mm",
"bottom": "4mm",
"left": "4mm"
},
"locale": "en-GB",
"timezone": "Europe/London",
"currency": "GBP"
}{
"shop": {
"name": "Fennlor Coffee",
"street": "1 Example Street",
"city": "Exampletown",
"vat_id": "GB000000000"
},
"receipt": {
"number": "R-2026-004721",
"issued": "2026-09-28T07:41:00Z",
"till": 2,
"served_by": "Jane",
"url": "https://fennlor.example/r/R-2026-004721",
"lines": [
{
"name": "Flat white",
"qty": 2,
"price": 3.4,
"total": 6.8,
"vat": "A"
},
{
"name": "Almond croissant",
"qty": 1,
"price": 3.1,
"total": 3.1,
"vat": "Z"
},
{
"name": "Coffee beans, house blend 250 g",
"qty": 1,
"price": 8.5,
"total": 8.5,
"vat": "Z"
},
{
"name": "Enamel mug, sand",
"qty": 1,
"price": 12,
"total": 12,
"vat": "A"
}
],
"payment": {
"method": "Card",
"card": "•••• 0000",
"amount": 30.4
}
},
"vat_rates": [
{
"code": "A",
"rate": 20
},
{
"code": "Z",
"rate": 0
}
]
}Über die API rendern
Speichern Sie die Beispieldaten als data.json und senden Sie sie mit dem Namen der Vorlage. TypeScript und Python nutzen die SDKs; jede andere Sprache schickt dasselbe JSON mit ihrer Standardbibliothek an die REST-API. Renderings mit einem Schlüssel, der mit ff_test_ beginnt, werden nicht gezählt; im Free-Tarif tragen sie ein kleines Wasserzeichen.
jq '{template: "receipt", data: ., filename: "receipt-R-2026-004721.pdf"}' data.json \
| curl https://api-eu.formfeed.dev/v1/renders \
-H "Authorization: Bearer ff_test_..." \
-H "Content-Type: application/json" \
--data-binary @-import { Formfeed } from '@formfeed/sdk';
import data from './data.json' with { type: 'json' };
const formfeed = new Formfeed({ apiKey: process.env.FORMFEED_API_KEY! });
const render = await formfeed.renders.create({
template: 'receipt',
data,
filename: 'receipt-R-2026-004721.pdf',
});
console.log(render.status, render.download_url);import json
import os
from formfeed import Formfeed
formfeed = Formfeed(api_key=os.environ["FORMFEED_API_KEY"])
with open("data.json", encoding="utf-8") as file:
data = json.load(file)
render = formfeed.renders.create(
template="receipt",
data=data,
filename="receipt-R-2026-004721.pdf",
)
print(render.status, render.download_url)<?php
$body = [
'template' => 'receipt',
'data' => json_decode(file_get_contents('data.json'), true),
'filename' => 'receipt-R-2026-004721.pdf',
];
$curl = curl_init('https://api-eu.formfeed.dev/v1/renders');
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('FORMFEED_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode($body),
]);
$render = json_decode(curl_exec($curl), true);
echo $render['status'], ' ', $render['download_url'], PHP_EOL;package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
func main() {
data, err := os.ReadFile("data.json")
if err != nil {
panic(err)
}
body, _ := json.Marshal(map[string]any{
"template": "receipt",
"data": json.RawMessage(data),
"filename": "receipt-R-2026-004721.pdf",
})
request, _ := http.NewRequest("POST", "https://api-eu.formfeed.dev/v1/renders", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+os.Getenv("FORMFEED_API_KEY"))
request.Header.Set("Content-Type", "application/json")
response, err := http.DefaultClient.Do(request)
if err != nil {
panic(err)
}
defer response.Body.Close()
var render struct {
Status string `json:"status"`
DownloadURL string `json:"download_url"`
}
json.NewDecoder(response.Body).Decode(&render)
fmt.Println(render.Status, render.DownloadURL)
}using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
var body = new
{
template = "receipt",
data = JsonDocument.Parse(File.ReadAllText("data.json")).RootElement,
filename = "receipt-R-2026-004721.pdf",
};
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("FORMFEED_API_KEY"));
var response = await http.PostAsJsonAsync("https://api-eu.formfeed.dev/v1/renders", body);
var render = await response.Content.ReadFromJsonAsync<JsonElement>();
Console.WriteLine($"{render.GetProperty("status")} {render.GetProperty("download_url")}");import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
public class Render {
public static void main(String[] args) throws Exception {
String data = Files.readString(Path.of("data.json"));
String body = """
{
"template": "receipt",
"filename": "receipt-R-2026-004721.pdf",
"data": %s
}
""".formatted(data);
HttpRequest request = HttpRequest.newBuilder(URI.create("https://api-eu.formfeed.dev/v1/renders"))
.header("Authorization", "Bearer " + System.getenv("FORMFEED_API_KEY"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body()); // status, download_url, page_count, units …
}
}require "json"
require "net/http"
body = {
template: "receipt",
data: JSON.parse(File.read("data.json")),
filename: "receipt-R-2026-004721.pdf",
}
response = Net::HTTP.post(
URI("https://api-eu.formfeed.dev/v1/renders"),
JSON.generate(body),
"Authorization" => "Bearer #{ENV.fetch("FORMFEED_API_KEY")}",
"Content-Type" => "application/json"
)
render = JSON.parse(response.body)
puts render["status"], render["download_url"]Heute mit dem Rendern beginnen
Der kostenlose Plan enthält 100 Einheiten im Monat und kostenlose Testrenderings. Wechseln Sie den Plan, wenn Sie mehr brauchen.
