| """ |
| Professional Q4 Report Generator - Soft Professional Theme |
| Comfortable, professional colors with random filename generation |
| """ |
| import markdown |
| from weasyprint import HTML, CSS |
| import requests |
| import os |
| import re |
| import secrets |
| from datetime import datetime |
| from typing import List, Dict, Optional |
| import logging |
| from pathlib import Path |
|
|
| |
| |
| |
| logging.basicConfig( |
| level=logging.INFO, |
| format='%(asctime)s - %(levelname)s - %(message)s', |
| datefmt='%Y-%m-%d %H:%M:%S' |
| ) |
| logger = logging.getLogger(__name__) |
|
|
| |
| |
| |
| 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: #2d3748; |
| line-height: 1.65; |
| font-size: 11pt; |
| } |
| |
| .header { |
| position: running(header); |
| font-size: 9pt; |
| color: #718096; |
| border-bottom: 1px solid #e2e8f0; |
| padding-bottom: 6px; |
| margin-bottom: 20px; |
| } |
| |
| .footer { |
| position: running(footer); |
| font-size: 9pt; |
| color: #718096; |
| border-top: 1px solid #e2e8f0; |
| padding-top: 6px; |
| text-align: center; |
| } |
| |
| .footer::after { |
| content: "Page " counter(page) " of " counter(pages); |
| } |
| |
| .confidential-mark { |
| color: #e53e3e; |
| 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: #4a5568; |
| margin-bottom: 0.5in; |
| letter-spacing: 1px; |
| line-height: 1.2; |
| } |
| |
| .cover-subtitle { |
| font-size: 20pt; |
| color: #5b7c99; |
| margin-bottom: 1.2in; |
| font-weight: 500; |
| } |
| |
| .cover-meta { |
| font-size: 15pt; |
| color: #718096; |
| margin-bottom: 0.25in; |
| line-height: 1.5; |
| } |
| |
| .cover-date { |
| font-size: 12pt; |
| color: #a0aec0; |
| margin-top: 0.5in; |
| } |
| |
| .cover-divider { |
| width: 3in; |
| height: 3px; |
| background: #5b7c99; |
| margin: 0.5in auto; |
| } |
| |
| h1 { |
| color: #4a5568; |
| font-size: 20pt; |
| border-bottom: 3px solid #5b7c99; |
| padding-bottom: 10px; |
| margin-top: 28px; |
| margin-bottom: 18px; |
| page-break-after: avoid; |
| font-weight: bold; |
| } |
| |
| h2 { |
| color: #5b7c99; |
| font-size: 15pt; |
| margin-top: 22px; |
| margin-bottom: 14px; |
| page-break-after: avoid; |
| font-weight: 600; |
| } |
| |
| h3 { |
| color: #6b8ba8; |
| font-size: 13pt; |
| margin-top: 18px; |
| margin-bottom: 12px; |
| page-break-after: avoid; |
| font-weight: 600; |
| } |
| |
| h4 { |
| color: #7a9bb7; |
| font-size: 12pt; |
| margin-top: 16px; |
| margin-bottom: 10px; |
| page-break-after: avoid; |
| font-weight: 600; |
| } |
| |
| h5 { |
| color: #718096; |
| font-size: 11.5pt; |
| margin-top: 14px; |
| margin-bottom: 8px; |
| page-break-after: avoid; |
| font-weight: 600; |
| } |
| |
| h6 { |
| color: #a0aec0; |
| 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.08); |
| } |
| |
| thead { |
| background: #5b7c99; |
| } |
| |
| th { |
| background-color: #5b7c99; |
| color: white; |
| padding: 12px; |
| text-align: left; |
| font-weight: bold; |
| border: 1px solid #4a6b85; |
| font-size: 10.5pt; |
| } |
| |
| td { |
| padding: 10px 12px; |
| border: 1px solid #e2e8f0; |
| line-height: 1.5; |
| } |
| |
| tr:nth-child(even) { |
| background-color: #f7fafc; |
| } |
| |
| tr:nth-child(odd) { |
| background-color: #ffffff; |
| } |
| |
| .highlight-box { |
| background-color: #edf2f7; |
| border-left: 5px solid #5b7c99; |
| padding: 16px 20px; |
| margin: 20px 0; |
| page-break-inside: avoid; |
| box-shadow: 0 2px 4px rgba(0,0,0,0.04); |
| } |
| |
| .highlight-box p:first-child { |
| margin-top: 0; |
| } |
| |
| .highlight-box p:last-child { |
| margin-bottom: 0; |
| } |
| |
| .key-metric-box { |
| background: #5b7c99; |
| 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.12); |
| } |
| |
| .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 #5b7c99; |
| margin-left: 0; |
| padding-left: 20px; |
| color: #4a5568; |
| font-style: italic; |
| background-color: #f7fafc; |
| padding: 14px 14px 14px 20px; |
| margin: 18px 0; |
| box-shadow: 0 1px 2px rgba(0,0,0,0.04); |
| } |
| |
| blockquote p:first-child { |
| margin-top: 0; |
| } |
| |
| blockquote p:last-child { |
| margin-bottom: 0; |
| } |
| |
| blockquote strong { |
| font-weight: 700; |
| color: #2d3748; |
| } |
| |
| .figure-container { |
| text-align: center; |
| margin: 24px 0; |
| page-break-inside: avoid; |
| } |
| |
| .figure-container img { |
| max-width: 100%; |
| height: auto; |
| border: 1px solid #e2e8f0; |
| padding: 8px; |
| background: white; |
| box-shadow: 0 2px 4px rgba(0,0,0,0.08); |
| } |
| |
| .figure-caption { |
| font-size: 9.5pt; |
| color: #718096; |
| margin-top: 10px; |
| font-style: italic; |
| font-weight: 500; |
| } |
| |
| .executive-summary { |
| background-color: #ebf4ff; |
| border: 2px solid #5b7c99; |
| 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: #2d3748; |
| } |
| |
| em, i { |
| font-style: italic; |
| } |
| |
| code { |
| font-family: 'Courier New', monospace; |
| background-color: #edf2f7; |
| padding: 2px 6px; |
| border-radius: 3px; |
| font-size: 10pt; |
| color: #5b7c99; |
| } |
| |
| pre { |
| background-color: #edf2f7; |
| border: 1px solid #e2e8f0; |
| 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: #4a5568; |
| } |
| |
| a { |
| color: #5b7c99; |
| text-decoration: none; |
| } |
| |
| a:hover { |
| text-decoration: underline; |
| } |
| |
| hr { |
| border: none; |
| border-top: 2px solid #e2e8f0; |
| margin: 24px 0; |
| } |
| """ |
|
|
| |
| |
| |
| def generate_random_filename(base_name: str = "Q4_Report") -> str: |
| """Generate a random filename to avoid duplicates.""" |
| timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') |
| random_suffix = secrets.token_hex(4) |
| return f"{base_name}_{timestamp}_{random_suffix}.pdf" |
|
|
| |
| |
| |
| 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 |
|
|
| |
| |
| |
| def preprocess_markdown(content: str) -> str: |
| """Preprocess markdown content to ensure proper parsing inside HTML blocks.""" |
| container_tags = ['div', 'blockquote', 'section', 'article', 'aside'] |
| |
| for tag in container_tags: |
| pattern = rf'<{tag}(\s+[^>]*?)?(?!\s+markdown\s*=)>' |
| |
| def add_markdown_attr(match): |
| attrs = match.group(1) or '' |
| 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 |
|
|
| |
| |
| |
| class Q4ReportGenerator: |
| """Generates executive-quality Q4 reports with soft professional colors.""" |
| |
| 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.""" |
| html = "" |
| |
| content = section.get("content", "") |
| if content: |
| preprocessed_content = preprocess_markdown(content) |
| |
| html_content = markdown.markdown( |
| preprocessed_content, |
| extensions=[ |
| 'extra', 'sane_lists', 'tables', 'fenced_code', |
| 'nl2br', 'codehilite', 'toc', 'md_in_html', |
| 'attr_list', 'def_list', 'abbr', 'footnotes', 'smarty' |
| ], |
| extension_configs={ |
| 'codehilite': { |
| 'guess_lang': False, |
| 'noclasses': True, |
| 'css_class': 'highlight' |
| } |
| } |
| ) |
| |
| html += f"<div class='section'>{html_content}</div>" |
| |
| |
| 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> |
| """ |
| |
| if section.get("page_break", False): |
| html += '<div class="page-break"></div>' |
| |
| return html |
| |
| def generate(self, |
| sections: List[Dict], |
| filename: str = None, |
| output_dir: str = "./output", |
| cover_page: bool = True) -> tuple[bool, str]: |
| """ |
| Generate PDF report with random filename. |
| Returns: (success: bool, output_path: str) |
| """ |
| try: |
| os.makedirs(output_dir, exist_ok=True) |
| |
| |
| if filename is None: |
| filename = generate_random_filename("Q4_Report") |
| elif not filename.endswith('.pdf'): |
| |
| base = filename.replace('.pdf', '') |
| timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') |
| random_suffix = secrets.token_hex(4) |
| filename = f"{base}_{timestamp}_{random_suffix}.pdf" |
| |
| output_path = os.path.join(output_dir, filename) |
| |
| logger.info(f"Starting Q4 report generation: {output_path}") |
| logger.info("Theme: Soft Professional Colors") |
| |
| |
| body_html = "" |
| if cover_page: |
| body_html += self._build_cover_page() |
| |
| for idx, section in enumerate(sections): |
| logger.info(f"Processing section {idx + 1}/{len(sections)}") |
| body_html += self._process_section(section, output_dir) |
| |
| |
| 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> |
| """ |
| |
| |
| 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}") |
| return True, output_path |
| |
| 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}") |
|
|
|
|
| |
| |
| |
| if __name__ == "__main__": |
| 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 |
| } |
| ] |
| |
| success, output_path = generator.generate( |
| sections=sections, |
| filename="Q4_2024_Report", |
| output_dir="./reports" |
| ) |
| |
| if success: |
| print(f"✓ Report generated: {output_path}") |
| else: |
| print("✗ Report generation failed!") |