# PDF Generator Module # Contains create_pdf_document() and all PDF-related functions import re import os import tempfile import time import sys from typing import List, Dict, Any # Add the parent directory to sys.path to import from models.py sys.path.append(os.path.dirname(os.path.dirname(__file__))) from models import ExportRequest # Import from the same directory (Document Generation) from document_helpers import ( filter_empty_sections, extract_business_name_from_content, remove_duplicate_headings ) from table_of_content import generate_table_of_contents_after_content, add_table_of_contents_page from chart_generator import parse_embedded_chart_data, create_chart_from_data from markdown_renderer import ( clean_text_for_pdf, render_markdown_to_pdf_grouped ) # Add Currency Mapping path currency_mapping_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'CurrencyMapping') if currency_mapping_path not in sys.path: sys.path.append(currency_mapping_path) from CurrencyMapping.currency_mapping import convert_currency_symbols_to_iso # Import sort_sections_by_order from utils module from utils import sort_sections_by_order def calculate_real_page_numbers(request: ExportRequest, filtered_sections): """Calculate real page numbers by creating a temporary PDF with all content INCLUDING charts""" try: from fpdf2 import FPDF except ImportError: try: from fpdf import FPDF except ImportError: raise Exception("Neither fpdf2 nor fpdf package found. Please install fpdf2.") # Create temporary PDF to calculate page numbers temp_pdf = FPDF() temp_pdf.set_auto_page_break(auto=True, margin=15) # Add cover page temp_pdf.add_page() # Add TOC page (placeholder) temp_pdf.add_page() # Parse charts from embedded chart data in section content embedded_charts = parse_embedded_chart_data(filtered_sections) # Create charts and map them to sections chart_info_map = {} if embedded_charts: for chart_info in embedded_charts: chart_bytes = create_chart_from_data(chart_info, request.businessIdea or "Business") if chart_bytes: source_section = chart_info["source_section"] if source_section not in chart_info_map: chart_info_map[source_section] = [] chart_info_map[source_section].append({ "chart_bytes": chart_bytes, "title": chart_info["title"], "type": chart_info["type"] }) # Add all sections to calculate real page numbers INCLUDING charts section_page_map = {} sorted_sections = sort_sections_by_order(filtered_sections) for section in sorted_sections: temp_pdf.add_page() current_page = temp_pdf.page_no() section_page_map[section.title] = current_page # Don't add section title manually - let the markdown content handle headings # Add section content temp_pdf.set_font("Arial", "", 12) temp_pdf.set_text_color(51, 51, 51) temp_pdf.set_xy(25, temp_pdf.get_y()) # Process content try: content_text = clean_text_for_pdf(section.content) except Exception as e: content_text = section.content or "" # Remove chart data markers content_text = re.sub(r'.*?', '', content_text, flags=re.DOTALL) content_text = re.sub(r'.*$', '', content_text, flags=re.DOTALL) # No need to filter section titles since we're not adding them manually # Add content with proper markdown rendering if content_text and content_text.strip(): render_markdown_to_pdf_grouped(temp_pdf, content_text, (0,0,0), (75,0,130), (51,51,51)) # Add charts for this section (same logic as in actual PDF generation) section_charts = chart_info_map.get(section.title, []) # Try different matching strategies for charts if not section_charts: # Case-insensitive match for chart_source, charts in chart_info_map.items(): if chart_source.lower() == section.title.lower(): section_charts = charts break # Partial match if not section_charts: for chart_source, charts in chart_info_map.items(): if (section.title.lower() in chart_source.lower() or chart_source.lower() in section.title.lower()): section_charts = charts break # Key-based match if not section_charts and hasattr(section, 'key'): for chart_source, charts in chart_info_map.items(): if section.key and section.key.replace('prompt_', '') in chart_source.lower(): section_charts = charts break # Add charts with same page break logic as actual PDF if section_charts: charts_per_page = 0 max_charts_per_page = 5 for chart_info in section_charts: try: chart_height_needed = 140 # Chart height (120) + title (20) + minimal spacing current_y = temp_pdf.get_y() charts_per_page += 1 # Only force page break if absolutely necessary if (current_y + chart_height_needed > 290) or (charts_per_page > 5): temp_pdf.add_page() y_title = 25 charts_per_page = 1 else: y_title = current_y # Add minimal spacing before chart if not at top of page if y_title > 25: temp_pdf.ln(3) # Chart title is already included in the chart itself, no need for separate title # Add chart image img_width = 160 # Much larger chart width x = (210 - img_width) / 2 y_img = y_title # Save image to temp file with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as tmp_img: tmp_img.write(chart_info["chart_bytes"]) temp_img_path = tmp_img.name # Add chart chart_height = 120 # Much larger chart height temp_pdf.image(temp_img_path, x=x, y=y_img, w=img_width) temp_pdf.ln(chart_height + 5) # Clean up temp file try: os.remove(temp_img_path) except Exception: pass except Exception: # Skip faulty chart and continue continue return section_page_map def create_pdf_document(request: ExportRequest): """Create a PROFESSIONAL PDF document from the business plan data using FPDF2 NOTE: Now uses matplotlib charts exclusively - old base64 chart logic commented out""" try: from fpdf2 import FPDF except ImportError: try: from fpdf import FPDF except ImportError: raise Exception("Neither fpdf2 nor fpdf package found. Please install fpdf2.") # Filter out empty sections to prevent blank pages try: filtered_sections = filter_empty_sections(request.sections) except Exception as e: raise Exception(f"Failed to filter sections: {str(e)}") if not filtered_sections: raise Exception("No valid sections found after filtering. All sections appear to be empty.") # First pass: Calculate real page numbers by creating a temporary PDF section_page_map = calculate_real_page_numbers(request, filtered_sections) # Generate TOC with real page numbers toc_items = generate_table_of_contents_after_content(filtered_sections, section_page_map, request.businessIdea) # Second pass: Create final PDF with TOC on page 2 pdf = FPDF() pdf.set_auto_page_break(auto=True, margin=15) # Reduced margin to allow more charts per page # Add confidentiality footer to every page def add_confidentiality_footer(pdf): pdf.set_font("Arial", "", 8) pdf.set_text_color(100, 100, 100) # Gray text pdf.set_y(-15) # 15mm from bottom pdf.cell(0, 5, "CONFIDENTIAL - This document contains proprietary information and may not be shared without consent", 0, 0, 'C') # Override the footer method pdf.footer = lambda: add_confidentiality_footer(pdf) pdf.add_page() # Professional color scheme - Refined for bank/investor presentation primary_color = (0, 0, 0) # Black - for headings accent_color = (75, 0, 130) # Navy purple - for accent lines and highlights text_color = (51, 51, 51) # Dark gray - for body text light_gray = (221, 221, 221) # Lighter gray - for subtle lines (#DDDDDD) navy_color = (25, 25, 112) # Navy blue - for TOC and emphasis # Professional fonts - Arial for body, optional serif for main titles title_font = 'Arial' # Keep Arial for consistency body_font = 'Arial' # Modern business feel # Helper functions for consistent styling def add_header(pdf, text, size=24, y_offset=25, primary_color=(0,0,0), accent_color=(75,0,130)): pdf.set_font("Arial", "B", size) pdf.set_text_color(*primary_color) pdf.set_xy(25, y_offset) pdf.cell(0, 18, text, ln=True) # Calculate text width and make line autofit text_width = pdf.get_string_width(text) line_width = min(text_width + 10, 160) # Add 10mm padding, max 160mm (page width - margins) # Removed purple accent line return y_offset + 25 def add_subheader(pdf, text, size=16, y_offset=None, primary_color=(0,0,0)): if y_offset is None: y_offset = pdf.get_y() + 10 pdf.set_font("Arial", "B", size) pdf.set_text_color(*primary_color) pdf.set_xy(25, y_offset) pdf.cell(0, 14, text, ln=True) # Calculate text width and make line autofit text_width = pdf.get_string_width(text) line_width = min(text_width + 8, 160) # Add 8mm padding, max 160mm (page width - margins) pdf.set_draw_color(221, 221, 221) # Lighter gray (#DDDDDD) for subtle lines pdf.set_line_width(0.3) # Thinner line pdf.line(25, y_offset + 14, 25 + line_width, y_offset + 14) return y_offset + 20 def add_body_text(pdf, text, y_offset=None, text_color=(51,51,51)): if y_offset is None: y_offset = pdf.get_y() + 5 pdf.set_font("Arial", "", 12) pdf.set_text_color(*text_color) pdf.set_xy(25, y_offset) pdf.multi_cell(160, 8, text, align='L') # Left align to prevent text justification return pdf.get_y() def check_page_break(pdf, required_height=120, bottom_margin=300): """Check if we need a page break and return the Y position to use""" y = pdf.get_y() if y + required_height > bottom_margin: pdf.add_page() return 25 return y # ===== COVER PAGE ===== def add_cover_page(): # Clean white background - no purple background # Add company logo at the top try: # Use the MBD logo PNG file - now in app folder for HF deployment app_root = os.path.dirname(os.path.dirname(__file__)) logo_path = os.path.join(app_root, 'assets', 'logos', 'MBD logo.png') if os.path.exists(logo_path): # Add logo to PDF (centered, top of page) - LOGO FIRST, THEN TITLE BELOW # Center the logo: (210 - 70) / 2 = 70, so logo starts at x=70 pdf.image(logo_path, x=70, y=20, w=70) # Even larger size to show full logo print(f"Logo added successfully from: {logo_path}") else: print(f"Logo file not found at: {logo_path}") except Exception as e: # Log any errors with logo print(f"Logo error: {e}") pass # Extract business name from content, fallback to businessIdea extracted_business_name = extract_business_name_from_content(filtered_sections) business_name = extracted_business_name or request.businessIdea or "Business Plan" # Company/Project name - DARK TEXT (moved down below logo) pdf.set_font(title_font, "B", 32) pdf.set_text_color(0, 0, 0) # Black text for visibility on white background pdf.ln(60) # More space to move title below logo pdf.cell(0, 25, business_name, ln=True, align='C') # Subtitle - DARK GRAY, BOLD, NO ITALICS pdf.set_font(title_font, "B", 16) # Reduced from 18pt, removed italics pdf.set_text_color(51, 51, 51) # Dark gray for visibility on white background pdf.cell(0, 12, "Business Plan", ln=True, align='C') # Add some space pdf.ln(40) pdf.set_font(body_font, "", 12) pdf.set_text_color(51, 51, 51) # Dark text for content pdf.set_xy(35, 145) pdf.cell(0, 8, f"Created: {time.strftime('%B %d, %Y')}", ln=True) pdf.set_xy(35, 153) pdf.cell(0, 8, f"Format: {request.format.upper()}", ln=True) pdf.set_xy(35, 161) pdf.cell(0, 8, f"Sections: {len(request.sections)}", ln=True) # Footer - simple footer since confidentiality is now in page footer pdf.set_font(body_font, "", 10) pdf.set_text_color(51, 51, 51) # Dark gray text for visibility on white background pdf.set_xy(0, 280) pdf.cell(0, 8, "Business Plan Document", ln=True, align='C') # ===== SECTION PAGES ===== def add_sections_with_charts(): # Parse charts from embedded chart data in section content embedded_charts = parse_embedded_chart_data(filtered_sections) if embedded_charts: # Create actual charts from the parsed data matplotlib_charts = [] chart_info_map = {} # Map section names to their charts # logging.info(f"Chart info map keys: {list(chart_info_map.keys())}") for chart_info in embedded_charts: chart_bytes = create_chart_from_data(chart_info, request.businessIdea or "Business") if chart_bytes: matplotlib_charts.append(chart_bytes) # Map this chart to its source section source_section = chart_info["source_section"] # logging.info(f"Chart '{chart_info['title']}' has source_section: '{source_section}'") if source_section not in chart_info_map: chart_info_map[source_section] = [] chart_info_map[source_section].append({ "chart_bytes": chart_bytes, "title": chart_info["title"], "type": chart_info["type"] }) # logging.info(f"Mapping chart '{chart_info['title']}' to source section: '{source_section}'") # CRITICAL DEBUG: Show the exact mapping being created # logging.info(f" -> Chart '{chart_info['title']}' mapped to key '{source_section}' in chart_info_map") else: pass # logging.info(f"Total charts created: {len(matplotlib_charts)}") # logging.info(f"Final chart_info_map: {chart_info_map}") # Debug: Show all chart mappings for source_section, charts in chart_info_map.items(): # logging.info(f"Source section '{source_section}' has {len(charts)} charts:") for chart in charts: # logging.info(f" - {chart['title']} ({chart['type']})") pass else: matplotlib_charts = [] chart_info_map = {} total_charts_placed = 0 # Sort sections according to predefined order sorted_sections = sort_sections_by_order(filtered_sections) # logging.info(f"Section titles in sorted order: {[s.title for s in sorted_sections]}") # CRITICAL DEBUG: Show exact section titles and their keys for i, section in enumerate(sorted_sections): # logging.info(f"Section {i+1}: '{section.title}' (key: {getattr(section, 'key', 'NO_KEY')})") pass for i, section in enumerate(sorted_sections): # Always start each section on a new page pdf.add_page() # Don't add section title manually - let the markdown content handle headings # Section content (properly positioned) pdf.set_font(body_font, "", 12) pdf.set_text_color(*text_color) pdf.set_xy(25, pdf.get_y()) # Use current Y position for consistent spacing # Process content try: content_text = clean_text_for_pdf(section.content) except Exception as e: content_text = section.content or "" try: content_text = convert_currency_symbols_to_iso(content_text) except Exception: # If currency conversion fails, keep original text pass # Remove chart data markers content_text = re.sub(r'.*?', '', content_text, flags=re.DOTALL) content_text = re.sub(r'.*$', '', content_text, flags=re.DOTALL) # Remove chart titles from content to prevent duplicate headings # Get chart titles for this section section_charts = chart_info_map.get(section.title, []) if not section_charts: # Try case-insensitive match for chart_source, charts in chart_info_map.items(): if chart_source.lower() == section.title.lower(): section_charts = charts break # Remove chart titles from content for chart_info in section_charts: chart_title = chart_info["title"] # Remove various markdown heading formats for the chart title patterns_to_remove = [ rf'^#+\s*{re.escape(chart_title)}\s*$', # # Title, ## Title, etc. rf'^\*\*{re.escape(chart_title)}\*\*\s*$', # **Title** rf'^{re.escape(chart_title)}\s*$', # Just the title ] for pattern in patterns_to_remove: content_text = re.sub(pattern, '', content_text, flags=re.MULTILINE | re.IGNORECASE) # No need to filter section titles since we're not adding them manually # Skip enhance_subheadings_with_bold for grouped rendering to preserve ** markers # content_text = enhance_subheadings_with_bold(content_text) content_text = remove_duplicate_headings(content_text) # Add content with proper markdown rendering using grouped approach if content_text and content_text.strip(): render_markdown_to_pdf_grouped(pdf, content_text, primary_color, accent_color, text_color) else: # Add placeholder text to prevent blank page pdf.multi_cell(160, 8, f"Content for {section.title} section is being processed...", align='L') # Add visual separation between content and charts if content_text and content_text.strip(): pdf.ln(15) # More generous spacing for better visual separation # ---- Charts for this section ---- charts_added = 0 # IMPORTANT: initialize to avoid NameError # CRITICAL DEBUG: Show exactly what charts exist and their source_section values if section.title == "Financial Plan & Funding": # logging.info(f"=== LAST SECTION DEBUG ===") # logging.info(f"Section title: '{section.title}'") # logging.info(f"All chart_info_map keys: {list(chart_info_map.keys())}") for key, charts in chart_info_map.items(): # logging.info(f" Key '{key}' has {len(charts)} charts") for chart in charts: # logging.info(f" Chart: {chart['title']} ({chart['type']})") pass # logging.info(f"=== END LAST SECTION DEBUG ===") pass section_charts = chart_info_map.get(section.title, []) # logging.info(f"Section '{section.title}': Direct lookup found {len(section_charts)} charts") # Strategy 1: Exact title match if not section_charts: # logging.info(f"No exact match found for '{section.title}'") pass # Strategy 2: Case-insensitive match for chart_source, charts in chart_info_map.items(): if chart_source.lower() == section.title.lower(): section_charts = charts # logging.info(f"Found charts via case-insensitive match: '{chart_source}' -> {len(charts)} charts") break # Strategy 3: Partial match if not section_charts: for chart_source, charts in chart_info_map.items(): if (section.title.lower() in chart_source.lower() or chart_source.lower() in section.title.lower()): section_charts = charts # logging.info(f"Found charts via partial match: '{chart_source}' -> {len(charts)} charts") break # Strategy 4: Key-based match (for prompt_ prefixed sections) if not section_charts and hasattr(section, 'key'): for chart_source, charts in chart_info_map.items(): if section.key and section.key.replace('prompt_', '') in chart_source.lower(): section_charts = charts # logging.info(f"Found charts via key match: '{section.key}' -> '{chart_source}' -> {len(charts)} charts") break # logging.info(f"Section '{section.title}': Final chart count: {len(section_charts)}") if section_charts: # Group charts that can fit on the same page charts_per_page = 0 max_charts_per_page = 5 # Allow up to 5 charts per page for chart_info in section_charts: try: # Check if we need a new page for this chart chart_height_needed = 140 # Chart height (120) + title (20) + minimal spacing # Smart page break logic - allow multiple charts per page current_y = pdf.get_y() charts_per_page += 1 # Only force page break if absolutely necessary - allow up to 5 charts per page if (current_y + chart_height_needed > 290) or (charts_per_page > 5): pdf.add_page() # Header/footer removed for now y_title = 25 # Start at normal position charts_per_page = 1 # Reset counter for new page else: y_title = current_y # Add minimal spacing before chart if not at top of page if y_title > 25: # If not at top of page, add minimal space pdf.ln(3) # Minimal spacing to fit more charts per page # Chart title is already included in the chart itself, no need for separate subheader # Centered image with larger dimensions for better visibility img_width = 160 # Much larger chart width for better visibility x = (210 - img_width) / 2 y_img = y_title # Save image to temp file with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as tmp_img: tmp_img.write(chart_info["chart_bytes"]) temp_img_path = tmp_img.name # Chart dimensions (no border needed) chart_height = 120 # Much larger height for better visibility chart_width = img_width # Image pdf.image(temp_img_path, x=x, y=y_img, w=chart_width) # Move below image and clean up (spacing based on actual chart height) pdf.ln(chart_height + 5) # Chart height (120) + minimal spacing between charts try: os.remove(temp_img_path) except Exception: pass charts_added += 1 total_charts_placed += 1 except Exception: # Skip faulty chart and continue rendering others continue else: # Debug: Check if there are any charts that might match this section potential_matches = [] for chart_source, charts in chart_info_map.items(): if (section.title.lower() in chart_source.lower() or chart_source.lower() in section.title.lower()): potential_matches.append(f"'{chart_source}' -> {len(charts)} charts") if potential_matches: # logging.info(f"Section '{section.title}': Potential chart matches: {potential_matches}") pass else: # logging.info(f"Section '{section.title}': No charts found and no potential matches") pass # Each section is already on its own page, no need for complex page break logic # ===== BUILD THE PDF ===== try: # Add all sections try: add_cover_page() except Exception as e: raise Exception(f"Cover page failed: {str(e)}") # Add TOC on page 2 with REAL page numbers (already calculated above) try: if toc_items: add_table_of_contents_page(pdf, toc_items, request.businessIdea) else: # Add a basic TOC page if no items were generated pdf.add_page() pdf.set_font("Arial", "B", 20) pdf.set_text_color(0, 0, 0) pdf.set_xy(25, 25) pdf.cell(0, 18, "Table of Contents", ln=True) pdf.set_font("Arial", "", 12) pdf.set_text_color(51, 51, 51) pdf.set_xy(25, 50) pdf.multi_cell(160, 8, "No sections available for table of contents.", align='L') except Exception as e: # logging.error(f"Table of contents failed: {str(e)}") pass # Add a basic TOC page as fallback pdf.add_page() pdf.set_font("Arial", "B", 20) pdf.set_text_color(0, 0, 0) pdf.set_xy(25, 25) pdf.cell(0, 18, "Table of Contents", ln=True) pdf.set_font("Arial", "", 12) pdf.set_text_color(51, 51, 51) pdf.set_xy(25, 50) pdf.multi_cell(160, 8, "Table of contents could not be generated.", align='L') # Executive summary is now handled as part of the sections # No separate page needed - the first section contains the executive summary try: add_sections_with_charts() except Exception as e: raise Exception(f"Sections with charts failed: {str(e)}") # CRITICAL CHECK: Ensure we have content in the PDF total_pages = pdf.page_no() if total_pages < 2: # Should have at least cover + 1 content page # Add a content page to prevent blank PDF pdf.add_page() pdf.set_font("Arial", "B", 16) pdf.set_text_color(255, 0, 0) pdf.cell(0, 20, "Content Processing Issue", ln=True, align='C') pdf.set_font("Arial", "", 12) pdf.set_text_color(0, 0, 0) pdf.cell(0, 10, "The PDF generation encountered an issue with content processing.", ln=True, align='C') pdf.cell(0, 10, "Please check the server logs for detailed information.", ln=True, align='C') # Get PDF output and convert to bytes try: pdf_output = pdf.output(dest='S') if isinstance(pdf_output, bytearray): pdf_bytes = bytes(pdf_output) else: pdf_bytes = pdf_output.encode('latin-1') if isinstance(pdf_output, str) else pdf_output # CRITICAL SAFETY CHECK: Ensure PDF is not empty if len(pdf_bytes) < 1000: # PDF should be at least 1KB # Try to add some content to prevent blank PDF pdf.add_page() pdf.set_font("Arial", "B", 16) pdf.set_text_color(255, 0, 0) # Red text pdf.cell(0, 20, "PDF Generation Issue Detected", ln=True, align='C') pdf.set_font("Arial", "", 12) pdf.set_text_color(0, 0, 0) pdf.cell(0, 10, "If you see this message, there was an issue with content processing.", ln=True, align='C') pdf.cell(0, 10, "Please check the server logs for details.", ln=True, align='C') # Regenerate PDF with error message pdf_output = pdf.output(dest='S') if isinstance(pdf_output, bytearray): pdf_bytes = bytes(pdf_output) else: pdf_bytes = pdf_output.encode('latin-1') if isinstance(pdf_output, str) else pdf_output return pdf_bytes, f"business_plan_{request.businessIdea or 'export'}.pdf" except Exception as e: raise Exception(f"PDF conversion failed: {str(e)}") except Exception as e: # logging.error(f"PDF generation failed: {e}") raise Exception(f"Failed to generate professional PDF: {str(e)}")