Resume builder for academics and engineers with a smart AI assistant

02 Jun, 2026

Share on

Generating PDFs from Structured Data with Python and Typst

Skip headless Chrome and brittle LaTeX. Render a Typst file from Jinja2 templates and compile it to a PDF in milliseconds with typst-py.

Generating a PDF from structured data is one of those tasks that looks trivial until you ship it. You have a dictionary, a database row, or a validated object, and you need a clean, paginated, printable document on the other end. Invoices, reports, certificates, resumes, statements. The data is easy. The typesetting is where teams reach for the wrong tool and pay for it in production.

This post walks through the approach RenderCV uses internally: keep your data structured, render a Typst source file from Jinja2 templates, then compile that source to PDF with the typst-py bindings. It is fast, deterministic, and has a single dependency. By the end you will have a self-contained snippet you can adapt to your own data.

The patterns most teams get wrong

There are three common approaches to programmatic PDF generation, and all three have sharp edges.

HTML to PDF with headless Chrome. You render an HTML page, then drive Puppeteer or Playwright to print it to PDF. This works, sort of, but you are now shipping a full browser as a dependency. A typical headless Chrome install is hundreds of megabytes. Startup is slow, page rendering is slow, and you are at the mercy of how a browser's print engine paginates content. Page breaks land in the middle of table rows. Headers and footers require hacks. The same input can produce subtly different output across Chrome versions, which is a nightmare for anything that needs to look identical every time. And you are running an entire browser to lay out text.

String concatenation. You build the document by gluing strings together, whether that is HTML, RTF, or raw PDF operators. This starts as ten lines and grows into an unmaintainable thicket of conditionals and whitespace management. Every optional field is an if. Every layout tweak risks breaking the escaping. There is no separation between what the document says and how it looks, so a designer can never touch it and a small change ripples everywhere.

Wrestling LaTeX. LaTeX produces beautiful output, and for decades it was the only serious option for programmatic typesetting. But it is a multi-gigabyte install, it compiles in seconds (sometimes across multiple passes), and its error messages are famously cryptic. Debugging a broken .tex file generated by a template, at scale, in CI, is not where you want to spend your afternoons. We cover the full trade-off in Typst vs LaTeX for CVs.

The cleaner approach keeps two things separate that the patterns above tend to merge: your data and your layout. Data stays structured. Layout lives in a template. A typesetting engine joins them and emits a PDF.

The approach: structured data, Jinja2, Typst

The pipeline has three stages.

structured data  ->  Typst source (.typ)  ->  PDF
                 ^                        ^
               Jinja2                   typst-py
  1. Keep the data structured. A dict, a dataclass, a Pydantic model. Whatever you already have. Do not flatten it into strings prematurely.
  2. Render a Typst source file with Jinja2. The template is the layout. It loops over your data and emits Typst markup. This is the data/layout separation that makes the whole thing maintainable.
  3. Compile the Typst source to PDF with typst-py. One function call, milliseconds of work, no browser, no TeX distribution.

Typst is a typesetting language, the same way LaTeX is. You write a .typ text file describing content and layout, and the compiler turns it into a PDF. Its syntax reads closer to Markdown than to LaTeX, which matters when you are generating it from a template and want to keep the template readable.

Why Typst over LaTeX or HTML/CSS

Four reasons, all practical:

  • Speed. Typst compiles a normal document in well under 100 milliseconds. LaTeX takes seconds. Headless Chrome takes seconds plus the cost of spinning up a browser. When you are generating PDFs on a request or in a tight loop, this is the difference between a snappy API and a queue.
  • Deterministic output. The same source produces the same bytes. There is no browser version drift, no float-rounding differences between machines. For documents that must look identical every time, this is non-negotiable.
  • A real text layer. The output is real, selectable, searchable, accessible text with proper font embedding, not an image of text and not a fragile approximation. Copy-paste works. Screen readers work. Indexing works.
  • A single dependency. typst-py bundles the compiler. There is no system package to install, no TeX Live, no Chrome binary to provision in your Docker image. pip install typst and you are done.

A worked example

Let's build a small but complete example: render a one-page invoice from a Python dict. The shape generalizes to any document.

Install the two dependencies:

pip install jinja2 typst

First, the Typst template. Save this as invoice.typ.j2. It is plain Typst with Jinja2 placeholders woven in. Note how the layout (margins, fonts, the table, the total) lives entirely here, separate from any data.

#set page(margin: 2cm)
#set text(font: "Linux Libertine", size: 11pt)

= Invoice #{{ invoice.number }}

#grid(
  columns: (1fr, 1fr),
  [
    *Billed to:* \
    {{ invoice.client.name }} \
    {{ invoice.client.email }}
  ],
  [
    #set align(right)
    *Date:* {{ invoice.date }} \
    *Due:* {{ invoice.due_date }}
  ],
)

#v(1em)

#table(
  columns: (1fr, auto, auto),
  align: (left, right, right),
  table.header([*Item*], [*Qty*], [*Amount*]),
  {% for item in invoice.items %}
  [{{ item.description }}], [{{ item.quantity }}], [${{ "%.2f"|format(item.amount) }}],
  {% endfor %}
)

#v(1em)
#align(right)[
  *Total: ${{ "%.2f"|format(invoice.total) }}*
]

A few things worth pointing out. #set page(...) and #set text(...) are Typst's way of configuring document defaults. #table and #grid are built-in layout functions. The Jinja2 {% for %} loop emits one table row per line item, and Jinja2's format filter handles money formatting so the template controls presentation. Anything Typst-specific (escaping, special characters) you handle in the template or in a small filter, which keeps the Python side clean.

Now the Python code that fills the template and compiles it:

import tempfile
from pathlib import Path

import jinja2
import typst

# 1. Your structured data. This could come from a database,
#    a Pydantic model dumped to a dict, an API payload, anything.
invoice = {
    "number": "2026-0042",
    "date": "2026-06-02",
    "due_date": "2026-07-02",
    "client": {"name": "Acme Corp", "email": "ap@acme.example"},
    "items": [
        {"description": "Design consultation", "quantity": 8, "amount": 1200.00},
        {"description": "Implementation", "quantity": 40, "amount": 6000.00},
    ],
    "total": 7200.00,
}

# 2. Render the Typst source from the template.
env = jinja2.Environment(
    loader=jinja2.FileSystemLoader("templates"),
    # Trim Jinja2's own whitespace so the generated .typ stays tidy.
    trim_blocks=True,
    lstrip_blocks=True,
)
template = env.get_template("invoice.typ.j2")
typst_source = template.render(invoice=invoice)

# 3. Compile the Typst source to a PDF.
with tempfile.TemporaryDirectory() as tmp:
    typ_path = Path(tmp) / "invoice.typ"
    typ_path.write_text(typst_source, encoding="utf-8")
    typst.compile(typ_path, output="invoice.pdf")

print("Wrote invoice.pdf")

That is the whole thing. The typst.compile() call does the heavy lifting, and it returns in milliseconds. If you would rather not touch the filesystem, typst-py can also return the PDF as bytes, which is ideal for serving from a web handler:

pdf_bytes = typst.compile(typ_path)  # returns bytes when output is omitted

Wrap that in a Flask, FastAPI, or Django response and you have a PDF endpoint with no browser in sight.

Handling Markdown in your data

A common wrinkle: your structured data contains rich text. A description field might hold **bold** or [a link](https://example.com), written by a user or stored in a CMS. Typst does not understand Markdown syntax, so you convert it. Parse the Markdown into a tree, then walk the tree and emit the Typst equivalent: #strong[...] for bold, #emph[...] for italics, #link("url")[text] for links.

This Markdown-to-Typst step is small but powerful. It lets the people writing your data use a familiar, safe syntax while the rendering layer stays in Typst. RenderCV does exactly this; you can see the real implementation in its templater source, which renders Jinja2 templates and converts Markdown fields to Typst before compilation.

Why the separation pays off

The reason this pattern holds up where string concatenation collapses is the boundary between data and layout.

Your data stays in its natural shape, validated by whatever you already use (Pydantic is a great fit here). Your layout lives in one or more templates that anyone comfortable with Typst can edit without reading your Python. Want a different look? Swap the template, not the code. Want two themes? Two template directories. Want to let users customize their own document? Let them override the template file. This is the same idea behind treating your resume as code: the content is structured data, and rendering is a deterministic transform applied on top of it.

It also composes well. Because each stage is independent, you can test the template render (does the right Typst come out for this data?) separately from the compile step (does this Typst produce a valid PDF?). Failures are localized. A bad field shows up in the rendered source, not as a mysterious blank page.

This exact architecture, structured data validated by Pydantic, Jinja2 templates emitting Typst, and typst-py compiling to PDF, is how RenderCV turns a YAML file into a typeset CV. If you want a deeper look at the full pipeline, including the validation and Markdown stages, see how RenderCV works.

Where to go from here

The snippet above is a complete starting point. To take it further:

  • Read the Typst docs to learn the layout primitives: grid, table, stack, place, and the #set and #show rules that drive document-wide styling.
  • Explore typst-py for options like passing fonts, setting the root directory for #import, and choosing PDF versus PNG or SVG output.
  • Move shared styling into a Typst file you #import, so your templates stay focused on content and your look-and-feel lives in one place.

The principle is the part to keep: do not let your data and your layout bleed into each other, and do not run a browser to lay out text. Keep the data structured, template the Typst, compile in milliseconds.

That is precisely the engine behind RenderCV, which is open source at github.com/rendercv/rendercv. If you want to see this approach running in production rather than build it from scratch, that is a good place to start.

Share this article