DEV Community

Amaury
Amaury

Posted on

Generate a PDF report from HTML in Python, with print CSS

HTML is a convenient way to lay out a report: you already know how to build tables, style headings, and format numbers. The awkward part is turning that HTML into a readable PDF when the table runs across pages.

Here's a complete Python example that builds a small report with synthetic data and sends it to a hosted Chromium-based PDF API. It uses only Python's standard library. We'll also cover page breaks, retries, and what happens to external assets.

Disclosure: I run PDF1729, the API used below. AI helped draft this article. You can use the same HTML and print CSS with a local renderer if you would rather operate your own browser.

1. Get an API key

Sign in at PDF1729, then create an API key from your account page. The free plan currently includes 100 successful renders per UTC calendar month. Store the key in an environment variable instead of putting it in your code:

export PDF_API_KEY='your-key-here'
Enter fullscreen mode Exit fullscreen mode

Keep the key on your server. Don't ship it to a browser or commit it to a repository.

2. Build the report and render it

Save this as report.py and run python3 report.py. The document contains example data only. The long table deliberately gives the renderer enough rows to span multiple pages.

import html
import json
import os
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen

key = os.environ.get("PDF_API_KEY")
if not key:
    raise SystemExit("Set PDF_API_KEY first")

# Replace with your own records. Escape text inserted into HTML.
rows = [(f"Station {n:02}", 100 + n * 7) for n in range(1, 61)]
table_rows = "\n".join(
    f"<tr><td>{html.escape(name)}</td><td>{value:,}</td></tr>"
    for name, value in rows
)
total = sum(value for _, value in rows)

document = f"""<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Monthly report</title>
  <style>
    body {{ font: 11pt sans-serif; color: #182433; }}
    h1 {{ margin-bottom: 0.2em; }}
    .muted {{ color: #536172; }}
    table {{ width: 100%; border-collapse: collapse; }}
    th, td {{ padding: 8px; border-bottom: 1px solid #cbd5e1; text-align: left; }}
    th:last-child, td:last-child {{ text-align: right; }}
    th {{ background: #e7f0fa; }}
    thead {{ display: table-header-group; }}
    tr {{ break-inside: avoid; }}
    .summary {{ margin-top: 1em; break-inside: avoid; }}
  </style>
</head>
<body>
  <h1>Monthly report</h1>
  <p class="muted">Synthetic example ยท 60 stations</p>
  <table>
    <thead><tr><th>Station</th><th>Units</th></tr></thead>
    <tbody>{table_rows}</tbody>
  </table>
  <p class="summary"><strong>Total: {total:,} units</strong></p>
</body>
</html>"""

payload = json.dumps({
    "html": document,
    "format": "A4",
    "print_background": True,
}).encode("utf-8")

request = Request(
    "https://pdf.1729.studio/v1/pdf",
    data=payload,
    headers={
        "Authorization": f"Bearer {key}",
        "Content-Type": "application/json",
    },
    method="POST",
)

try:
    with urlopen(request, timeout=40) as response:
        pdf = response.read()
except HTTPError as error:
    detail = error.read(4096).decode("utf-8", errors="replace")
    raise SystemExit(f"PDF API returned {error.code}: {detail}")
except URLError as error:
    raise SystemExit(f"PDF request failed: {error.reason}")

if not pdf.startswith(b"%PDF-"):
    raise SystemExit("The response was not a PDF")

Path("report.pdf").write_bytes(pdf)
print("Saved report.pdf")
Enter fullscreen mode Exit fullscreen mode

Open report.pdf and inspect every page. Print layout is different from a browser viewport: check long headings, page breaks, row heights, and whether the table header repeats in your renderer. break-inside: avoid is a layout hint, not a guarantee for a row taller than a page.

Why those options matter

  • format: "A4" sets the paper size; Letter is also supported.
  • print_background: true keeps the light blue header background. The default is false.
  • A table header group lets Chromium repeat headings when a table spans pages. break-inside: avoid asks it to keep rows together where possible.
  • html.escape protects inserted text from being interpreted as markup. If your real data contains richer HTML, treat it as untrusted input and sanitize it for your use case; escaping plain text is the safer default.

The example is self-contained: it doesn't fetch web fonts, stylesheets, or images. PDF1729 blocks external network access by default. If you render a public URL or load a remote resource, you must explicitly add the destination host and every needed resource or redirect host to allow_hosts; private addresses and local file URLs are blocked. See the API documentation before enabling external resources.

Handling failures and retries

An invalid key returns 401; a render blocked by resource restrictions or an unsupported page feature can return 422; rate or quota limits return 429; worker unavailability can return 503. Check the response before saving the body as a PDF. The docs list the limits and error cases.

Don't automatically retry every failure. Fix a 422 input or configuration problem first. For transient failures, use backoff and respect Retry-After where present. A request can finish rendering even if its response is lost; because there is no idempotency guarantee, a retry can consume another unit. If duplicate work matters, keep your own job state and make retries a deliberate decision.

The free plan is useful for trying the workflow; check the current pricing and terms before using it in production. The service has no availability SLA, so consider what your application should do when the renderer is unavailable.

Top comments (0)