How to Convert HTML to PDF Using Python-PDFKit
With the rising demand for automated document generation, developers need reliable tools to create professional PDFs programmatically. Enter PDFKit — a powerful Python wrapper for wkhtmltopdf that transforms well-structured HTML into polished PDF documents. In this guide, we'll walk through a practical implementation, showing you how to leverage this library to create dynamic, multi-page contracts that look truly professional.
Understanding PDFKit and wkhtmltopdf
PDFKit is a Python wrapper for wkhtmltopdf, a powerful command-line tool that renders HTML into PDF using the WebKit rendering engine. This combination offers:
- High-fidelity HTML/CSS rendering.
- Header and footer customization.
- Table of contents generation.
- Document outline creation.
- Advanced options for PDF generation.
Unlike some alternative solutions, PDFKit gives you the complete power of a mature web rendering engine, ensuring your documents look exactly as designed.
Step-by-Step Guide: Converting HTML to PDF with PDFKit
Step 1: Setting Up the Environment
Prerequisites
Before you begin, make sure you have the following:
Requirement | Description and Resources |
---|---|
Python | Python installed - python.org |
wkhtmltopdf | Download from wkhtmltopdf.org |
wkhtmltopdf.org installation is critical for PDFKit to function properly. Make sure it's correctly installed and accessible in your PATH.
Let's install the necessary packages:
pip install pdfkit jinja2
Step 2: Project Directory Structure
Before writing code, let's set up an organized project structure that will help maintain a clean separation of concerns:
contract-generator/
├── contract_generator.py # Main Python script
├── data/
│ └── contract_data.json # Sample data for the contract
├── static/
│ ├── css/
│ │ └── contract_style.css # Styling for the contract
│ └── img/
│ └── logo.png # Company logo
├── templates/
│ └── contract_template.html # Jinja2 template
└── output/ # Generated PDFs will go here
This structure separates data, presentation, and logic, making your project more maintainable.
Step 3: Create the HTML Template
Now, let's create a contract template using HTML and Jinja2 for dynamic content insertion. This template will serve as the foundation for our multi-page agreement.
Save this as templates/contract_template.html
.
View code - contract_template.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Professional Services Agreement</title>
<link rel="stylesheet" href="{{ css_path }}" type="text/css">
</head>
<body>
<div class="container">
<header>
{% if contract.logo_path %}
<img src="{{ contract.logo_path }}" alt="Company Logo" class="logo">
{% endif %}
<h1>Professional Services Agreement</h1>
<p>Contract #{{ contract.contract_id }}</p>
<hr>
</header>
<div class="party">
<h2>SERVICE PROVIDER</h2>
<p><strong>{{ contract.company_name }}</strong></p>
<p>{{ contract.company_address.street }}</p>
<p>{{ contract.company_address.city }}, {{ contract.company_address.state }} {{ contract.company_address.zip }}</p>
<p>Email: {{ contract.company_email }}</p>
<p>Phone: {{ contract.company_phone }}</p>
</div>
<div class="party">
<h2>CLIENT</h2>
<p><strong>{{ contract.client_name }}</strong></p>
<p>{{ contract.client_address.street }}</p>
<p>{{ contract.client_address.city }}, {{ contract.client_address.state }} {{ contract.client_address.zip }}</p>
<p>Email: {{ contract.client_email }}</p>
<p>Phone: {{ contract.client_phone }}</p>
</div>
<h3 class="centered">Effective Date: {{ contract.effective_date }}</h3>
<main>
{% for section in contract.sections %}
<section>
<h2>{{ loop.index }}. {{ section.title }}</h2>
<p>{{ section.content }}</p>
</section>
{% endfor %}
{% if contract.services %}
<section>
<h2>{{ contract.sections|length + 1 }}. SERVICES AND PRICING</h2>
<table>
<thead>
<tr>
<th>Service Description</th>
<th>Quantity</th>
<th>Rate</th>
<th>Amount</th>
</tr>
</thead>
<tbody>
{% for service in contract.services %}
<tr>
<td>{{ service.description }}</td>
<td>{{ service.quantity }}</td>
<td>${{ "%.2f"|format(service.rate) }}</td>
<td>${{ "%.2f"|format(service.amount) }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td colspan="3" class="text-right">Total</td>
<td>${{ "%.2f"|format(contract.total_amount) }}</td>
</tr>
</tfoot>
</table>
</section>
{% endif %}
{% if contract.additional_terms %}
<section>
<h2>{{ contract.sections|length + 2 }}. ADDITIONAL TERMS</h2>
{% for term in contract.additional_terms %}
<h3>{{ loop.index }}. {{ term.title }}</h3>
<p>{{ term.content }}</p>
{% endfor %}
</section>
{% endif %}
</main>
<div class="signatures">
<h2>SIGNATURES</h2>
<p>By signing below, the parties agree to be bound by the terms of this Agreement.</p>
<div class="signature">
<div class="signature-line"></div>
<p><strong>{{ contract.company_name }}</strong></p>
<p>Date: {{ contract.effective_date }}</p>
</div>
<div class="signature">
<div class="signature-line"></div>
<p><strong>{{ contract.client_name }}</strong></p>
<p>Date: {{ contract.effective_date }}</p>
</div>
</div>
</div>
</body>
</html>
The template uses Jinja2's powerful features like conditional statements ({% if %}
) and loops ({% for %}
) to dynamically generate content based on the provided data.
Step 4: Create the CSS Stylesheet
A professional-looking document requires proper styling. Let's create a CSS file that will make our contract visually appealing and properly formatted.
Save this as static/css/contract_style.css
.
View code - contract_style.css
body {
font-family: Arial, sans-serif;
font-size: 12pt;
line-height: 1.5;
margin: 0;
padding: 0;
color: #333;
text-align: justify;
}
.container {
padding: 30px;
}
.centered, header, header p {
text-align: center;
}
.text-right {
text-align: right;
}
.logo {
max-width: 300px;
height: auto;
margin: 0 auto 20px;
display: block;
}
h1, h2, th {
color: #062657;
}
h1 {
font-size: 24pt;
margin-bottom: 10px;
}
hr {
border: none;
height: 1px;
background-color: #ddd;
margin: 25px 0;
}
.party {
margin: 20px 0 30px;
border: 1px solid #ddd;
}
.party h2 {
background-color: #062657;
color: white;
padding: 8px 10px;
margin: 0;
font-size: 14pt;
}
h2 {
font-size: 16pt;
margin-top: 20px;
border-bottom: 1px solid #ddd;
padding-bottom: 5px;
}
p, td, .party p {
margin: 5px 10px;
font-size: 12pt;
}
table {
width: 100%;
border-collapse: collapse;
margin: 15px 0;
}
th {
background-color: #062657;
color: white;
padding: 10px;
text-align: left;
}
td {
padding: 10px;
border-bottom: 1px solid #ddd;
}
tr:nth-child(even) {
background-color: #f9f9f9;
}
tfoot tr {
background-color: #062657;
color: white;
}
.signature-line {
border-bottom: 1px solid #9e9e9e;
width: 350px;
margin: 30px 0 10px 0;
}
.signature {
margin: 50px 0;
}
Step 5: Create Sample Contract Data
To demonstrate how data is injected into our template, let's prepare sample contract data. This structured JSON will be used to populate all dynamic fields in our contract.
Save this as data/contract_data.json
.
View example data
{
"contract_id": "CONT-2025-04",
"effective_date": "April 3, 2025",
"company_name": "Byte Me Innovations",
"company_address": {
"street": "123 Binary Blvd",
"city": "Silicon Valley",
"state": "CA",
"zip": "94016"
},
"company_email": "[email protected]",
"company_phone": "+1-415-555-1010",
"client_name": "Ctrl Alt Elite",
"client_address": {
"street": "456 Digital Drive",
"city": "Cyber City",
"state": "NY",
"zip": "10001"
},
"client_email": "[email protected]",
"client_phone": "+1-212-555-2020",
"sections": [
{
"title": "SCOPE OF SERVICES",
"content": "The Service Provider agrees to provide the Client with the services described in this Agreement, in accordance with the terms and conditions set out herein. The services will include all work specified in the attached Statement of Work as well as any additional services agreed upon in writing by both parties. The Service Provider shall use best efforts to complete all services in a timely and professional manner, utilizing qualified personnel and industry best practices."
},
{
"title": "CONFIDENTIALITY",
"content": "Each party agrees to maintain the confidentiality of any proprietary information received from the other party during the term of this Agreement. 'Confidential Information' includes, but is not limited to, business plans, financial information, customer lists, technical specifications, trade secrets, and any other information that is marked confidential or would reasonably be understood to be confidential. Each party agrees to use the same degree of care to protect the other party's Confidential Information as it uses to protect its own. This obligation shall survive the termination of this Agreement for a period of three (3) years. The Service Provider warrants that the services will be performed in a professional and workmanlike manner consistent with industry standards."
},
{
"title": "TERM",
"content": "This Agreement shall commence on the Effective Date and shall continue until the Services are completed or this Agreement is terminated as provided herein. The estimated completion date for all services is six (6) months from the Effective Date, though this timeline may be adjusted by mutual written agreement. Either party may terminate this Agreement with thirty (30) days written notice to the other party."
},
{
"title": "PAYMENT TERMS",
"content": "The Client agrees to pay the Service Provider as set forth in the Services and Pricing section. Payment is due within 30 days of invoice receipt. Invoices will be issued according to the following schedule: 30% upon signing of this Agreement, 30% at project midpoint, and 40% upon completion of all services. Late payments shall accrue interest at a rate of 1.5% per month or the maximum amount allowed by law, whichever is less. The Client shall be responsible for all reasonable expenses incurred by the Service Provider in the performance of services under this Agreement, provided such expenses are approved in advance by the Client."
},
{
"title": "INTELLECTUAL PROPERTY",
"content": "Upon full payment of all amounts due under this Agreement, the Service Provider assigns to the Client all rights, title, and interest in the deliverables produced under this Agreement, including all intellectual property rights. The Service Provider retains ownership of any pre-existing materials, tools, techniques, and know-how used in the development of the deliverables. The Service Provider grants the Client a non-exclusive, perpetual license to use such pre-existing materials to the extent they are incorporated into the deliverables."
}
],
"services": [
{
"description": "Web Application Development",
"quantity": "1",
"rate": 15000.00,
"amount": 15000.00
},
{
"description": "Database Design and Implementation",
"quantity": "1",
"rate": 5000.00,
"amount": 5000.00
},
{
"description": "Technical Documentation",
"quantity": "40",
"rate": 75.00,
"amount": 3000.00
},
{
"description": "UI/UX Design",
"quantity": "1",
"rate": 7500.00,
"amount": 7500.00
},
{
"description": "Quality Assurance and Testing",
"quantity": "80",
"rate": 65.00,
"amount": 5200.00
},
{
"description": "Deployment and Server Configuration",
"quantity": "1",
"rate": 2500.00,
"amount": 2500.00
},
{
"description": "Project Management",
"quantity": "60",
"rate": 95.00,
"amount": 5700.00
},
{
"description": "API Integration Services",
"quantity": "1",
"rate": 4000.00,
"amount": 4000.00
},
{
"description": "Security Audit and Implementation",
"quantity": "1",
"rate": 3800.00,
"amount": 3800.00
}
],
"additional_terms": [
{
"title": "INDEMNIFICATION",
"content": "Each party agrees to indemnify, defend, and hold harmless the other party from and against any and all claims, damages, losses, liabilities, costs, and expenses (including reasonable attorneys' fees) arising out of or related to the indemnifying party's breach of this Agreement or any negligent or willful acts or omissions."
},
{
"title": "FORCE MAJEURE",
"content": "Neither party shall be liable for any failure or delay in performance due to circumstances beyond its reasonable control, including but not limited to acts of God, natural disasters, war, terrorism, riots, civil unrest, government actions, labor disputes, or Internet service provider failures."
},
{
"title": "DISPUTE RESOLUTION",
"content": "Any dispute arising out of or related to this Agreement shall first be addressed through good faith negotiation between the parties. If the dispute cannot be resolved through negotiation within 30 days, the parties agree to submit the dispute to binding arbitration in accordance with the rules of the American Arbitration Association. The arbitration shall take place in the state of the defendant. The prevailing party shall be entitled to recover its reasonable attorneys' fees and costs."
},
{
"title": "GOVERNING LAW",
"content": "This Agreement shall be governed by and construed in accordance with the laws of the State of California, without giving effect to any choice of law or conflict of law provisions. Any legal action or proceeding relating to this Agreement shall be instituted in the courts of the State of California."
}
],
"total_amount": 23000.00
}
Step 6: Python Script for PDF Generation
Now, let's create the Python script that brings everything together. This script will load the data, render the template, and generate the PDF.
Save this as contract_generator.py
.
View code - contract_generator.py
import os
import json
import pdfkit
from jinja2 import Environment, FileSystemLoader
from datetime import datetime
# Converts a relative file path to an absolute file URL with file:/// prefix
def get_absolute_file_url(base_dir: str, *path_parts: str) -> str:
full_path = os.path.join(base_dir, *path_parts)
return 'file:///' + os.path.abspath(full_path).replace('\\', '/')
def generate_contract_pdf():
# Base directory
current_dir = os.path.dirname(os.path.abspath(__file__))
# Path to wkhtmltopdf executable - replace with your actual path
path_wkhtmltopdf = r'C:\Program Files\wkhtmltopdf\bin\wkhtmltopdf.exe'
# Configure PDFKit with wkhtmltopdf path
config = pdfkit.configuration(wkhtmltopdf=path_wkhtmltopdf)
# Define paths relative to the current directory
templates_dir = os.path.join(current_dir, 'templates')
data_file = os.path.join(current_dir, 'data', 'contract_data.json')
output_dir = os.path.join(current_dir, 'output')
# Create absolute file URLs for CSS and logo
css_path = get_absolute_file_url(current_dir, 'static', 'css', 'contract_style.css')
# Make sure output directory exists
os.makedirs(output_dir, exist_ok=True)
# Load data from JSON file
with open(data_file, 'r', encoding='utf-8') as file:
contract_data = json.load(file)
# Add logo path to contract data if logo exists
logo_path = get_absolute_file_url(current_dir, 'static', 'img', 'logo.png')
if os.path.exists(os.path.join(current_dir, 'static', 'img', 'logo.png')):
contract_data['logo_path'] = logo_path
# Configure Jinja2 environment
env = Environment(loader=FileSystemLoader(templates_dir))
template = env.get_template('contract_template.html')
# Render template with data and absolute CSS path
rendered_html = template.render(
contract=contract_data,
css_path=css_path
)
# Create PDF filename based on contract ID and date
pdf_filename = f"contract_{contract_data['contract_id']}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
pdf_path = os.path.join(output_dir, pdf_filename)
# Configure pdfkit options
options = {
'page-size': 'A4',
'margin-top': '20mm',
'margin-right': '20mm',
'margin-bottom': '20mm',
'margin-left': '20mm',
'encoding': 'UTF-8',
'title': 'Professional Services Agreement',
'enable-local-file-access': True,
# Footer configuration
'footer-center': '[page] of [topage]',
'footer-font-name': 'Arial',
'footer-font-size': '10',
'footer-spacing': '5',
# Header configuration
'header-right': 'Professional Services Agreement',
'header-font-name': 'Arial',
'header-font-size': '10',
'header-line': True,
'header-spacing': '5',
# For better rendering
'dpi': 300,
}
# Generate PDF using wkhtmltopdf configuration
pdfkit.from_string(rendered_html, pdf_path, options=options, configuration=config)
print(f"Contract PDF has been generated: {pdf_path}")
return pdf_path
if __name__ == "__main__":
generate_contract_pdf()
The script performs several key operations:
- Sets up paths to necessary files and directories.
- Loads contract data from JSON.
- Configures Jinja2 for template rendering.
- Renders the HTML template with the provided data.
- Sets up PDF generation options (margins, headers, footers, etc.).
- Generates the final PDF file.
Step 7: Running the Script
To generate the contract PDF, simply run the Python script:
python contract_generator.py
The script will process everything and create a PDF in the output
directory with a filename that includes the contract ID and a timestamp.
Step 8: Check the Output
After running the script:
- Open the output directory in your project folder.
- Look for a PDF file named
contract_CONT-2025-04_[DATE_TIMESTAMP].pdf
. - Open the PDF to review the generated contract.
➡️ Click to view generated contract
Troubleshooting Common Issues
When working with PDFKit and wkhtmltopdf, you may encounter some common issues. Here's how to address them:
Issue 1: wkhtmltopdf Not Found
Problem: You get an error like No wkhtmltopdf executable found
.
Solution:
- Ensure wkhtmltopdf is installed correctly.
- Specify the exact path to the executable:
config = pdfkit.configuration(wkhtmltopdf='/path/to/wkhtmltopdf')
- On Windows, use raw strings for the path:
config = pdfkit.configuration(wkhtmltopdf=r'C:\Program Files\wkhtmltopdf\bin\wkhtmltopdf.exe')
Issue 2: CSS Not Loading
Problem: Your PDF is generated but without any styling.
Solution:
- Make sure your CSS path is correctly specified as an absolute path with
file:///
prefix. - Add the
--enable-local-file-access
option:
options = {
'enable-local-file-access': True,
}
- Check if your CSS file contains any syntax errors.
Issue 3: Images Not Displaying
Problem: Images in your template don't appear in the PDF.
Solution:
- Ensure image paths are absolute with
file:///
prefix. - Add the following options:
options = {
'enable-local-file-access': True,
'images': True,
}
- Verify that the image files exist at the specified paths.
Issue 4: Special Characters Rendered Incorrectly
Problem: Special characters (like accented letters) appear as gibberish in the PDF.
Solution:
- Ensure your HTML template has the correct charset:
<meta charset="UTF-8">
- Add encoding to pdfkit options:
options = {
'encoding': 'UTF-8',
}
- Open files with explicit encoding:
with open(file_path, 'r', encoding='utf-8') as file:
Conclusion
In this detailed guide, we've explored how to generate professional multi-page contracts as PDFs using Python-PDFKit and Jinja2. This approach offers several advantages:
- Flexibility: HTML and CSS provide powerful formatting capabilities.
- Customization: Dynamic content insertion through Jinja2 templates.
- Professional Features: Headers, footers, page numbers, and proper styling.
By combining these technologies, you can create a robust document generation system for contracts, agreements, reports, invoices, and more. The step-by-step approach we've outlined can be adapted to various document types and business needs.
Remember that the key to successful PDF generation is in the details — proper HTML structure, clean CSS styling, and thoughtful template design will ensure your documents look professional and consistent every time.
May your PDFs be perfectly formatted - life's too short to struggle with contract layouts! 📝