Executor / pdf_report_generation_service.py
Soumik Bose
go
a28f249
Raw
History Blame
19.4 kB
"""
Professional Q4 Report Generator - Complete Markdown Parsing
Ensures ALL markdown syntax is properly rendered in PDF reports
"""
import markdown
from weasyprint import HTML, CSS
import requests
import os
import re
from datetime import datetime
from typing import List, Dict, Optional
import logging
from pathlib import Path
# ---------------------------------------------------------
# LOGGING CONFIGURATION
# ---------------------------------------------------------
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------
# EXECUTIVE Q4 REPORT THEME - COMPLETE STYLING
# ---------------------------------------------------------
CSS_STYLE = """
@page {
size: letter;
margin: 0.75in 0.75in 1in 0.75in;
@top-center {
content: element(header);
}
@bottom-center {
content: element(footer);
}
}
body {
font-family: 'Helvetica', Arial, sans-serif;
color: #1a1a1a;
line-height: 1.65;
font-size: 11pt;
}
.header {
position: running(header);
font-size: 9pt;
color: #666;
border-bottom: 1px solid #ddd;
padding-bottom: 6px;
margin-bottom: 20px;
}
.footer {
position: running(footer);
font-size: 9pt;
color: #666;
border-top: 1px solid #ddd;
padding-top: 6px;
text-align: center;
}
.footer::after {
content: "Page " counter(page) " of " counter(pages);
}
.confidential-mark {
color: #d32f2f;
font-weight: bold;
float: left;
}
.cover-page {
text-align: center;
padding-top: 3in;
page-break-after: always;
}
.cover-title {
font-size: 36pt;
font-weight: bold;
color: #003366;
margin-bottom: 0.5in;
letter-spacing: 1px;
line-height: 1.2;
}
.cover-subtitle {
font-size: 20pt;
color: #0055a5;
margin-bottom: 1.2in;
font-weight: 500;
}
.cover-meta {
font-size: 15pt;
color: #555;
margin-bottom: 0.25in;
line-height: 1.5;
}
.cover-date {
font-size: 12pt;
color: #888;
margin-top: 0.5in;
}
.cover-divider {
width: 3in;
height: 3px;
background: linear-gradient(to right, #003366, #0055a5);
margin: 0.5in auto;
}
h1 {
color: #003366;
font-size: 20pt;
border-bottom: 3px solid #0055a5;
padding-bottom: 10px;
margin-top: 28px;
margin-bottom: 18px;
page-break-after: avoid;
font-weight: bold;
}
h2 {
color: #0055a5;
font-size: 15pt;
margin-top: 22px;
margin-bottom: 14px;
page-break-after: avoid;
font-weight: 600;
}
h3 {
color: #333;
font-size: 13pt;
margin-top: 18px;
margin-bottom: 12px;
page-break-after: avoid;
font-weight: 600;
}
h4 {
color: #555;
font-size: 12pt;
margin-top: 16px;
margin-bottom: 10px;
page-break-after: avoid;
font-weight: 600;
}
h5 {
color: #666;
font-size: 11.5pt;
margin-top: 14px;
margin-bottom: 8px;
page-break-after: avoid;
font-weight: 600;
}
h6 {
color: #777;
font-size: 11pt;
margin-top: 12px;
margin-bottom: 8px;
page-break-after: avoid;
font-weight: 600;
}
p {
margin: 10px 0;
line-height: 1.65;
}
table {
width: 100%;
border-collapse: collapse;
margin: 18px 0;
font-size: 10pt;
page-break-inside: avoid;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
thead {
background: linear-gradient(to bottom, #003366, #00264d);
}
th {
background-color: #003366;
color: white;
padding: 12px;
text-align: left;
font-weight: bold;
border: 1px solid #002244;
font-size: 10.5pt;
}
td {
padding: 10px 12px;
border: 1px solid #ddd;
line-height: 1.5;
}
tr:nth-child(even) {
background-color: #f8f9fa;
}
tr:nth-child(odd) {
background-color: #ffffff;
}
.highlight-box {
background-color: #e8f4f8;
border-left: 5px solid #0055a5;
padding: 16px 20px;
margin: 20px 0;
page-break-inside: avoid;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.highlight-box p:first-child {
margin-top: 0;
}
.highlight-box p:last-child {
margin-bottom: 0;
}
.key-metric-box {
background: linear-gradient(135deg, #003366 0%, #0055a5 100%);
color: white;
border-radius: 8px;
padding: 18px 22px;
margin: 20px 0;
page-break-inside: avoid;
box-shadow: 0 3px 6px rgba(0,0,0,0.15);
}
.key-metric-box h3,
.key-metric-box h4,
.key-metric-box h5 {
color: white;
margin-top: 0;
}
.key-metric-box p:first-child {
margin-top: 0;
}
.key-metric-box p:last-child {
margin-bottom: 0;
}
.key-metric-box strong {
color: #ffffff;
}
blockquote {
border-left: 5px solid #003366;
margin-left: 0;
padding-left: 20px;
color: #333;
font-style: italic;
background-color: #f8f9fa;
padding: 14px 14px 14px 20px;
margin: 18px 0;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
blockquote p:first-child {
margin-top: 0;
}
blockquote p:last-child {
margin-bottom: 0;
}
blockquote strong {
font-weight: 700;
color: #000;
}
.figure-container {
text-align: center;
margin: 24px 0;
page-break-inside: avoid;
}
.figure-container img {
max-width: 100%;
height: auto;
border: 1px solid #ddd;
padding: 8px;
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.figure-caption {
font-size: 9.5pt;
color: #666;
margin-top: 10px;
font-style: italic;
font-weight: 500;
}
.executive-summary {
background-color: #f0f7ff;
border: 2px solid #0055a5;
padding: 20px;
margin: 24px 0;
page-break-inside: avoid;
border-radius: 5px;
}
.executive-summary p:first-child {
margin-top: 0;
}
.executive-summary p:last-child {
margin-bottom: 0;
}
.page-break {
page-break-after: always;
}
/* COMPREHENSIVE LIST STYLING */
ul {
margin: 16px 0;
padding-left: 35px;
list-style-type: disc;
list-style-position: outside;
}
ol {
margin: 16px 0;
padding-left: 35px;
list-style-type: decimal;
list-style-position: outside;
}
li {
margin: 6px 0;
line-height: 1.6;
padding-left: 5px;
display: list-item;
}
li p {
margin: 4px 0;
}
ul ul {
margin: 8px 0;
padding-left: 25px;
list-style-type: circle;
}
ul ul ul {
list-style-type: square;
}
ol ol {
margin: 8px 0;
padding-left: 25px;
list-style-type: lower-alpha;
}
ol ol ol {
list-style-type: lower-roman;
}
/* Lists inside special boxes */
.highlight-box ul,
.highlight-box ol,
.key-metric-box ul,
.key-metric-box ol,
.executive-summary ul,
.executive-summary ol,
blockquote ul,
blockquote ol {
margin: 12px 0;
}
.key-metric-box li {
color: white;
}
/* TEXT FORMATTING */
strong, b {
font-weight: 700;
color: #000;
}
em, i {
font-style: italic;
}
code {
font-family: 'Courier New', monospace;
background-color: #f4f4f4;
padding: 2px 6px;
border-radius: 3px;
font-size: 10pt;
color: #c7254e;
}
pre {
background-color: #f4f4f4;
border: 1px solid #ddd;
border-radius: 4px;
padding: 12px;
overflow-x: auto;
font-family: 'Courier New', monospace;
font-size: 9.5pt;
line-height: 1.4;
margin: 16px 0;
}
pre code {
background-color: transparent;
padding: 0;
color: #333;
}
a {
color: #0055a5;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
hr {
border: none;
border-top: 2px solid #ddd;
margin: 24px 0;
}
"""
# ---------------------------------------------------------
# IMAGE HANDLING
# ---------------------------------------------------------
def fetch_image(url: str, output_dir: str) -> Optional[str]:
"""
Download image from URL to persistent file for PDF rendering.
"""
if not url.startswith(('http://', 'https://')):
if os.path.exists(url):
abs_path = os.path.abspath(url)
return Path(abs_path).as_uri()
return None
try:
img_dir = os.path.join(output_dir, 'images')
os.makedirs(img_dir, exist_ok=True)
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
response = requests.get(url, headers=headers, timeout=15)
response.raise_for_status()
content_type = response.headers.get('content-type', '')
ext = '.jpg'
if 'png' in content_type:
ext = '.png'
elif 'gif' in content_type:
ext = '.gif'
elif 'svg' in content_type:
ext = '.svg'
elif 'webp' in content_type:
ext = '.webp'
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S_%f')
filename = f'img_{timestamp}{ext}'
filepath = os.path.join(img_dir, filename)
with open(filepath, 'wb') as f:
f.write(response.content)
abs_path = os.path.abspath(filepath)
file_uri = Path(abs_path).as_uri()
logger.info(f"Downloaded image: {url[:50]}... -> {filename}")
return file_uri
except Exception as e:
logger.warning(f"Failed to download image: {url[:50]}... - {e}")
return None
# ---------------------------------------------------------
# MARKDOWN PREPROCESSING
# ---------------------------------------------------------
def preprocess_markdown(content: str) -> str:
"""
Preprocess markdown content to ensure proper parsing inside HTML blocks.
Adds markdown="1" attribute to known HTML container tags.
"""
# List of container tags that might contain markdown
container_tags = ['div', 'blockquote', 'section', 'article', 'aside']
for tag in container_tags:
# Pattern to find opening tags without markdown attribute
# Matches: <div class="something"> or <div> but not <div markdown="1">
pattern = rf'<{tag}(\s+[^>]*?)?(?!\s+markdown\s*=)>'
def add_markdown_attr(match):
attrs = match.group(1) or ''
# If there are existing attributes, add markdown after them
if attrs:
return f'<{tag}{attrs} markdown="1">'
else:
return f'<{tag} markdown="1">'
content = re.sub(pattern, add_markdown_attr, content, flags=re.IGNORECASE)
return content
# ---------------------------------------------------------
# Q4 REPORT GENERATOR CLASS
# ---------------------------------------------------------
class Q4ReportGenerator:
"""Generates executive-quality Q4 reports using WeasyPrint with complete markdown support."""
def __init__(self,
title: str = "Q4 Performance Report",
subtitle: str = None,
author: str = None,
department: str = None,
date: str = None,
header_text: str = None,
confidential: bool = True):
self.title = title
self.subtitle = subtitle or "Fourth Quarter Analysis"
self.author = author or "Data Analytics Team"
self.department = department or "Analytics Department"
self.date = date or datetime.now().strftime("%B %d, %Y")
self.confidential = confidential
self.downloaded_images = []
if header_text:
self.header_text = header_text
else:
parts = [self.department, self.subtitle]
self.header_text = " | ".join(parts)
def _build_header(self) -> str:
return f'<div class="header">{self.header_text}</div>'
def _build_footer(self) -> str:
footer_html = '<div class="footer">'
if self.confidential:
footer_html += '<span class="confidential-mark">CONFIDENTIAL</span>'
footer_html += '</div>'
return footer_html
def _build_cover_page(self) -> str:
return f"""
<div class="cover-page">
<div class="cover-title">{self.title}</div>
<div class="cover-divider"></div>
<div class="cover-subtitle">{self.subtitle}</div>
<div class="cover-meta">{self.department}</div>
<div class="cover-meta">Prepared by: {self.author}</div>
<div class="cover-date">{self.date}</div>
</div>
"""
def _process_section(self, section: Dict, output_dir: str) -> str:
"""
Process section with COMPLETE markdown parsing support.
Handles markdown both outside and inside HTML tags.
"""
html = ""
content = section.get("content", "")
if content:
# Step 1: Preprocess to add markdown="1" to HTML containers
preprocessed_content = preprocess_markdown(content)
# Step 2: Apply comprehensive markdown parsing
html_content = markdown.markdown(
preprocessed_content,
extensions=[
'extra', # Tables, footnotes, attributes, etc.
'sane_lists', # Better list handling
'tables', # Table support
'fenced_code', # Code blocks with ```
'nl2br', # Convert newlines to <br>
'codehilite', # Syntax highlighting
'toc', # Table of contents
'md_in_html', # CRITICAL: Parse markdown inside HTML tags
'attr_list', # Add attributes to elements
'def_list', # Definition lists
'abbr', # Abbreviations
'footnotes', # Footnotes
'smarty' # Smart quotes and dashes
],
extension_configs={
'codehilite': {
'guess_lang': False,
'noclasses': True,
'css_class': 'highlight'
}
}
)
html += f"<div class='section'>{html_content}</div>"
# Handle images
images = section.get("images", [])
for img_data in images:
if isinstance(img_data, dict):
img_url = img_data.get("url", "")
caption = img_data.get("caption", "Figure: Visualization")
else:
img_url = img_data
caption = "Figure: Visualization"
if img_url:
file_uri = fetch_image(img_url, output_dir)
if file_uri:
self.downloaded_images.append(file_uri)
html += f"""
<div class="figure-container">
<img src="{file_uri}" alt="{caption}">
<div class="figure-caption">{caption}</div>
</div>
"""
# Page break if requested
if section.get("page_break", False):
html += '<div class="page-break"></div>'
return html
def generate(self,
sections: List[Dict],
filename: str = "Q4_Report.pdf",
output_dir: str = "./output",
cover_page: bool = True) -> bool:
"""Generate PDF report with complete markdown parsing everywhere."""
try:
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, filename)
logger.info(f"Starting Q4 report generation: {output_path}")
logger.info("Markdown parsing: FULL (inside and outside HTML tags)")
# Build HTML body
body_html = ""
if cover_page:
body_html += self._build_cover_page()
# Process all sections
for idx, section in enumerate(sections):
logger.info(f"Processing section {idx + 1}/{len(sections)}")
body_html += self._process_section(section, output_dir)
# Construct full HTML document
full_html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{self.title}</title>
</head>
<body>
{self._build_header()}
{self._build_footer()}
{body_html}
</body>
</html>
"""
# Generate PDF
logger.info("Rendering PDF with WeasyPrint...")
html_doc = HTML(string=full_html)
css_doc = CSS(string=CSS_STYLE)
html_doc.write_pdf(output_path, stylesheets=[css_doc])
logger.info(f"βœ“ PDF Generated Successfully: {output_path}")
logger.info(f"βœ“ All markdown syntax properly parsed")
return True
except Exception as e:
logger.error(f"βœ— Error generating report: {e}", exc_info=True)
return False
def cleanup_images(self, output_dir: str):
"""Optional: Clean up downloaded images after PDF generation."""
try:
img_dir = os.path.join(output_dir, 'images')
if os.path.exists(img_dir):
import shutil
shutil.rmtree(img_dir)
logger.info("Cleaned up temporary images")
except Exception as e:
logger.warning(f"Failed to cleanup images: {e}")
# ---------------------------------------------------------
# EXAMPLE USAGE
# ---------------------------------------------------------
if __name__ == "__main__":
# Example of how to use the generator
generator = Q4ReportGenerator(
title="Q4 2024 Performance Report",
subtitle="Executive Summary & Analysis",
author="Analytics Team",
department="Business Intelligence",
confidential=True
)
sections = [
{
"content": """
# Executive Summary
This report presents our **Q4 2024** performance metrics.
## Key Highlights
- Revenue increased by **23%**
- Customer satisfaction at **94%**
- Market share grew to **31%**
### Performance Metrics
| Metric | Q3 | Q4 | Change |
|--------|-------|-------|---------|
| Revenue | $2.3M | $2.8M | +23% |
| Customers | 1,200 | 1,450 | +21% |
| NPS Score | 72 | 78 | +8% |
<div class="highlight-box">
**Important Note:** All figures are preliminary and subject to final audit.
</div>
""",
"page_break": False
},
{
"content": """
# Strategic Recommendations
1. **Expand marketing efforts** in Q1 2025
2. **Invest in customer support** infrastructure
3. **Explore new markets** in Southeast Asia
---
*Report generated on January 24, 2026*
""",
"page_break": False
}
]
success = generator.generate(
sections=sections,
filename="Q4_2024_Report.pdf",
output_dir="./reports"
)
if success:
print("βœ“ Report generated successfully!")
else:
print("βœ— Report generation failed!")