Jinja2PDF
Jahresbericht mit Inhaltsverzeichnis und Lesezeichen
Ein vierseitiger Bericht: ein Deckblatt mit verlinktem Inhaltsverzeichnis, Kennzahlen, ein Liniendiagramm mit zwei Jahren, Regionen nach Umsatz sortiert und ein Anhang mit Konten nach Region gruppiert. pdf.outline macht die Überschriften zu Lesezeichen, und keine Region wird über eine Seite getrennt.
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 Inhaltsverzeichnis auf dem Deckblatt verlinkt die Kapitel, und
pdf.outlinemacht aus den Überschriften PDF-Lesezeichen. Chromium 153 unterstützttarget-counter()nicht; das Verzeichnis nennt deshalb keine Seitenzahlen, Links und Lesezeichen übernehmen die Navigation. regions | sortBy('revenue', 'desc')stellt die größte Region nach vorn, und jeder Anteil wird in seiner Zeile berechnet: Das Vereinigte Königreich macht36.3%von£591,000aus.- Ein Liniendiagramm vergleicht zwei Jahre aus denselben Zeilen:
pluck('previous')undpluck('revenue')werden zu zwei Reihen, unddevicePixelRatio: 3in den Chart.js-Optionen hält es beim Zoomen im PDF scharf. - Im Anhang ist jede Region ein eigenes
tbodymitbreak-inside: avoid; eine Region wird also nie über Seiten getrennt, und die Spaltenköpfe wiederholen sich. Die Messungen zu Seitenumbrüchen zeigen, warum das funktioniert. - Die Fußzeile druckt den Dokumenttitel aus
pdf.metadata.titleüber Chromiums Klassetitle, neben der Seitenzahl. access: "public"in der Anfrage liefert einen schlichten CDN-Link für die Website, der funktioniert, solange die Datei aufbewahrt wird; wie lange das ist, entscheidet die Aufbewahrung Ihres Plans.
Verwendete Helfer
Vorlagenordner
{% set revenue = months | sum('revenue') %}
{% set previous = months | sum('previous') %}
{% set orders = months | sum('orders') %}
{% set best = months | sortBy('revenue', 'desc') | first %}
<section class="cover">
<p class="eyebrow">{{ report.company }}</p>
<h1>{{ report.title }}</h1>
<p class="lead">{{ report.subtitle }} · prepared {{ report.prepared | date('d MMMM yyyy') }}</p>
<nav class="contents" aria-label="Contents">
<p class="label">Contents</p>
<ol>
<li><a href="#summary">Summary</a><span>Revenue, orders and growth at a glance</span></li>
<li><a href="#months">Revenue by month</a><span>{{ report.year }} against {{ report.year - 1 }}, month by month</span></li>
<li><a href="#regions">Regions</a><span>Where the revenue came from, largest first</span></li>
<li><a href="#accounts">Largest accounts</a><span>The {{ accounts | length }} largest accounts, region by region</span></li>
</ol>
</nav>
</section>
<section class="chapter" id="summary">
<h2>Summary</h2>
<div class="kpis">
<div><span>Revenue</span><strong>£{{ revenue | number(0) }}</strong><em>+{{ ((revenue - previous) / previous * 100) | round(1) }}% on {{ report.year - 1 }}</em></div>
<div><span>Orders</span><strong>{{ orders | number(0) }}</strong><em>{{ customers }} customers</em></div>
<div><span>Average order</span><strong>{{ (revenue / orders) | money }}</strong><em>per order</em></div>
<div><span>Best month</span><strong>{{ best.label }}</strong><em>£{{ best.revenue | number(0) }}</em></div>
</div>
<p>{{ report.summary }}</p>
</section>
<section class="chapter" id="months">
<h2>Revenue by month</h2>
{{ chart({
type: 'line',
labels: months | pluck('label'),
series: [
{ label: report.year - 1, values: months | pluck('previous'), color: '#94a3b8' },
{ label: report.year, values: months | pluck('revenue'), color: '#4f46e5' }
],
options: { devicePixelRatio: 3 },
width: 640,
height: 260,
alt: 'Line chart of the revenue by month, ' ~ report.year ~ ' against ' ~ (report.year - 1)
}) }}
</section>
<section class="chapter" id="regions">
<h2>Regions</h2>
<table class="figures">
<thead><tr><th>Region</th><th class="num">Customers</th><th class="num">Orders</th><th class="num">Revenue</th><th>Share</th></tr></thead>
<tbody>
{% for region in regions | sortBy('revenue', 'desc') %}
{% set share = (region.revenue / revenue * 100) | round(1) %}
<tr>
<td>{{ region.name }}</td>
<td class="num">{{ region.customers }}</td>
<td class="num">{{ region.orders | number(0) }}</td>
<td class="num">£{{ region.revenue | number(0) }}</td>
<td class="share"><span class="bar" style="width: {{ share }}%"></span>{{ share }}%</td>
</tr>
{% endfor %}
</tbody>
<tbody class="total">
<tr><td>Total</td><td class="num">{{ regions | sum('customers') }}</td><td class="num">{{ regions | sum('orders') | number(0) }}</td><td class="num">£{{ regions | sum('revenue') | number(0) }}</td><td class="share">100%</td></tr>
</tbody>
</table>
</section>
<section class="chapter" id="accounts">
<h2>Largest accounts</h2>
<table class="figures accounts">
<thead><tr><th>Account</th><th class="num">Orders</th><th class="num">Revenue</th><th class="num">Average order</th></tr></thead>
{% for group in accounts | groupBy('region') %}
<tbody>
<tr class="group"><th colspan="4">{{ group.key }}</th></tr>
{% for account in group.items | sortBy('revenue', 'desc') %}
<tr>
<td>{{ account.account }}</td>
<td class="num">{{ account.orders }}</td>
<td class="num">£{{ account.revenue | number(0) }}</td>
<td class="num">{{ (account.revenue / account.orders) | money }}</td>
</tr>
{% endfor %}
<tr class="subtotal"><td>{{ group.items | length }} accounts</td><td class="num">{{ group.items | sum('orders') }}</td><td class="num">£{{ group.items | sum('revenue') | number(0) }}</td><td class="num">{{ ((group.items | sum('revenue')) / (group.items | sum('orders'))) | money }}</td></tr>
</tbody>
{% endfor %}
<tbody class="total">
<tr><td>All {{ accounts | length }} accounts</td><td class="num">{{ accounts | sum('orders') | number(0) }}</td><td class="num">£{{ accounts | sum('revenue') | number(0) }}</td><td class="num">{{ ((accounts | sum('revenue')) / (accounts | sum('orders'))) | money }}</td></tr>
</tbody>
</table>
</section>body { font-family: 'Inter', sans-serif; font-size: 9.5pt; line-height: 1.5; color: #1e293b; }
.cover { height: 250mm; display: flex; flex-direction: column; justify-content: center; }
.eyebrow { margin: 0; color: #4f46e5; font-weight: 600; letter-spacing: 0.15em; text-transform: uppercase; }
h1 { margin: 3mm 0 2mm; font-size: 38pt; line-height: 1.1; color: #0f172a; }
.lead { margin: 0; font-size: 12pt; color: #475569; }
.contents { margin-top: 26mm; padding-top: 6mm; border-top: 1px solid #cbd5e1; }
.contents .label { margin: 0 0 3mm; font-size: 8pt; font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase; color: #64748b; }
.contents ol { margin: 0; padding-left: 6mm; }
.contents li { margin-bottom: 3mm; font-size: 11pt; }
.contents a { color: #0f172a; font-weight: 600; text-decoration: none; }
.contents span { display: block; font-size: 9pt; color: #64748b; }
body { counter-reset: chapter; }
.chapter { counter-increment: chapter; margin-bottom: 10mm; }
h2 { margin: 0 0 5mm; font-size: 16pt; color: #0f172a; break-after: avoid; }
h2::before { content: counter(chapter) " "; color: #4f46e5; }
#summary { break-before: page; }
.kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4mm; margin-bottom: 6mm; }
.kpis div { padding: 4mm; border-radius: 2mm; background: #eef2ff; }
.kpis span, .kpis em { display: block; font-size: 8pt; font-style: normal; color: #64748b; }
.kpis strong { display: block; margin: 1mm 0; font-size: 15pt; color: #0f172a; }
.ff-chart { display: block; margin: 0 auto; }
.figures { width: 100%; border-collapse: collapse; }
.figures th, .figures td { padding: 1.8mm 2mm; border-bottom: 1px solid #e2e8f0; text-align: left; }
.figures thead th { border-bottom: 1.5px solid #0f172a; font-size: 7.5pt; letter-spacing: 0.05em; text-transform: uppercase; color: #64748b; }
.figures tbody { break-inside: avoid; }
.figures .group th { padding-top: 4mm; border-bottom: 1px solid #cbd5e1; font-size: 9pt; color: #4f46e5; }
.figures .subtotal td { color: #64748b; font-weight: 600; }
.figures .total td { border-top: 1.5px solid #0f172a; border-bottom: 0; font-weight: 700; }
.figures .total tr { break-before: avoid; } /* the total never stands alone on a page */
.accounts td { padding-top: 1.4mm; padding-bottom: 1.4mm; }
.num { text-align: right !important; white-space: nowrap; }
.share { width: 34mm; white-space: nowrap; font-size: 8.5pt; color: #475569; }
.share .bar { display: inline-block; height: 2.5mm; margin-right: 2mm; border-radius: 1mm; background: #4f46e5; vertical-align: middle; max-width: 18mm; }
.accounts { margin-top: 2mm; }<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap"><div style="width:100%;display:flex;justify-content:space-between;font-size:7.5px;color:#64748b">
<span class="title"></span>
<span>Page <span class="pageNumber"></span> of <span class="totalPages"></span></span>
</div>{
"paper": {
"format": "A4"
},
"margin": {
"top": "20mm",
"right": "18mm",
"bottom": "22mm",
"left": "18mm"
},
"pdf": {
"metadata": {
"title": "{{ report.title }} · {{ report.company }}",
"author": "Fennlor Studio Ltd"
},
"tagged": true,
"outline": true
},
"locale": "en-GB",
"timezone": "Europe/London",
"currency": "GBP",
"footer": {
"height": "12mm"
}
}{
"report": {
"company": "Fennlor Studio Ltd",
"title": "Annual report 2025",
"subtitle": "Sales, regions and accounts",
"year": 2025,
"prepared": "2026-03-16",
"summary": "Revenue grew in every month of the year. The last quarter carried the growth, with the new pendant range in its first full season, and the United Kingdom stayed the largest region with more than a third of revenue."
},
"customers": 425,
"months": [
{
"label": "Jan",
"revenue": 41250,
"previous": 36100,
"orders": 296
},
{
"label": "Feb",
"revenue": 39800,
"previous": 35400,
"orders": 281
},
{
"label": "Mar",
"revenue": 46120,
"previous": 39800,
"orders": 327
},
{
"label": "Apr",
"revenue": 44300,
"previous": 38950,
"orders": 314
},
{
"label": "May",
"revenue": 47900,
"previous": 41200,
"orders": 339
},
{
"label": "Jun",
"revenue": 49650,
"previous": 42700,
"orders": 351
},
{
"label": "Jul",
"revenue": 45200,
"previous": 40100,
"orders": 318
},
{
"label": "Aug",
"revenue": 42750,
"previous": 38300,
"orders": 301
},
{
"label": "Sep",
"revenue": 51380,
"previous": 44900,
"orders": 362
},
{
"label": "Oct",
"revenue": 54900,
"previous": 47300,
"orders": 388
},
{
"label": "Nov",
"revenue": 61240,
"previous": 52800,
"orders": 431
},
{
"label": "Dec",
"revenue": 66510,
"previous": 57450,
"orders": 468
}
],
"regions": [
{
"name": "Germany",
"customers": 96,
"orders": 941,
"revenue": 131950
},
{
"name": "France",
"customers": 47,
"orders": 455,
"revenue": 64120
},
{
"name": "United Kingdom",
"customers": 148,
"orders": 1512,
"revenue": 214300
},
{
"name": "Nordics",
"customers": 39,
"orders": 412,
"revenue": 58370
},
{
"name": "Netherlands",
"customers": 51,
"orders": 509,
"revenue": 72480
},
{
"name": "Rest of Europe",
"customers": 44,
"orders": 347,
"revenue": 49780
}
],
"accounts": [
{
"account": "A-1003",
"region": "United Kingdom",
"orders": 61,
"revenue": 28400
},
{
"account": "A-1007",
"region": "Germany",
"orders": 49,
"revenue": 21300
},
{
"account": "A-1011",
"region": "United Kingdom",
"orders": 55,
"revenue": 24150
},
{
"account": "A-1012",
"region": "Netherlands",
"orders": 33,
"revenue": 14800
},
{
"account": "A-1015",
"region": "France",
"orders": 29,
"revenue": 12900
},
{
"account": "A-1018",
"region": "Nordics",
"orders": 31,
"revenue": 13600
},
{
"account": "A-1021",
"region": "Germany",
"orders": 41,
"revenue": 17650
},
{
"account": "A-1024",
"region": "United Kingdom",
"orders": 47,
"revenue": 19800
},
{
"account": "A-1026",
"region": "Rest of Europe",
"orders": 22,
"revenue": 9800
},
{
"account": "A-1030",
"region": "Germany",
"orders": 34,
"revenue": 15200
},
{
"account": "A-1033",
"region": "United Kingdom",
"orders": 38,
"revenue": 16920
},
{
"account": "A-1036",
"region": "Netherlands",
"orders": 27,
"revenue": 11900
},
{
"account": "A-1039",
"region": "France",
"orders": 24,
"revenue": 10400
},
{
"account": "A-1041",
"region": "United Kingdom",
"orders": 33,
"revenue": 14300
},
{
"account": "A-1044",
"region": "Nordics",
"orders": 25,
"revenue": 10900
},
{
"account": "A-1046",
"region": "Germany",
"orders": 28,
"revenue": 12400
},
{
"account": "A-1049",
"region": "United Kingdom",
"orders": 29,
"revenue": 12650
},
{
"account": "A-1052",
"region": "Netherlands",
"orders": 22,
"revenue": 9650
},
{
"account": "A-1055",
"region": "Rest of Europe",
"orders": 17,
"revenue": 7300
},
{
"account": "A-1057",
"region": "Germany",
"orders": 23,
"revenue": 10150
},
{
"account": "A-1060",
"region": "United Kingdom",
"orders": 26,
"revenue": 11200
},
{
"account": "A-1062",
"region": "France",
"orders": 20,
"revenue": 8800
},
{
"account": "A-1065",
"region": "Nordics",
"orders": 19,
"revenue": 8200
},
{
"account": "A-1068",
"region": "United Kingdom",
"orders": 22,
"revenue": 9850
},
{
"account": "A-1070",
"region": "Germany",
"orders": 20,
"revenue": 8900
},
{
"account": "A-1073",
"region": "Netherlands",
"orders": 18,
"revenue": 8100
},
{
"account": "A-1075",
"region": "United Kingdom",
"orders": 19,
"revenue": 8400
},
{
"account": "A-1078",
"region": "France",
"orders": 15,
"revenue": 6700
},
{
"account": "A-1080",
"region": "Germany",
"orders": 17,
"revenue": 7400
},
{
"account": "A-1083",
"region": "Rest of Europe",
"orders": 13,
"revenue": 5900
},
{
"account": "A-1085",
"region": "United Kingdom",
"orders": 16,
"revenue": 7120
},
{
"account": "A-1088",
"region": "Nordics",
"orders": 15,
"revenue": 6400
},
{
"account": "A-1090",
"region": "Netherlands",
"orders": 14,
"revenue": 6300
},
{
"account": "A-1093",
"region": "Germany",
"orders": 14,
"revenue": 6150
},
{
"account": "A-1095",
"region": "France",
"orders": 12,
"revenue": 5500
},
{
"account": "A-1098",
"region": "Netherlands",
"orders": 12,
"revenue": 5200
}
]
}Ü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: "annual-report", data: ., filename: "annual-report-2025.pdf", access: "public"}' 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: 'annual-report',
data,
filename: 'annual-report-2025.pdf',
access: 'public',
});
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="annual-report",
data=data,
filename="annual-report-2025.pdf",
access="public",
)
print(render.status, render.download_url)<?php
$body = [
'template' => 'annual-report',
'data' => json_decode(file_get_contents('data.json'), true),
'filename' => 'annual-report-2025.pdf',
'access' => 'public',
];
$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": "annual-report",
"data": json.RawMessage(data),
"filename": "annual-report-2025.pdf",
"access": "public",
})
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 = "annual-report",
data = JsonDocument.Parse(File.ReadAllText("data.json")).RootElement,
filename = "annual-report-2025.pdf",
access = "public",
};
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": "annual-report",
"filename": "annual-report-2025.pdf",
"access": "public",
"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: "annual-report",
data: JSON.parse(File.read("data.json")),
filename: "annual-report-2025.pdf",
access: "public",
}
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.

