· 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> |
Lejer: <strong>{{ occupant_name }}</strong> |
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.