Skip to content

Jinja2PDF

Annual report with contents and bookmarks

A four-page report: a cover with a linked table of contents, KPI cards, a line chart comparing two years, regions sorted by revenue and an appendix of accounts grouped by region. pdf.outline turns the headings into bookmarks, and no region is split across a page.

Free plan, no card. You get your own copy in the editor, published and ready for the API.

Rendered result

PDF · 4 pages · 431 KB ↓ download

What this example shows

  • The contents on the cover link to the chapters, and pdf.outline turns the headings into PDF bookmarks. Chromium 153 does not support target-counter(), so the contents give no page numbers; links and bookmarks do the navigating.
  • regions | sortBy('revenue', 'desc') puts the largest region first, and each share is computed in its row: the United Kingdom is 36.3% of £591,000.
  • One line chart compares two years from the same rows: pluck('previous') and pluck('revenue') become two series, and devicePixelRatio: 3 in the Chart.js options keeps it sharp when the PDF is zoomed.
  • In the appendix each region is its own tbody with break-inside: avoid, so a region never splits across pages and the column headings repeat. The page-break measurements show why that works.
  • The footer prints the document title from pdf.metadata.title through Chromium’s title class, beside the page number.
  • access: "public" in the request returns a plain CDN link to put on a website, which works for as long as the file is kept: the retention of your plan decides how long that is.

Helpers used

More on this use case: Report PDF generation →

Template folder

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
    }
  ]
}

Render it through the API

Save the sample data as data.json and send it with the template’s name. TypeScript and Python use the SDKs; every other language posts the same JSON to the REST API with its standard library. Renders with a key that starts with ff_test_ are not metered; on the Free plan they carry a small watermark.

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"]

Start rendering today

The free plan includes 100 units a month and free test renders. Change plans when you need more.