M-Bus Gateway
← Tilbage til blog
· 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.

Se WeasyPrint PDF guide eller Hetzner Object Storage guide.