In B2B software, automated reporting is an essential feature.
Clients need monthly invoices, executive performance summaries, ISO compliance certificates, and shipping manifests formatted as clean, printable PDF documents.
Generating PDFs by manually calculating X/Y pixel coordinates in legacy libraries like ReportLab is painful and fragile.
Here is the modern approach: Design with HTML & CSS, Render with WeasyPrint or Headless Chrome.
The HTML-to-PDF Pipeline
Django Model Data ──► Jinja2/Django Template (HTML+CSS) ──► WeasyPrint ──► S3 PDF URL
Why HTML-to-PDF Wins:
- Familiar Web Styling: Use CSS flexbox, clean web typography, and embedded SVGs to design documents exactly like web pages.
- CSS Paged Media Standards: Control page breaks (
page-break-inside: avoid;), headers, footers, and page numbers with native CSS@pagerules. - Background Worker Execution: Offload PDF compilation to Celery workers to keep web requests lightning fast.
Choosing an Engine
| Engine | Strengths | Limits | Best for |
|---|---|---|---|
| WeasyPrint | Pure Python, no browser, excellent CSS Paged Media, small memory footprint | No JavaScript; SVG support is partial; heavy pages are slow | Invoices, statements, certificates, compliance docs |
| Playwright / headless Chrome | Full CSS and JS, web fonts, chart libraries work | ~1–2s and 150–400MB per browser context; needs careful lifecycle management | Dashboards with JS charts, complex marketing-quality reports |
| wkhtmltopdf | Historically popular | Unmaintained, ancient WebKit, poor CSS support | Nothing new — migrate away |
| ReportLab | Precise programmatic control, fast, no HTML layer | You hand-place everything; design changes are code changes | Highly structured documents, barcode/label printing |
| LaTeX (via Pandoc) | Superb typesetting, academic-quality output | Toolchain weight, limited template flexibility, awkward CI | Long-form technical or academic documents |
Our default is WeasyPrint: it renders HTML and CSS in-process, so a worker can generate hundreds of invoices without browser overhead. Reach for headless Chrome when the report contains JavaScript-driven charts or CSS that WeasyPrint does not support.
CSS Paged Media: The Techniques That Make Reports Look Professional
The difference between a report that looks generated and one that looks designed is usually a dozen lines of CSS:
@page {
size: A4;
margin: 18mm 16mm 20mm 16mm;
@top-center {
content: string(report-title);
font-size: 9pt;
color: #666;
}
@bottom-right {
content: "Page " counter(page) " of " counter(pages);
font-size: 9pt;
}
}
h1 { string-set: report-title content(); }
/* Never split an item across pages */
.line-item, .summary-card { break-inside: avoid; }
/* Repeat table headers on every page */
thead { display: table-header-group; }
tr { break-inside: avoid; }
.wide-table { break-before: page; } /* force a clean start */
p { orphans: 3; widows: 3; }
Two details worth knowing: string-set lets a running header pull its text from document content (so the client's name appears in the header on every page without manual work), and counter(pages) produces an accurate "page X of Y" only because WeasyPrint does a second layout pass — do not try to fake it.
What Breaks in Production
Fonts disappear on the worker. Your local machine has the fonts; a slim Docker image does not. Embed fonts with @font-face and src: url(file:///app/assets/fonts/Inter.woff2) or install them in the image. Check licensing: some fonts cannot be embedded in documents you distribute.
Images fail to load in the worker. Absolute URLs pointing at localhost or a private S3 bucket will not resolve. Pass images as base64 data URIs or read them from disk. Never let the renderer fetch arbitrary remote URLs — that is an SSRF risk and a source of flaky output.
User data injects HTML. A customer named <script> or with < in an address breaks the layout. Escape all interpolated values, and treat rich-text fields as untrusted input.
Numbers do not reconcile. The fastest way to lose a client's confidence is a PDF whose totals do not match the application. Compute totals once, store them with the report, and render from the stored values. Add an automated check that the sum of line items equals the reported total, and fail the job loudly if it does not.
Dates and currency drift. A report generated at 23:50 on the 31st with the wrong timezone shows the wrong month. Freeze the reporting period and the generation timestamp explicitly, format dates according to the client's locale, and never rely on the server's default timezone.
Charts render inconsistently. For WeasyPrint, render charts server-side to PNG or SVG with matplotlib rather than relying on a JavaScript charting library. For headless Chrome, wait for the chart's render event before printing — otherwise you will occasionally capture an empty canvas.
Performance and the Worker Lifecycle
A single invoice takes roughly 80–300ms in WeasyPrint and 1–2s in headless Chrome. That difference decides your architecture at volume:
| Volume | Approach |
|---|---|
| Tens per day | Generate on demand in a task; no special handling |
| Hundreds per day | Queue with a dedicated reports queue; 2–4 workers |
| Thousands (monthly invoicing run) | Batch with throttling, chunk tenants across tasks, monitor queue depth |
| Tens of thousands | Consider a pre-warmed renderer pool and a scheduled multi-hour window |
With headless Chrome, recycle the browser instance every N documents and reuse a single browser across contexts; launching a browser per document is the classic performance mistake, and leaked Chrome processes are the classic memory problem. Set max_tasks_per_child on that worker so memory returns to the OS.
Delivery, Storage and Security
- Store in object storage, not on the web server, with a signed URL for download and a deterministic key (
reports/{tenant}/{report_type}/{period}.pdf) so regeneration is idempotent. - Keep a copy. Regenerating a year-old invoice from current data produces a different document; retaining the generated file (or a full data snapshot) is what makes an audit defensible.
- Email attachments have limits. Most providers cap around 25MB and many corporate mail systems silently strip larger files. Send a link, not a 40MB report.
- Protect sensitive documents. Password-protected PDFs or expiring links for anything containing personal or financial data, and never leave the bucket public.
- Consider tagged PDF. For accessibility and some compliance regimes, a tagged, structured PDF with a document title and language set is required. WeasyPrint supports PDF/UA tagging; check your client's requirements early rather than at delivery.
Structuring the Template Layer
Reports are usually 70% shared structure and 30% unique content. Structure it that way:
- One base template with the letterhead, footer, running header and page setup, and per-report templates that extend it. Report authors then touch only content.
- A context builder per report type — a plain Python function that assembles the data (period, totals, currency, locale) independently of the template. That function is what you unit test; the template is what you visually review.
- A component library of small includes: line-item table, totals block, signature block, chart panel. Consistency across invoices, statements and certificates then comes for free.
- An explicit
report_versionandgenerated_atprinted in the footer. When a client asks why last month's report differs from this month's, the version answers it.
A Worked Example: The Monthly Invoice Run
What this looks like end to end for a firm invoicing 2,000 customers on the first of the month:
- Celery Beat fires at 01:00 in the billing timezone and enqueues one fan-out task.
- The fan-out task pages through tenants and enqueues one
generate_invoicetask per customer — deliberately one task per invoice, so a single bad record cannot fail the batch. - Each task computes totals, writes the invoice row, renders the PDF with the customer's locale and currency, uploads to S3 under a deterministic key, and marks the invoice ready.
- A final task waits for the batch (a chord or a completion counter), verifies every invoice has a file, and reports counts to the team. Never assume the batch succeeded — count what was produced and compare with what was expected.
- Emails go out on the next day, not immediately, after a human has seen the reconciliation summary.
That last step is not bureaucracy. Invoicing runs fail in ways that are invisible until a customer complains — a missing tax line, a duplicated proration — and a single morning check catches them before 2,000 emails leave the building.
Quality Control Checklist
Before any report template goes live, verify each of these with real, worst-case data — the customer with the 60-character name, the one with 180 line items, the one in a different timezone:
- [ ] Page numbers and "page X of Y" are correct
- [ ] Table headers repeat on every page
- [ ] No line item or signature block splits across pages
- [ ] No content overflows the page margin
- [ ] Totals reconcile with the application to the cent
- [ ] Currency, decimal separators and date formats match the client's locale
- [ ] Logo renders at correct resolution in print and on screen
- [ ] Fonts are embedded and consistent
- [ ] Empty sections say "None" rather than rendering blank tables
- [ ] The document is searchable (text, not images of text)
- [ ] File size is reasonable (under ~5MB for a standard report)
- [ ] The same input regenerates an identical document
Test with golden files. Render the template against a fixed dataset and compare the output in CI — page count assertions plus a visual diff of page images with a tolerance. It catches the layout regressions that manual review always misses, particularly after a CSS refactor.
Related reading: Background task processing is where report generation belongs; dashboard development covers the on-screen version of the same numbers; and document management covers storing and serving the output securely. Need automated invoicing, document generation, or reporting pipelines? Partner with KEHEM IT.
Generating reports by hand every month? Show us the template and we will scope the automation.
Have a project in mind?
KEHEM designs and builds thoughtful websites, SaaS products, and business systems.