"""
PDF Generation utility for invoices
Uses WeasyPrint to generate professional PDF invoices
"""
import os
import html as html_lib
from datetime import datetime
from weasyprint import HTML, CSS
from weasyprint.text.fonts import FontConfiguration
from app.models import Settings
from flask import current_app
from flask_babel import gettext as _
try:
from babel.dates import format_date as babel_format_date
except Exception:
babel_format_date = None
from pathlib import Path
from flask import render_template
class InvoicePDFGenerator:
"""Generate PDF invoices with company branding"""
def __init__(self, invoice, settings=None):
self.invoice = invoice
self.settings = settings or Settings.get_settings()
def generate_pdf(self):
"""Generate PDF content and return as bytes"""
use_custom_html = bool((self.settings.invoice_pdf_template_html or '').strip())
use_custom_css = bool((self.settings.invoice_pdf_template_css or '').strip())
if use_custom_html or use_custom_css:
html_content, css_content = self._render_from_custom_template()
else:
html_content = self._generate_html()
css_content = self._generate_css()
# Configure fonts
font_config = FontConfiguration()
# Create PDF (avoid passing unexpected args to PDF class)
base_url = None
try:
base_url = current_app.root_path
except Exception:
base_url = None
html_doc = HTML(string=html_content, base_url=base_url)
css_doc = CSS(string=css_content, font_config=font_config)
pdf_bytes = html_doc.write_pdf(stylesheets=[css_doc], font_config=font_config)
return pdf_bytes
def _render_from_custom_template(self):
"""Render HTML and CSS from custom templates stored in settings, with fallback to default template."""
html_template = (self.settings.invoice_pdf_template_html or '').strip()
css_template = (self.settings.invoice_pdf_template_css or '').strip()
html = ''
if css_template:
css = css_template
else:
try:
from flask import render_template as _render_tpl
css = _render_tpl('invoices/pdf_styles_default.css')
except Exception:
css = self._generate_css()
try:
# Render using Flask's Jinja environment to include app filters and _()
if html_template:
from flask import render_template_string
html = render_template_string(html_template, invoice=self.invoice, settings=self.settings, Path=Path)
except Exception:
html = ''
if not html:
try:
html = render_template('invoices/pdf_default.html', invoice=self.invoice, settings=self.settings, Path=Path)
except Exception:
html = f"
{_('Invoice')} {self.invoice.invoice_number}
"
return html, css
def _generate_html(self):
"""Generate HTML content for the invoice"""
html = f"""
{_('Invoice')} {self.invoice.invoice_number}
{_('Bill To')}
{self._escape(self.invoice.client_name)}
{self._get_client_email_html()}
{self._get_client_address_html()}
{_('Project')}
{self._escape(self.invoice.project.name)}
{self._get_project_description_html()}
| {_('Description')} |
{_('Quantity (Hours)')} |
{_('Unit Price')} |
{_('Total Amount')} |
{self._generate_items_rows()}
{self._generate_totals_rows()}
{self._get_additional_info_html()}
"""
return html
def _escape(self, value):
return html_lib.escape(value) if value else ''
def _nl2br(self, value):
if not value:
return ''
return self._escape(value).replace('\n', '
')
def _get_company_logo_html(self):
"""Generate HTML for company logo if available"""
if self.settings.has_logo():
logo_path = self.settings.get_logo_path()
if logo_path and os.path.exists(logo_path):
# Build a cross-platform file URI (handles Windows and POSIX paths)
try:
file_url = Path(logo_path).resolve().as_uri()
except Exception:
# Fallback to naive file:// if as_uri fails
file_url = f'file://{logo_path}'
return f'
'
return ''
def _get_company_tax_info(self):
"""Generate HTML for company tax information"""
if self.settings.company_tax_id:
return f'Tax ID: {self.settings.company_tax_id}
'
return ''
def _get_client_email_html(self):
"""Generate HTML for client email if available"""
if self.invoice.client_email:
return f'{self.invoice.client_email}
'
return ''
def _get_client_address_html(self):
"""Generate HTML for client address if available"""
if self.invoice.client_address:
return f'{self.invoice.client_address}
'
return ''
def _get_project_description_html(self):
"""Generate HTML for project description if available"""
if self.invoice.project.description:
return f'{self.invoice.project.description}
'
return ''
def _generate_items_rows(self):
"""Generate HTML rows for invoice items"""
rows = []
for item in self.invoice.items:
row = f"""
|
{self._escape(item.description)}
{self._get_time_entry_info_html(item)}
|
{item.quantity:.2f} |
{self._format_currency(item.unit_price)} |
{self._format_currency(item.total_amount)} |
"""
rows.append(row)
return ''.join(rows)
def _get_time_entry_info_html(self, item):
"""Generate HTML for time entry information if available"""
if item.time_entry_ids:
count = len(item.time_entry_ids.split(','))
return f'
Generated from {count} time entries'
return ''
def _generate_totals_rows(self):
"""Generate HTML rows for invoice totals"""
rows = []
# Subtotal
rows.append(f"""
| Subtotal: |
{self._format_currency(self.invoice.subtotal)} |
""")
# Tax if applicable
if self.invoice.tax_rate > 0:
rows.append(f"""
| Tax ({self.invoice.tax_rate:.2f}%): |
{self._format_currency(self.invoice.tax_amount)} |
""")
# Total
rows.append(f"""
| Total Amount: |
{self._format_currency(self.invoice.total_amount)} |
""")
return ''.join(rows)
def _get_additional_info_html(self):
"""Generate HTML for additional invoice information"""
html_parts = []
if self.invoice.notes:
html_parts.append(f"""
{_('Notes:')}
{self.invoice.notes}
""")
if self.invoice.terms:
html_parts.append(f"""
{_('Terms:')}
{self.invoice.terms}
""")
if html_parts:
return f'{"".join(html_parts)}
'
return ''
def _format_currency(self, value):
"""Format numeric currency with thousands separators and 2 decimals."""
try:
return f"{float(value):,.2f} {self.settings.currency}"
except Exception:
return f"{value} {self.settings.currency}"
def _get_payment_info_html(self):
"""Generate HTML for payment information"""
if self.settings.company_bank_info:
return f"""
{_('Payment Information:')}
{self.settings.company_bank_info}
"""
return ''
def _generate_css(self):
"""Generate CSS styles for the invoice"""
return """
@page {
size: A4;
margin: 2cm;
@bottom-center {
content: "Page " counter(page) " of " counter(pages);
font-size: 10pt;
color: #666;
}
}
body {
font-family: 'Helvetica Neue', Arial, sans-serif;
font-size: 12pt;
line-height: 1.4;
color: #333;
margin: 0;
padding: 0;
}
.invoice-container {
max-width: 100%;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 2em;
border-bottom: 2px solid #007bff;
padding-bottom: 1em;
}
.company-info {
flex: 1;
}
.company-logo {
max-width: 150px;
max-height: 80px;
display: block;
margin-left: auto;
margin-right: 0;
margin-bottom: 1em;
}
.company-name {
font-size: 24pt;
font-weight: bold;
color: #007bff;
margin: 0 0 0.5em 0;
}
.company-address {
margin-bottom: 0.5em;
line-height: 1.3;
}
.company-contact {
margin-bottom: 0.5em;
}
.company-contact span {
display: block;
margin-bottom: 0.2em;
font-size: 10pt;
}
.company-tax {
font-size: 10pt;
color: #666;
}
.invoice-info {
text-align: right;
min-width: 200px;
}
.logo-container {
text-align: right;
margin-bottom: 1em;
}
.invoice-title {
font-size: 28pt;
font-weight: bold;
color: #007bff;
margin: 0 0 1em 0;
}
.invoice-details .detail-row {
margin-bottom: 0.5em;
}
.detail-row .label {
font-weight: bold;
margin-right: 0.5em;
}
.status-draft { color: #6c757d; }
.status-sent { color: #17a2b8; }
.status-paid { color: #28a745; }
.status-overdue { color: #dc3545; }
.status-cancelled { color: #343a40; }
.client-section, .project-section {
margin-bottom: 2em;
}
.client-section h3, .project-section h3 {
font-size: 14pt;
font-weight: bold;
color: #007bff;
margin: 0 0 0.5em 0;
border-bottom: 1px solid #dee2e6;
padding-bottom: 0.3em;
}
.client-name {
font-weight: bold;
font-size: 14pt;
margin-bottom: 0.5em;
}
.client-email, .client-address, .project-description {
margin-bottom: 0.3em;
color: #666;
}
.items-section {
margin-bottom: 2em;
}
.invoice-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1em;
}
.invoice-table th,
.invoice-table td {
border: 1px solid #dee2e6;
padding: 0.75em;
text-align: left;
}
.invoice-table th {
background-color: #f8f9fa;
font-weight: bold;
color: #495057;
}
.description { width: 40%; }
.quantity { width: 15%; text-align: center; }
.unit-price { width: 20%; text-align: right; }
.total { width: 25%; text-align: right; }
.text-center { text-align: center; }
.text-right { text-align: right; }
.time-entry-info {
color: #6c757d;
font-style: italic;
}
.subtotal { background-color: #f8f9fa; }
.tax { background-color: #fff3cd; }
.total { background-color: #d1ecf1; font-weight: bold; }
.additional-info {
margin-bottom: 2em;
}
.notes-section, .terms-section {
margin-bottom: 1em;
}
.notes-section h4, .terms-section h4 {
font-size: 12pt;
font-weight: bold;
color: #495057;
margin: 0 0 0.5em 0;
}
.footer {
margin-top: 2em;
padding-top: 1em;
border-top: 1px solid #dee2e6;
}
.payment-info {
margin-bottom: 1em;
}
.payment-info h4 {
font-size: 12pt;
font-weight: bold;
color: #495057;
margin: 0 0 0.5em 0;
}
.bank-info {
color: #666;
line-height: 1.3;
}
.terms h4 {
font-size: 12pt;
font-weight: bold;
color: #495057;
margin: 0 0 0.5em 0;
}
.terms p {
color: #666;
line-height: 1.3;
}
/* Utility classes */
.nl2br {
white-space: pre-line;
}
"""