""" 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 CONFIGURATION # --------------------------------------------------------- logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S' ) logger = logging.getLogger(__name__) # --------------------------------------------------------- # SOFT PROFESSIONAL THEME - COMFORTABLE COLORS # --------------------------------------------------------- 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; } """ # --------------------------------------------------------- # FILENAME GENERATION # --------------------------------------------------------- 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) # 8 character hex string return f"{base_name}_{timestamp}_{random_suffix}.pdf" # --------------------------------------------------------- # 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.""" 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 # --------------------------------------------------------- # Q4 REPORT GENERATOR CLASS # --------------------------------------------------------- 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'
{self.header_text}
' def _build_footer(self) -> str: footer_html = '' return footer_html def _build_cover_page(self) -> str: return f"""
{self.title}
{self.subtitle}
{self.department}
Prepared by: {self.author}
{self.date}
""" 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"
{html_content}
" # 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"""
{caption}
{caption}
""" if section.get("page_break", False): html += '
' 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) # Generate random filename if not provided if filename is None: filename = generate_random_filename("Q4_Report") elif not filename.endswith('.pdf'): # Add random suffix even if filename provided 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") # Build HTML body 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) # Construct full HTML document full_html = f""" {self.title} {self._build_header()} {self._build_footer()} {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}") 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}") # --------------------------------------------------------- # EXAMPLE USAGE # --------------------------------------------------------- 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% |
**Important Note:** All figures are preliminary and subject to final audit.
""", "page_break": False } ] success, output_path = generator.generate( sections=sections, filename="Q4_2024_Report", # Will add random suffix automatically output_dir="./reports" ) if success: print(f"✓ Report generated: {output_path}") else: print("✗ Report generation failed!")