M-Bus Gateway
← Tilbage til blog
· WeasyPrint· PDF· Python· Jinja2· HTML· CSS· Celery· Docker· afregning

WeasyPrint PDF-generering i Python — afregninger og rapporter

WeasyPrint til PDF-generering i Python: HTML/CSS templates, CSS @page print layout, Jinja2 integration, Docker-opsætning, fonter og Celery async generering.

Af M-Bus Gateway

M-Bus Gateway platformen bruger WeasyPrint til at generere PDF-afregninger til lejere. Her er implementeringen der producerer professionelle PDF'er fra HTML/CSS templates.


Hvorfor WeasyPrint frem for alternativer?

Alternativer vurderet:

ReportLab:
  ✓ Hurtig, modnet
  ✗ Programmeres direkte i Python — svær at style
  ✗ Ingen HTML/CSS support

wkhtmltopdf:
  ✓ Kendt
  ✗ Kræver Qt/WebKit headless browser
  ✗ Dårlig Unicode/dansk support
  ✗ Ikke aktivt vedligeholdt

fpdf2:
  ✓ Letvægt
  ✗ Ingen CSS-layout

WeasyPrint:
  ✓ Ren Python (ingen headless browser)
  ✓ CSS Paged Media standard (@page)
  ✓ Perfekt dansk Unicode + fonter
  ✓ Aktivt vedligeholdt
  ✓ Docker-venlig
  ✗ Langsommere end ReportLab (~1-3 sek pr. side)
  ✗ Ikke al CSS3 understøttet (ingen flexbox grid)

Jinja2 template-struktur

server/src/templates/
├── base.html              ← CSS-variabler, @font-face, @page
├── settlement.html        ← Afregningsskabelon (extends base)
├── settlement_line.html   ← Én lejligheds-linje (macro)
└── annual_report.html     ← Årsrapport (extends base)
<!-- server/src/templates/base.html -->
<!DOCTYPE html>
<html lang="da">
<head>
  <meta charset="UTF-8">
  <style>
    /* CSS Paged Media — kontrollér sideskift og marginer: */
    @page {
      size: A4;
      margin: 2cm 2cm 3cm 2cm;

      @top-right {
        content: "Side " counter(page) " af " counter(pages);
        font-size: 9pt;
        color: #666;
      }

      @bottom-center {
        content: "M-Bus Gateway · CVR 12345678 · mbus-gateway.dk";
        font-size: 8pt;
        color: #999;
      }
    }

    /* Undgå sideskift midt i tabeller: */
    tr { page-break-inside: avoid; }
    h2, h3 { page-break-after: avoid; }

    /* Dansk font med æøå: */
    @font-face {
      font-family: "Inter";
      src: url("/fonts/Inter-Regular.woff2") format("woff2");
    }

    body {
      font-family: "Inter", "Helvetica Neue", sans-serif;
      font-size: 10pt;
      color: #1a1a1a;
      line-height: 1.5;
    }

    {% block extra_css %}{% endblock %}
  </style>
</head>
<body>
  {% block content %}{% endblock %}
</body>
</html>

Afregnings-template

<!-- server/src/templates/settlement.html -->
{% extends "base.html" %}

{% block extra_css %}
.header { display: table; width: 100%; margin-bottom: 1cm; }
.logo { display: table-cell; width: 40%; }
.address-block { display: table-cell; width: 60%; text-align: right; font-size: 9pt; }

.settlement-box {
  border: 1pt solid #3b82f6;
  padding: 0.5cm;
  margin-bottom: 0.8cm;
  background: #f0f7ff;
}

table.lines { width: 100%; border-collapse: collapse; margin-top: 0.5cm; }
table.lines th { background: #1e3a5f; color: white; padding: 5pt 8pt; text-align: left; }
table.lines td { padding: 4pt 8pt; border-bottom: 0.5pt solid #ddd; }
table.lines tr:nth-child(even) td { background: #f9f9f9; }

.total-row td { font-weight: bold; border-top: 2pt solid #1e3a5f; }
{% endblock %}

{% block content %}
<div class="header">
  <div class="logo">
    <img src="{{ logo_url }}" width="150" alt="Logo">
  </div>
  <div class="address-block">
    <strong>{{ tenant_name }}</strong><br>
    {{ tenant_address }}<br>
    CVR: {{ tenant_cvr }}<br><br>
    Udstedt: {{ issued_date }}<br>
    Afregningsperiode: {{ period_start }} – {{ period_end }}
  </div>
</div>

<div class="settlement-box">
  <h2 style="margin: 0 0 0.3cm 0;">Varmeafregning — {{ property_address }}</h2>
  <p style="margin: 0;">
    Lejlighed: <strong>{{ unit_label }}</strong> &nbsp;|&nbsp;
    Lejer: <strong>{{ occupant_name }}</strong> &nbsp;|&nbsp;
    Periode: {{ period_start }} – {{ period_end }}
  </p>
</div>

<h3>Fordelingsopgørelse</h3>
<table class="lines">
  <thead>
    <tr>
      <th>Post</th>
      <th>Enhed</th>
      <th style="text-align: right;">Forbrug</th>
      <th style="text-align: right;">Enhedspris</th>
      <th style="text-align: right;">Beløb</th>
    </tr>
  </thead>
  <tbody>
    {% for line in settlement_lines %}
    <tr>
      <td>{{ line.description }}</td>
      <td>{{ line.unit }}</td>
      <td style="text-align: right;">{{ line.quantity | round(2) }}</td>
      <td style="text-align: right;">{{ line.unit_price | currency }}</td>
      <td style="text-align: right;">{{ line.amount | currency }}</td>
    </tr>
    {% endfor %}
    <tr class="total-row">
      <td colspan="4">I alt til opkrævning / tilbagebetaling</td>
      <td style="text-align: right;">{{ total | currency }}</td>
    </tr>
  </tbody>
</table>

{% if total > 0 %}
<p style="margin-top: 1cm; font-size: 9pt; color: #666;">
  Beløbet opkræves via din næste lejebetaling eller separat opkrævning.
</p>
{% else %}
<p style="margin-top: 1cm; font-size: 9pt; color: #666;">
  Tilbagebetaling sker senest 30 dage efter udstedelsesdato.
</p>
{% endif %}

<div style="margin-top: 1.5cm; font-size: 8pt; color: #999; border-top: 0.5pt solid #ddd; padding-top: 0.3cm;">
  Indsigelsesret: Du har 6 ugers ret til at gøre indsigelse mod denne afregning (BEK 563 § 19).
  Kontakt udlejer skriftligt inden {{ dispute_deadline }}.
</div>
{% endblock %}

WeasyPrint generator

# server/src/pdf/generator.py
from pathlib import Path
from weasyprint import HTML, CSS
from jinja2 import Environment, FileSystemLoader
import base64

TEMPLATE_DIR = Path(__file__).parent.parent / "templates"
FONT_DIR = Path(__file__).parent.parent / "static" / "fonts"

jinja_env = Environment(
    loader=FileSystemLoader(str(TEMPLATE_DIR)),
    autoescape=True,
)

# Custom Jinja2 filter for Danish currency formatting:
def currency_filter(value: float, currency: str = "DKK") -> str:
    return f"{value:,.2f} {currency}".replace(",", "X").replace(".", ",").replace("X", ".")

jinja_env.filters["currency"] = currency_filter


def render_settlement_pdf(context: dict) -> bytes:
    """
    Render settlement as PDF bytes.
    context: dict with all template variables (settlement_lines, totals, etc.)
    """
    template = jinja_env.get_template("settlement.html")
    html_str = template.render(**context)

    # WeasyPrint: HTML → PDF
    # base_url er nødvendig for at resolve relative stier (fonter, billeder):
    pdf_bytes = HTML(
        string=html_str,
        base_url=str(TEMPLATE_DIR),
    ).write_pdf(
        stylesheets=[
            CSS(string="@page { size: A4; }"),
        ]
    )

    return pdf_bytes

Celery task: Async PDF-generering

# server/src/workers/tasks/pdf.py
from celery import shared_task
from server.src.pdf.generator import render_settlement_pdf
from server.src.storage.s3 import upload_to_object_storage
import structlog

logger = structlog.get_logger()

@shared_task(
    bind=True,
    max_retries=3,
    default_retry_delay=30,
    name="pdf.generate_settlement",
)
def generate_settlement_pdf_task(self, settlement_id: str):
    """
    Generer PDF for én afregning og gem i Hetzner Object Storage.
    Idempotent — kan køres igen uden duplikater.
    """
    from server.src.db.sync_session import get_sync_session
    from server.src.settlements.models import Settlement

    try:
        with get_sync_session() as session:
            settlement = session.get(Settlement, settlement_id)
            if not settlement:
                logger.error("settlement_not_found", settlement_id=settlement_id)
                return

            if settlement.pdf_url:
                logger.info("pdf_already_exists", settlement_id=settlement_id)
                return  # Idempotent

            context = build_settlement_context(settlement)
            pdf_bytes = render_settlement_pdf(context)

            # Upload til Hetzner Object Storage:
            object_key = f"settlements/{settlement_id}/afregning.pdf"
            pdf_url = upload_to_object_storage(
                key=object_key,
                data=pdf_bytes,
                content_type="application/pdf",
                metadata={
                    "settlement_id": settlement_id,
                    "tenant_id": str(settlement.tenant_id),
                },
            )

            settlement.pdf_url = pdf_url
            session.commit()

            logger.info("pdf_generated", settlement_id=settlement_id, size_kb=len(pdf_bytes) // 1024)

    except Exception as exc:
        logger.error("pdf_generation_failed", settlement_id=settlement_id, error=str(exc))
        raise self.retry(exc=exc)

Docker-opsætning (fonter + WeasyPrint)

# server/Dockerfile
FROM python:3.11-slim

# WeasyPrint kræver system-biblioteker til fontrendering:
RUN apt-get update && apt-get install -y \
    libpango-1.0-0 \
    libpangoft2-1.0-0 \
    libpangocairo-1.0-0 \
    libgdk-pixbuf2.0-0 \
    libffi-dev \
    shared-mime-info \
    fonts-liberation \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Kopier Inter-font til container:
COPY server/static/fonts/ /app/server/static/fonts/
COPY server/ /app/server/

CMD ["uvicorn", "server.src.main:app", "--host", "0.0.0.0", "--port", "8000"]

Konklusion

WeasyPrint er det rigtige valg til PDF-afregninger i Python: HTML/CSS templates er lettere at vedligeholde end programmatisk layout, CSS Paged Media håndterer sideskift og headers/footers, og Jinja2 giver klar separation mellem logik og præsentation. Kør generering som Celery-task for at undgå at blokere API-tråde.

Se Celery beat guide eller Jinja2 templates guide.