Zenaight's picture
Upload 24 files
2d2c483 verified
Raw
History Blame Contribute Delete
32 kB
# 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'<!--CHARTDATASTART-->.*?<!--CHARTDATAEND-->', '', content_text, flags=re.DOTALL)
content_text = re.sub(r'<!--CHARTDATASTART-->.*$', '', 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'<!--CHARTDATASTART-->.*?<!--CHARTDATAEND-->', '', content_text, flags=re.DOTALL)
content_text = re.sub(r'<!--CHARTDATASTART-->.*$', '', 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)}")