· WeasyPrint· PDF· Python· Jinja2· CSS· afregning· varmeregnskab· Celery· WeasyPrint· generering
WeasyPrint PDF afregning — teknisk guide til varmeafregnings-PDF
WeasyPrint til varmeafregnings-PDF: HTML/CSS skabelon, CSS @page layout, Jinja2 dataindsprøjtning, tabelformatering, QR-kode og Celery-integration til async PDF-generering.
Af M-Bus Gateway
M-Bus Gateway genererer årsafregnings-PDF'er med WeasyPrint. Her er den tekniske implementering af skabeloner, layout og async-generering.
WeasyPrint vs. ReportLab og pdfkit
WeasyPrint (anbefalet):
→ HTML + CSS → PDF (native Python)
→ CSS @page til sideopsætning, sidehoved og sidefod
→ Understøtter CSS Grid, Flexbox (moderne layout)
→ Automatisk sideskift og paginering
→ Gratis og open source
ReportLab:
→ Programmatisk PDF (ingen HTML — koordinater og drawString)
→ Hurtigere ved store dokumenter
→ Kræver PDF-specifik kode (ikke genbrug af HTML-skabeloner)
pdfkit (wkhtmltopdf wrapper):
→ Kræver ekstern wkhtmltopdf-binær
→ Kan vise websider (JavaScript support)
→ Ikke velegnet til Docker (binary dependencies)
WeasyPrint i Docker:
→ Kræver system-fonts (Inter, DejaVu)
→ Kræver Cairo, Pango og GDK-PixBuf system-libs
→ Dockerfile nedenfor håndterer dette
Dockerfile med WeasyPrint dependencies
# server/Dockerfile
FROM python:3.11-slim
# WeasyPrint system dependencies:
RUN apt-get update && apt-get install -y --no-install-recommends \
libcairo2 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libgdk-pixbuf2.0-0 \
libffi-dev \
shared-mime-info \
fonts-liberation \
fonts-dejavu \
&& rm -rf /var/lib/apt/lists/*
# Custom font (Inter) fra Hetzner Object Storage:
RUN mkdir -p /usr/share/fonts/inter
COPY fonts/Inter-Regular.woff2 /usr/share/fonts/inter/
COPY fonts/Inter-Bold.woff2 /usr/share/fonts/inter/
RUN fc-cache -f -v
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CSS @page: Sideopsætning og sidehoved/sidefod
/* server/templates/pdf/afregning.css */
@import url('/fonts/Inter-Regular.woff2') format('woff2');
@page {
size: A4;
margin: 20mm 15mm 25mm 15mm;
/* Sidehoved pr. side: */
@top-left {
content: element(header-left);
}
@top-right {
content: string(property-name);
}
/* Sidefod pr. side: */
@bottom-left {
content: "Årsafregning — varme og vand";
font-size: 9pt;
color: #6b7280;
}
@bottom-right {
content: "Side " counter(page) " af " counter(pages);
font-size: 9pt;
color: #6b7280;
}
}
/* Definér hvilken content der bruges i header: */
#header-address {
position: running(header-left);
}
.property-name {
string-set: property-name content();
}
/* Fordelingstabel: */
table.fordeling {
width: 100%;
border-collapse: collapse;
font-size: 10pt;
page-break-inside: avoid; /* Undgå tabelsplit på tværs af sider */
}
table.fordeling th {
background: #1e3a5f;
color: white;
padding: 6px 8px;
text-align: left;
font-weight: 600;
}
table.fordeling td {
padding: 5px 8px;
border-bottom: 1px solid #e5e7eb;
}
table.fordeling tr:nth-child(even) td {
background: #f9fafb;
}
.total-row td {
font-weight: 700;
border-top: 2px solid #1e3a5f;
}
.amount {
text-align: right;
font-variant-numeric: tabular-nums; /* Alignér decimaler */
}
Jinja2 HTML-skabelon
{# server/templates/pdf/afregning.html #}
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="afregning.css">
</head>
<body>
<!-- Sidehoved (kører på alle sider via @page) -->
<div id="header-address">
<strong>{{ property.name }}</strong><br>
{{ property.address }}, {{ property.zip_code }} {{ property.city }}
</div>
<!-- Forside -->
<section class="page-break-after">
<h1>Årsafregning {{ period_year }}</h1>
<p class="property-name">{{ property.name }}</p>
<table class="info-tabel">
<tr><td>Lejer:</td><td><strong>{{ occupancy.tenant_name }}</strong></td></tr>
<tr><td>Lejlighed:</td><td>{{ unit.name }}</td></tr>
<tr><td>Periode:</td><td>{{ period_start }} – {{ period_end }}</td></tr>
<tr><td>Areal:</td><td>{{ unit.area_m2 }} m²</td></tr>
</table>
<!-- Resumé -->
<div class="summary-box">
<div class="kpi">
<span>Varmeforbrug</span>
<strong>{{ "%.1f"|format(heat_kwh) }} kWh</strong>
</div>
<div class="kpi">
<span>Din varmeandel</span>
<strong>{{ "%.2f"|format(heat_amount) }} {{ currency }}</strong>
</div>
<div class="kpi total {% if net_amount < 0 %}positive{% else %}negative{% endif %}">
<span>{% if net_amount < 0 %}Tilbage til dig{% else %}Restbetaling{% endif %}</span>
<strong>{{ "%.2f"|format(net_amount|abs) }} {{ currency }}</strong>
</div>
</div>
</section>
<!-- Fordelingstabel -->
<section>
<h2>Fordelingsregnskab</h2>
<table class="fordeling">
<thead>
<tr>
<th>Post</th>
<th>Total</th>
<th>Din andel</th>
<th>Beløb</th>
</tr>
</thead>
<tbody>
{% for line in settlement_lines %}
<tr>
<td>{{ line.description }}</td>
<td class="amount">{{ "%.2f"|format(line.total) }}</td>
<td class="amount">{{ "%.1f"|format(line.share_pct) }}%</td>
<td class="amount">{{ "%.2f"|format(line.amount) }}</td>
</tr>
{% endfor %}
<tr class="total-row">
<td colspan="3">I alt</td>
<td class="amount">{{ "%.2f"|format(total_amount) }} {{ currency }}</td>
</tr>
</tbody>
</table>
</section>
</body>
</html>
Python PDF-generering med WeasyPrint
# server/src/pdf/generator.py
import weasyprint
from jinja2 import Environment, FileSystemLoader
from pathlib import Path
TEMPLATE_DIR = Path(__file__).parent.parent / "templates" / "pdf"
jinja_env = Environment(
loader=FileSystemLoader(str(TEMPLATE_DIR)),
autoescape=True,
)
def generate_settlement_pdf(context: dict) -> bytes:
"""
Generér årsafregnings-PDF som bytes.
context: Dict med alle Jinja2-variabler til skabelonen.
"""
html_template = jinja_env.get_template("afregning.html")
html_string = html_template.render(**context)
css = weasyprint.CSS(filename=str(TEMPLATE_DIR / "afregning.css"))
pdf_bytes = weasyprint.HTML(
string=html_string,
base_url=str(TEMPLATE_DIR), # Resolv relative stier (fonts, CSS)
).write_pdf(stylesheets=[css])
return pdf_bytes
# Celery task (async PDF-generering):
@shared_task(name="workers.tasks.pdf.generate_settlement_pdf")
def generate_and_store_pdf(settlement_id: str) -> str:
"""Generér PDF og gem i Hetzner Object Storage. Returnerer URL."""
import asyncio
return asyncio.run(_generate_async(settlement_id))
async def _generate_async(settlement_id: str) -> str:
async with AsyncSessionLocal() as session:
settlement = await session.get(Settlement, settlement_id)
context = await _build_context(session, settlement)
pdf_bytes = generate_settlement_pdf(context) # Sync — kør i Celery worker
key = f"settlements/{settlement_id}/afregning.pdf"
url = upload_to_object_storage(key, pdf_bytes, "application/pdf")
async with AsyncSessionLocal() as session:
settlement = await session.get(Settlement, settlement_id)
settlement.pdf_url = url
settlement.updated_at = datetime.utcnow()
session.add(settlement)
await session.commit()
return url
Konklusion
WeasyPrint konverterer HTML/CSS til professionelle PDF-afregninger med CSS @page til sidehoved/sidefod, automatisk paginering og tabelformatering. Jinja2 injicerer data i HTML-skabelonen. Celery håndterer async PDF-generering i baggrunden — brugeren behøver ikke vente. PDF gemmes i Hetzner Object Storage og lejer får magic link til download.