""" 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: