Zum Inhalt springen

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

PDF · 4 Seiten · 431 KB ↓ herunterladen

Was dieses Beispiel zeigt

  • Das Inhaltsverzeichnis auf dem Deckblatt verlinkt die Kapitel, und pdf.outline macht aus den Überschriften PDF-Lesezeichen. Chromium 153 unterstützt target-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 macht 36.3% von £591,000 aus.
  • Ein Liniendiagramm vergleicht zwei Jahre aus denselben Zeilen: pluck('previous') und pluck('revenue') werden zu zwei Reihen, und devicePixelRatio: 3 in den Chart.js-Optionen hält es beim Zoomen im PDF scharf.
  • Im Anhang ist jede Region ein eigenes tbody mit break-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 Klasse title, 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

Mehr zu diesem Anwendungsfall: Report-PDFs erzeugen →

Vorlagenordner

templates/annual-report/template.htmlJinja2
{% 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>
templates/annual-report/style.cssCSS
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; }
templates/annual-report/head.htmlHTML
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap">
templates/annual-report/footer.htmlJinja2
<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>
templates/annual-report/settings.jsonJSON
{
  "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"
  }
}
templates/annual-report/data/default.jsonJSON
{
  "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.

render.shShell
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 @-
render.tsTypeScript
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);
render.pyPython
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)
render.phpPHP
<?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;
main.goGo
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)
}
Program.csC#
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")}");
Render.javaJava
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 …
  }
}
render.rbRuby
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.