Generate a PDF Invoice from a Web Form with Flask + WeasyPrint

Generate a PDF Invoice from a Web Form with Flask + WeasyPrint

If you already collect invoice details in a simple web form, the next step is usually: generate a clean PDF and download it. This Flask + WeasyPrint setup does exactly that. User submits a form, Flask renders an HTML invoice template, and WeasyPrint converts that HTML into a PDF you can download right away.

Typical use cases

  • Quick internal invoices (freelance, side projects, small business)
  • Generate PDFs from structured form data (quotes, receipts, reports)
  • Create consistent PDFs using HTML/CSS instead of “drawing” PDFs manually

Install

pip install flask weasyprint

Note: WeasyPrint may require OS-level libraries depending on your system (common on Linux). If install fails, check the WeasyPrint docs for your OS.


Improved script (safer, cleaner, returns PDF download)

Changes from the original:

  • Returns the PDF as a download instead of saving a fixed invoice.pdf on disk
  • Basic input cleanup + allowlist fields (avoid passing everything blindly into the template)
  • Uses in-memory PDF (BytesIO)
  • Optional stylesheet support
  • Better error handling and production-friendly defaults
from __future__ import annotations

from io import BytesIO
from pathlib import Path
from typing import Dict

from flask import Flask, render_template, request, send_file, abort
from weasyprint import HTML, CSS


app = Flask(__name__)

# Optional: basic request size limit (helps avoid giant form posts)
app.config["MAX_CONTENT_LENGTH"] = 1 * 1024 * 1024  # 1 MB

BASE_DIR = Path(__file__).resolve().parent
INVOICE_CSS = BASE_DIR / "static" / "invoice.css"

# Only accept the fields you expect (avoid dumping all request.form into templates)
ALLOWED_FIELDS = {
    "invoice_number",
    "invoice_date",
    "bill_to_name",
    "bill_to_email",
    "bill_to_address",
    "notes",
    "subtotal",
    "tax",
    "total",
}


def clean_form_data(form) -> Dict[str, str]:
    """
    Keep only allowed keys and do light cleanup.
    You can replace this with WTForms/Pydantic if you want stronger validation.
    """
    data: Dict[str, str] = {}
    for k in ALLOWED_FIELDS:
        if k in form:
            # Strip whitespace and keep it simple
            data[k] = form.get(k, "").strip()

    # Minimal required fields (adjust to your needs)
    if not data.get("invoice_number") or not data.get("bill_to_name"):
        abort(400, description="Missing required fields: invoice_number and bill_to_name")

    return data


@app.route("/", methods=["GET", "POST"])
def index():
    if request.method == "GET":
        return render_template("form.html")

    # POST
    data = clean_form_data(request.form)

    # Render invoice HTML from a template
    # IMPORTANT: do not mark user input as |safe in Jinja templates
    html_str = render_template("invoice.html", data=data)

    # Build WeasyPrint HTML and generate PDF bytes in memory
    # base_url helps resolve relative paths (images, etc.) if needed.
    html = HTML(string=html_str, base_url=str(BASE_DIR))

    stylesheets = []
    if INVOICE_CSS.exists():
        stylesheets.append(CSS(filename=str(INVOICE_CSS)))

    pdf_bytes = html.write_pdf(stylesheets=stylesheets)

    # Nice filename for the download
    filename = f"invoice-{data['invoice_number']}.pdf"

    return send_file(
        BytesIO(pdf_bytes),
        mimetype="application/pdf",
        as_attachment=True,
        download_name=filename,
    )


if __name__ == "__main__":
    # Debug off by default for safety; enable only while developing locally
    app.run(host="127.0.0.1", port=5000, debug=False)

Suggested folder structure

your_project/
  app.py
  templates/
    form.html
    invoice.html
  static/
    invoice.css   (optional)

Minimal templates (examples)

templates/form.html

<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <title>Invoice Form</title>
</head>
<body>
  <h1>Create Invoice</h1>

  <form method="post">
    <label>Invoice #</label><br>
    <input name="invoice_number" required><br><br>

    <label>Invoice date</label><br>
    <input name="invoice_date" placeholder="2026-02-05"><br><br>

    <label>Bill to name</label><br>
    <input name="bill_to_name" required><br><br>

    <label>Bill to email</label><br>
    <input name="bill_to_email"><br><br>

    <label>Address</label><br>
    <textarea name="bill_to_address"></textarea><br><br>

    <label>Subtotal</label><br>
    <input name="subtotal"><br><br>

    <label>Tax</label><br>
    <input name="tax"><br><br>

    <label>Total</label><br>
    <input name="total"><br><br>

    <label>Notes</label><br>
    <textarea name="notes"></textarea><br><br>

    <button type="submit">Generate PDF</button>
  </form>
</body>
</html>

templates/invoice.html

<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <title>Invoice {{ data.invoice_number }}</title>
</head>
<body>
  <h1>Invoice</h1>

  <p><strong>Invoice #:</strong> {{ data.invoice_number }}</p>
  <p><strong>Date:</strong> {{ data.invoice_date }}</p>

  <h3>Bill To</h3>
  <p>{{ data.bill_to_name }}</p>
  <p>{{ data.bill_to_email }}</p>
  <p style="white-space: pre-line;">{{ data.bill_to_address }}</p>

  <hr>

  <p><strong>Subtotal:</strong> {{ data.subtotal }}</p>
  <p><strong>Tax:</strong> {{ data.tax }}</p>
  <p><strong>Total:</strong> {{ data.total }}</p>

  <hr>

  <p style="white-space: pre-line;">{{ data.notes }}</p>
</body>
</html>

Run it

python app.py

Open: http://127.0.0.1:5000, fill the form, and you’ll get a PDF download.

Small production notes

  • Don’t run Flask’s built-in server in production. Use a WSGI server (gunicorn/uwsgi) behind a reverse proxy.
  • Validate numeric fields (subtotal/tax/total) if you rely on them for calculations.
  • If you store PDFs, generate unique filenames and keep them outside your code folder.

Comments

Popular posts from this blog

Open SSRS Linked URLS in a new window

SSRS Font Weight expressions