diff --git "a/app/main.py" "b/app/main.py" --- "a/app/main.py" +++ "b/app/main.py" @@ -22,9 +22,6 @@ import tempfile # ────────────────────────────────────────────────────────────────────────────── import hashlib, socket, random import matplotlib - -# Fix matplotlib permission issues by setting cache directory to a writable location -os.environ['MPLCONFIGDIR'] = '/tmp/matplotlib_cache' matplotlib.use('Agg') # Use non-interactive backend for server environments import matplotlib.pyplot as plt @@ -87,55 +84,6 @@ from langchain.llms.base import LLM from huggingface_hub import InferenceClient -# ────────────────────────────────────────────────────────────────────────────── -# Document Generation Imports -# ────────────────────────────────────────────────────────────────────────────── -import sys -import os - -# Add the Document Generation and Currency Mapping paths to sys.path -sys.path.append(os.path.join(os.path.dirname(__file__), 'DocumentGeneration')) -sys.path.append(os.path.join(os.path.dirname(__file__), 'CurrencyMapping')) - -# Import models -from models import ExportRequest, ExportSection, ExportResponse, SectionJob, BatchGenerateRequest, GenerateRequest, CreatePlanRequest - -# Import constants -from constants import SECTION_ORDER, SECTION_MAP, SECTION_COLUMNS - -# Import utilities -from utils import sort_sections_by_order - -# Now import the modules -import sys -import os - -# Add the Document Generation and Currency Mapping paths to sys.path -sys.path.append(os.path.join(os.path.dirname(__file__), 'DocumentGeneration')) -sys.path.append(os.path.join(os.path.dirname(__file__), 'CurrencyMapping')) - -from DocumentGeneration.pdf_generator import create_pdf_document -from DocumentGeneration.word_generator import create_word_document -from DocumentGeneration.live_pdf_generator import live_pdf_generator -from DocumentGeneration.chart_generator import parse_embedded_chart_data, create_chart_from_data -from DocumentGeneration.table_of_content import generate_table_of_contents_after_content, add_table_of_contents_page -from DocumentGeneration.document_helpers import ( - extract_business_name_from_content, - filter_empty_sections, - extract_subheadings, - remove_duplicate_headings -) -from DocumentGeneration.grouped import parse_content_into_groups, extract_key_phrases, get_content_groups_info -from DocumentGeneration.markdown_renderer import ( - clean_text_for_pdf, - render_markdown_to_pdf, - render_markdown_to_pdf_grouped, - render_markdown_to_docx, - render_markdown_to_docx_grouped, - clean_text_for_docx -) -from CurrencyMapping.currency_mapping import convert_currency_symbols_to_iso - # ────────────────────────────────────────────────────────────────────────────── # Logging # ────────────────────────────────────────────────────────────────────────────── @@ -250,11 +198,114 @@ class SectionJob(BaseModel): temperature: Optional[float] = None maxTokens: Optional[int] = None +class BatchGenerateRequest(BaseModel): + answers: List[str] + planType: Optional[str] = "free" # "free" | "full" + businessPlanId: Optional[str] = None + countryCode: Optional[str] = None + businessIdea: Optional[str] = None + anonymousId: Optional[str] = None # NEW: for free/anon tracking + feedback: Optional[str] = None # optional feedback to inject + sections: List[SectionJob] + format: Optional[str] = None # "pdf" or "word" - optional for generation + chartImages: Optional[List[str]] = None # Base64 encoded images + +class ExportResponse(BaseModel): + success: bool + downloadUrl: str + filename: str + size: int + message: str + documentData: Optional[str] = None # Base64 encoded document data for frontend upload + +# Add these missing models +class ExportSection(BaseModel): + key: str + title: str + content: str + +class ExportRequest(BaseModel): + summary: str + sections: List[ExportSection] + businessIdea: Optional[str] = None + isFullPlan: Optional[bool] = False + format: str # "pdf" or "word" + chartImages: Optional[List[str]] = None # Base64 encoded images + # Database helper constants +SECTION_MAP = { + "prompt_ExecutiveSummary": ("executiveSummary", "executiveSummaryComplete", "executiveSummaryGenerating"), + "prompt_CompanyProfile": ("companyProfile", "companyProfileComplete", "companyProfileGenerating"), + "prompt_MarketAnalysis": ("marketAnalysis", "marketAnalysisComplete", "marketAnalysisGenerating"), + "prompt_ProductOrService": ("productOrService", "productOrServiceComplete", "productOrServiceGenerating"), + "prompt_BusinessModel": ("businessModel", "businessModelComplete", "businessModelGenerating"), + "prompt_MarketingGrowth": ("marketingGrowth", "marketingGrowthComplete", "marketingGrowthGenerating"), + "prompt_OperationsPlan": ("operationsPlan", "operationsPlanComplete", "operationsPlanGenerating"), + "prompt_ManagementTeam": ("managementTeam", "managementTeamComplete", "managementTeamGenerating"), + "prompt_FinancialPlanFunding": ("financialPlanFunding", "financialPlanFundingComplete", "financialPlanFundingGenerating"), +} -# Database helper functions +# Human-readable section titles for TOC and display +SECTION_ORDER = [ + "Executive Summary", + "Company Profile", + "Market Analysis", + "Product or Service", + "Business Model", + "Marketing & Growth", + "Operations Plan", + "Management Team", + "Financial Plan & Funding" +] +# Database column names for sections +SECTION_COLUMNS = [ + "executiveSummary", + "companyProfile", + "marketAnalysis", + "productOrService", + "businessModel", + "marketingGrowth", + "operationsPlan", + "managementTeam", + "financialPlanFunding", +] +def sort_sections_by_order(sections: List[ExportSection]) -> List[ExportSection]: + """Sort sections according to the predefined SECTION_ORDER to ensure correct numerical ordering""" + # Create a mapping from section title to order index + order_map = {title: index for index, title in enumerate(SECTION_ORDER)} + + def get_section_order(section: ExportSection) -> int: + # Try to match by exact title first + if section.title in order_map: + order_index = order_map[section.title] + return order_index + + # Try case-insensitive matching + section_title_lower = section.title.lower() + for order_title, order_index in order_map.items(): + if order_title.lower() == section_title_lower: + return order_index + + # Try to match by key (remove 'prompt_' prefix if present) + key_clean = section.key.replace('prompt_', '') if section.key.startswith('prompt_') else section.key + if key_clean in order_map: + order_index = order_map[key_clean] + return order_index + + # Try partial matching for keys + for order_title, order_index in order_map.items(): + if key_clean.lower() in order_title.lower() or order_title.lower() in key_clean.lower(): + return order_index + + # If no match found, put at the end + return len(SECTION_ORDER) + + # Sort sections by their order + sorted_sections = sorted(sections, key=get_section_order) + + return sorted_sections def extract_summary_from_markdown(markdown_text: str, max_length: int = 200) -> str: """Extract a summary from markdown text, removing headers and formatting""" @@ -452,7 +503,35 @@ def clean_markdown_text(text: str) -> str: return cleaned +def clean_text_for_pdf(text: str) -> str: + """Clean text specifically for PDF generation, handling Unicode issues""" + if not text: + return "" + + # First clean markdown + cleaned = clean_markdown_text(text) + + # Replace problematic Unicode characters with ASCII equivalents + unicode_replacements = { + '\u2019': "'", # Right single quotation mark + '\u2018': "'", # Left single quotation mark + '\u201C': '"', # Left double quotation mark + '\u201D': '"', # Right double quotation mark + '\u2013': '-', # En dash + '\u2014': '--', # Em dash + '\u2022': '•', # Bullet + '\u2026': '...', # Horizontal ellipsis + '\u00A0': ' ', # Non-breaking space + '\u00B0': '°', # Degree sign + '\u00AE': '(R)', # Registered trademark + '\u2122': '(TM)', # Trademark + '\u00A9': '(C)', # Copyright + } + + for unicode_char, replacement in unicode_replacements.items(): + cleaned = cleaned.replace(unicode_char, replacement) + return cleaned def generate_model_output(model: str, provider: str, api_key: str, prompt: str, max_tokens: int = 4000) -> str: try: logging.info(f"Initializing InferenceClient with provider: {provider}") @@ -1445,6 +1524,740 @@ async def generate_business_plan(request: Request, data: GenerateRequest): logging.error(error_message) raise HTTPException(status_code=500, detail=error_message) +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 fpdf import FPDF + except ImportError: + raise Exception("fpdf2 is not installed. Please install it with: pip install fpdf2") + + # Filter out empty sections to prevent blank pages + filtered_sections = filter_empty_sections(request.sections) + if not filtered_sections: + raise Exception("No valid sections found after filtering. All sections appear to be empty.") + + # Generate table of contents + toc_items = generate_table_of_contents(filtered_sections, request.businessIdea) + # logging.info(f"Generated TOC with {len(toc_items)} items: {[item['title'] for item in toc_items[:3]]}...") + + # Create PDF with professional settings + 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) + 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 + + # 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 + pdf.set_font(title_font, "B", 32) + pdf.set_text_color(0, 0, 0) # Black text for visibility on white background + pdf.ln(15) + 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) + pdf.set_xy(35, 169) + pdf.cell(0, 8, f"Charts: {len(request.chartImages) if request.chartImages else 0}", 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() + + # Section title (properly positioned) + pdf.set_font(title_font, "B", 20) + pdf.set_text_color(*primary_color) + pdf.set_xy(25, 25) # Back to normal position + pdf.cell(0, 18, section.title, ln=True) + + # Add subtle line under section title - consistent with subheaders + text_width = pdf.get_string_width(section.title) + line_width = min(text_width + 8, 160) # Add 8mm padding, max 160mm + pdf.set_draw_color(221, 221, 221) # Lighter gray line (#DDDDDD) + pdf.set_line_width(0.3) # Thin line + pdf.line(25, pdf.get_y(), 25 + line_width, pdf.get_y()) + + # Consistent spacing after section title + pdf.ln(8) + + # 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 + content_text = clean_text_for_pdf(section.content) + content_text = convert_currency_symbols_to_iso(content_text) + + # 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 the section title from content to prevent duplication (since we already added it above) + section_title_clean = section.title.strip() + print(f"DEBUG: Section title: '{section_title_clean}'") + print(f"DEBUG: Original content before filtering: {content_text[:300]}...") + content_lines = content_text.split('\n') + filtered_lines = [] + for line in content_lines: + line_clean = line.strip() + # Skip lines that match the section title (with or without markdown) + # Check for various markdown formats and variations + if (line_clean == section_title_clean or + line_clean == f"**{section_title_clean}**" or + line_clean == f"# {section_title_clean}" or + line_clean == f"## {section_title_clean}" or + line_clean == f"### {section_title_clean}" or + line_clean == f"#### {section_title_clean}" or + # Also check if the line starts with the section title (for numbered headings) + line_clean.startswith(f"{section_title_clean}") and len(line_clean) <= len(section_title_clean) + 10): + continue + filtered_lines.append(line) + content_text = '\n'.join(filtered_lines) + + # 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...") + + # Add minimal spacer only if we have content + if content_text and content_text.strip(): + pdf.ln(3) # Small space after content + + # ---- 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 = 95 # Chart height (75) + 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 + + # Subheader (title) and subtle rule + y_title = add_subheader(pdf, chart_info["title"], 16, y_title) + + # Centered image with minimal border - optimized for more charts per page + img_width = 100 # Optimized for smaller charts (6x4.5 ratio) + 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 = 75 # Optimized height for 6:4.5 aspect ratio (100 * 4.5/6) + 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 (75) + minimal spacing between charts + try: + os.remove(temp_img_path) + except Exception: + pass + + charts_added += 1 + total_charts_placed += 1 + except Exception as chart_error: + 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)}") + + try: + # Ensure TOC items are generated before adding the page + 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.") + 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.") + + # 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)}") + +def create_word_document(request: ExportRequest): + """Create a Word document from the business plan data with front page, TOC, and proper page breaks""" + try: + from docx import Document + from docx.shared import Inches, Pt, RGBColor + from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_TAB_ALIGNMENT + from docx.oxml import OxmlElement + from docx.oxml.ns import qn + except ImportError: + raise Exception("python-docx is not installed. Please install it with: pip install python-docx") + + # Filter out empty sections to prevent blank pages + filtered_sections = filter_empty_sections(request.sections) + if not filtered_sections: + raise Exception("No valid sections found after filtering. All sections appear to be empty.") + + doc = Document() + + # Add confidentiality footer to every page + section = doc.sections[0] + footer = section.footer + footer_para = footer.paragraphs[0] + footer_para.alignment = WD_ALIGN_PARAGRAPH.CENTER + footer_run = footer_para.add_run("CONFIDENTIAL - This document contains proprietary information and may not be shared without consent") + footer_run.font.size = Pt(8) + footer_run.font.color.rgb = RGBColor(100, 100, 100) # Gray text + + # ===== FRONT PAGE ===== + # Add top spacing + doc.add_paragraph() + doc.add_paragraph() + + # 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" + + # Business name as main title with enhanced styling - PURPLE THEME + title = doc.add_heading(business_name, 0) + title.alignment = WD_ALIGN_PARAGRAPH.CENTER + + # Style the title with larger font and PURPLE color + for run in title.runs: + run.font.size = Pt(36) + run.font.color.rgb = RGBColor(139, 92, 246) # PURPLE + + # Removed decorative line under title + + # Add subtitle with enhanced styling - PURPLE + doc.add_paragraph() + subtitle = doc.add_paragraph("Professional Business Plan Document") + subtitle.alignment = WD_ALIGN_PARAGRAPH.CENTER + subtitle_run = subtitle.runs[0] + subtitle_run.font.size = Pt(18) + subtitle_run.font.color.rgb = RGBColor(124, 58, 237) # DARKER PURPLE + subtitle_run.italic = True + + # Add more spacing + doc.add_paragraph() + doc.add_paragraph() + doc.add_paragraph() + + # Add document info in a styled box - PURPLE BORDER + info_para = doc.add_paragraph() + info_para.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER + + # Add background color and border styling + info_run = info_para.add_run("Prepared by: Business Plan Generator") + info_run.font.size = Pt(14) + info_run.font.color.rgb = RGBColor(139, 92, 246) # PURPLE + + doc.add_paragraph() + date_para = doc.add_paragraph() + date_para.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER + date_run = date_para.add_run(f"Date: {time.strftime('%B %Y')}") + date_run.font.size = Pt(14) + date_run.font.color.rgb = RGBColor(139, 92, 246) # PURPLE + + # Confidentiality statement removed - now in footer of every page + + # Add more spacing before footer + doc.add_paragraph() + doc.add_paragraph() + doc.add_paragraph() + doc.add_paragraph() + + # Add footer with enhanced styling - PURPLE + footer = doc.add_paragraph("Confidential Business Information") + footer.alignment = WD_ALIGN_PARAGRAPH.CENTER + footer_run = footer.runs[0] + footer_run.font.size = Pt(12) + footer_run.font.color.rgb = RGBColor(139, 92, 246) # PURPLE + footer_run.bold = True + + # Removed decorative line above footer + + # ===== TABLE OF CONTENTS ===== + # Add page break before TOC + doc.add_page_break() + + # Add TOC title + toc_title = doc.add_heading("Table of Contents", level=1) + toc_title.alignment = WD_ALIGN_PARAGRAPH.CENTER + + # Removed decorative line under TOC title + + # Add spacing before TOC entries + doc.add_paragraph() + + # Sort sections according to predefined order (do this before TOC generation) + sorted_sections = sort_sections_by_order(filtered_sections) + + # Generate TOC entries with actual page numbers + # Since each section starts on a new page, we can calculate page numbers + toc_items = [] + current_page = 3 # Start after cover (1) and TOC (2) + + for section in sorted_sections: + if section.content and section.content.strip(): + # Add main section + toc_items.append({ + "title": section.title, + "level": 1, + "page": current_page + }) + + # Skip subheadings and chart titles - only include main section headings + + current_page += 1 # Each section starts on a new page + + # Add the actual TOC entries + for item in toc_items: + # Create TOC entry paragraph + toc_para = doc.add_paragraph() + toc_para.paragraph_format.space_after = Pt(6) + + # Add section title + title_run = toc_para.add_run(item["title"]) + title_run.font.size = Pt(11) + + # Add dots and page number + if item["level"] == 1: + # Main section - bold + title_run.bold = True + # Add dots + dots_run = toc_para.add_run(" " + "." * (60 - len(item["title"])) + " ") + dots_run.font.size = Pt(11) + dots_run.font.color.rgb = RGBColor(128, 128, 128) # Gray + # Add page number + page_run = toc_para.add_run(str(item["page"])) + page_run.font.size = Pt(11) + page_run.bold = True + else: + # Subsection - indented + toc_para.paragraph_format.left_indent = Inches(0.3) + # Add dots + dots_run = toc_para.add_run(" " + "." * (50 - len(item["title"])) + " ") + dots_run.font.size = Pt(11) + dots_run.font.color.rgb = RGBColor(128, 128, 128) # Gray + # Add page number + page_run = toc_para.add_run(str(item["page"])) + page_run.font.size = Pt(11) + + # logging.info(f"Generated {len(toc_items)} TOC items with actual page numbers") + + # Executive Summary is now handled as a regular section above + + # ===== MAIN SECTIONS ===== + for i, section in enumerate(sorted_sections): + if section.content.strip(): + # Add page break before each section (including Executive Summary) + doc.add_page_break() + + # Add section heading + doc.add_heading(section.title, level=1) + + # Clean and process content + clean_content = clean_text_for_pdf(section.content) + clean_content = convert_currency_symbols_to_iso(clean_content) + + # Remove chart data markers + clean_content = re.sub(r'.*?', '', clean_content, flags=re.DOTALL) + clean_content = re.sub(r'.*$', '', clean_content, flags=re.DOTALL) + + # Remove the section title from content to prevent duplication + section_title_clean = section.title.strip() + content_lines = clean_content.split('\n') + filtered_lines = [] + for line in content_lines: + line_clean = line.strip() + # Skip lines that match the section title (with or without markdown) + # Check for various markdown formats and variations + if (line_clean == section_title_clean or + line_clean == f"**{section_title_clean}**" or + line_clean == f"# {section_title_clean}" or + line_clean == f"## {section_title_clean}" or + line_clean == f"### {section_title_clean}" or + line_clean == f"#### {section_title_clean}" or + # Also check if the line starts with the section title (for numbered headings) + line_clean.startswith(f"{section_title_clean}") and len(line_clean) <= len(section_title_clean) + 10): + continue + filtered_lines.append(line) + clean_content = '\n'.join(filtered_lines) + + + + # Render markdown content with proper heading styling using grouped approach + render_markdown_to_docx_grouped(doc, clean_content) + + # Add charts for this section if they exist + embedded_charts = parse_embedded_chart_data([section]) + if embedded_charts: + for chart_info in embedded_charts: + try: + chart_bytes = create_chart_from_data(chart_info, request.businessIdea or "Business") + if chart_bytes: + # Add chart title + chart_title = chart_info["title"] + doc.add_heading(f"{chart_title}", level=2) + + # Save image temporarily + with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as tmp_img: + tmp_img.write(chart_bytes) + temp_img_path = tmp_img.name + + # Add image to Word document + doc.add_picture(temp_img_path, width=Inches(6.0)) # 6 inches width + + # Clean up temp file + os.remove(temp_img_path) + except Exception as chart_error: + # logging.warning(f"Failed to add chart for section '{section.title}': {chart_error}") + continue + + doc.add_paragraph() + + # Charts are now handled inline with the sections above + + # Save to bytes + buffer = io.BytesIO() + doc.save(buffer) + buffer.seek(0) + doc_bytes = buffer.getvalue() + buffer.close() + + filename = f"business_plan_{request.businessIdea or 'export'}.docx" + return doc_bytes, filename @app.post("/export", response_model=ExportResponse) async def export_business_plan(request: ExportRequest): @@ -1455,32 +2268,36 @@ async def export_business_plan(request: ExportRequest): FLOW: Server generates document → Returns base64 data → Frontend uploads to UploadThing → User gets download link """ try: - # Duplicate detection (best-effort; non-fatal if bookkeeping fails) - try: - request_id = str(id(request)) - request_fingerprint = f"{request.businessIdea}_{request.format}_{len(request.sections)}_{hash(str(request.sections))}" - current_time = time.time() - if hasattr(export_business_plan, 'last_requests'): - # Clean old requests (older than 5 seconds) - export_business_plan.last_requests = { - fp: timestamp for fp, timestamp in export_business_plan.last_requests.items() - if current_time - timestamp < 5 - } - # Check for duplicate - if request_fingerprint in export_business_plan.last_requests: - raise HTTPException( - status_code=429, - detail="Duplicate export request detected. Please wait a few seconds before trying again." - ) - else: - export_business_plan.last_requests = {} - # Record this request - export_business_plan.last_requests[request_fingerprint] = current_time - except HTTPException: - raise - except Exception: - # Do not block export on dedupe bookkeeping errors - pass + + + + # Generate unique request ID for tracking + request_id = str(id(request)) + + # Add request fingerprint for duplicate detection + request_fingerprint = f"{request.businessIdea}_{request.format}_{len(request.sections)}_{hash(str(request.sections))}" + + # Check if this is a duplicate request (within 5 seconds) + current_time = time.time() + if hasattr(export_business_plan, 'last_requests'): + # Clean old requests (older than 5 seconds) + export_business_plan.last_requests = { + fp: timestamp for fp, timestamp in export_business_plan.last_requests.items() + if current_time - timestamp < 5 + } + + # Check for duplicate + if request_fingerprint in export_business_plan.last_requests: + raise HTTPException( + status_code=429, + detail="Duplicate export request detected. Please wait a few seconds before trying again." + ) + else: + export_business_plan.last_requests = {} + + # Record this request + export_business_plan.last_requests[request_fingerprint] = current_time + # Validate request format @@ -1497,13 +2314,10 @@ async def export_business_plan(request: ExportRequest): if len(request.sections) == 0: raise HTTPException(status_code=400, detail="At least one section is required for export.") - # Check section content and shape + # Check section content for i, section in enumerate(request.sections): - title = getattr(section, 'title', None) - if not title or not str(title).strip(): - raise HTTPException(status_code=400, detail=f"Section at index {i} is missing a title.") if not section.content or not section.content.strip(): - raise HTTPException(status_code=400, detail=f"Section '{title}' has no content.") + raise HTTPException(status_code=400, detail=f"Section '{section.title}' has no content.") # logging.info(f"Exporting business plan for: {request.businessIdea} in {request.format.upper()} format") # logging.info(f"Request has {len(request.sections)} sections and summary length: {len(request.summary)}") @@ -1536,11 +2350,6 @@ async def export_business_plan(request: ExportRequest): doc_bytes, filename = create_pdf_document(request) else: # word doc_bytes, filename = create_word_document(request) - # Basic sanity check on output - if not doc_bytes or (isinstance(doc_bytes, (bytes, bytearray)) and len(doc_bytes) < 1000): - raise HTTPException(status_code=500, detail=f"Generated {request.format.upper()} appears empty or invalid.") - except HTTPException: - raise except ImportError as import_error: raise HTTPException( status_code=500, @@ -1595,78 +2404,6 @@ async def download_file(filename: str): raise HTTPException(status_code=404, detail="File not found") return FileResponse(file_path, filename=filename) - -# ────────────────────────────────────────────────────────────────────────────── -# Live PDF Generation Endpoints -# ────────────────────────────────────────────────────────────────────────────── - -@app.post("/live-pdf/start") -async def start_live_pdf_session(request: Request, data: ExportRequest): - """Start a live PDF session that streams updates as content changes""" - try: - # Create a new live PDF session - session_id = await live_pdf_generator.create_live_session( - data.businessIdea or "default", - data - ) - - return { - "success": True, - "session_id": session_id, - "message": "Live PDF session started successfully" - } - - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to start live PDF session: {str(e)}") - -@app.post("/live-pdf/update/{session_id}") -async def update_live_pdf_session(session_id: str, data: ExportRequest): - """Update an existing live PDF session with new content""" - try: - success = await live_pdf_generator.update_session(session_id, data) - - if not success: - raise HTTPException(status_code=404, detail="Session not found") - - return { - "success": True, - "message": "PDF updated successfully" - } - - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to update PDF: {str(e)}") - -@app.get("/live-pdf/stream/{session_id}") -async def stream_live_pdf_updates(session_id: str): - """Stream live PDF updates using Server-Sent Events""" - async def event_generator(): - async for chunk in live_pdf_generator.stream_pdf_updates(session_id): - yield chunk - - return StreamingResponse( - event_generator(), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "*" - } - ) - -@app.delete("/live-pdf/close/{session_id}") -async def close_live_pdf_session(session_id: str): - """Close and cleanup a live PDF session""" - try: - live_pdf_generator.close_session(session_id) - - return { - "success": True, - "message": "Live PDF session closed successfully" - } - - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to close session: {str(e)}") @app.get("/") def root(): return {"status": "FastAPI is running 🚀"} @@ -1674,7 +2411,7 @@ def root(): # @app.get("/test-toc") # def test_toc(): # """Test endpoint to verify TOC generation""" -# from app.main import generate_table_of_contents_after_content, ExportSection +# from app.main import generate_table_of_contents, ExportSection # # Create test sections # test_sections = [ @@ -1696,7 +2433,7 @@ def root(): # ] # # Generate TOC -# toc_items = generate_table_of_contents_after_content(test_sections, {}, "Test Business") +# toc_items = generate_table_of_contents(test_sections, "Test Business") # return { # "test_sections": [{"title": s.title, "key": s.key, "content_length": len(s.content)} for s in test_sections], @@ -1738,4 +2475,1631 @@ def root(): # status["models"]["GPT"] = "available" if openai_api_key else "unavailable (missing API key)" # return status +def fix_base64_padding(base64_string: str) -> str: + """Fix common base64 padding issues""" + # Remove any whitespace or newlines + base64_string = base64_string.strip() + + # Add padding if needed + missing_padding = len(base64_string) % 4 + if missing_padding: + base64_string += '=' * (4 - missing_padding) + + return base64_string + +def robust_base64_decode(base64_string: str) -> Optional[bytes]: + """Robustly decode base64 string with error handling and padding correction""" + try: + # First try direct decoding + return base64.b64decode(base64_string) + except Exception as e1: + try: + # Try with padding correction + fixed_string = fix_base64_padding(base64_string) + return base64.b64decode(fixed_string) + except Exception as e2: + try: + # Try with URL-safe base64 + return base64.urlsafe_b64decode(fix_base64_padding(base64_string)) + except Exception as e3: + try: + # Try with standard base64 ignoring padding + return base64.b64decode(base64_string + '==', validate=False) + except Exception as e4: + return None + + + + + +def parse_embedded_chart_data(sections: List[ExportSection]) -> List[Dict[str, Any]]: + """Parse chart data embedded in section content using CHARTDATA markers""" + charts = [] + + for section in sections: + if section.content: + # Look for chart data markers - FIXED: Use the same markers that clean_markdown_text removes + chart_matches = re.findall(r'(.*?)', section.content, re.DOTALL) + + # Also check for alternative markers as fallback + alt_chart_matches = re.findall(r'(.*?)', section.content, re.DOTALL) + chart_matches.extend(alt_chart_matches) + + # logging.info(f"Section '{section.title}': Found {len(chart_matches)} chart data markers") + + for chart_data in chart_matches: + try: + # Parse the chart data array + chart_array = eval(chart_data.strip()) + # logging.info(f"Parsed chart array with {len(chart_array)} items") + # logging.info(f"Chart array type: {type(chart_array)}") + + # Each chart is an array with: [Section, ChartType, Title, Period, Data] + for chart_item in chart_array: + if len(chart_item) >= 5: + section_name = chart_item[0] + chart_type = chart_item[1] # DC=Doughnut, BG=Bar Graph, etc. + chart_title = chart_item[2] + period = chart_item[3] + data = chart_item[4] + + chart_info = { + "section": section_name, + "type": chart_type, + "title": chart_title, + "period": period, + "data": data, + "source_section": section.title + } + + charts.append(chart_info) + # logging.info(f"Added chart: {chart_type} - {chart_title} for section {section_name}") + else: + # logging.warning(f"Chart item has insufficient data: {len(chart_item)} items, expected 5") + pass + + except Exception as e: + # logging.error(f"Failed to parse chart data for section '{section.title}': {str(e)}") + continue + + # Additional debugging: Check for any chart-related content + if not chart_matches: + # Look for any chart-related markers or text + chart_indicators = re.findall(r'', section.content, re.DOTALL) + if chart_indicators: + # logging.info(f"Section '{section.title}': Found chart indicators: {chart_indicators}") + pass + + # Check for general chart text + if 'CHART' in section.content.upper() or 'chart' in section.content.lower(): + # logging.info(f"Section '{section.title}': Contains chart-related text") + pass + + # logging.info(f"Total charts parsed: {len(charts)}") + return charts + +def create_chart_from_data(chart_info: Dict[str, Any], business_idea: str) -> bytes: + """Create a matplotlib chart from parsed chart data""" + try: + chart_type = chart_info["type"] + chart_title = chart_info["title"] + data = chart_info["data"] + + # logging.info(f"Creating chart: {chart_type} - {chart_title} with {len(data)} data points") + + if chart_type == "DC": # Doughnut Chart + fig, ax = plt.subplots(figsize=(6, 4.5)) # Smaller size for better page fit + + # Extract labels and values from data + labels = [item[0] for item in data] + values = [item[1] for item in data] + + # Professional color scheme - navy purple and gray tones + colors = ['#4B0082', '#9370DB', '#DDA0DD', '#E6E6FA', '#F0F0F0'] # Navy purple to light gray + + # Create doughnut chart with professional colors + wedges, texts, autotexts = ax.pie(values, labels=labels, autopct='%1.1f%%', startangle=90, colors=colors) + ax.add_patch(plt.Circle((0, 0), 0.7, fc='white')) + + ax.set_title(f"{business_idea} - {chart_title}", fontsize=14, fontweight='bold', pad=15, color='#2F2F2F') # Dark gray title + + elif chart_type == "BG": # Bar Graph + fig, ax = plt.subplots(figsize=(6, 4.5)) # Smaller size for better page fit + + # Extract labels and values from data + labels = [item[0] for item in data] + values = [item[1] for item in data] + + # Professional color scheme - navy purple and gray tones + colors = ['#4B0082', '#9370DB', '#DDA0DD', '#E6E6FA', '#F0F0F0'] # Navy purple to light gray + + # Create bar chart with professional colors + bars = ax.bar(labels, values, color=colors[:len(labels)]) # Use only needed colors + ax.set_title(f"{business_idea} - {chart_title}", fontsize=14, fontweight='bold', pad=15, color='#2F2F2F') # Dark gray title + ax.set_ylabel('Value', fontsize=10, color='#2F2F2F') # Dark gray labels + ax.set_xlabel('Category', fontsize=10, color='#2F2F2F') # Dark gray labels + + # Style the axes + ax.spines['top'].set_visible(False) + ax.spines['right'].set_visible(False) + ax.spines['left'].set_color('#CCCCCC') + ax.spines['bottom'].set_color('#CCCCCC') + ax.tick_params(colors='#2F2F2F') + + # Add value labels on bars + for bar in bars: + height = bar.get_height() + ax.text(bar.get_x() + bar.get_width()/2., height, + f'{height:,.0f}', ha='center', va='bottom', fontsize=9, color='#2F2F2F') # Dark gray text + + plt.xticks(rotation=45) + + else: # Default to bar chart + fig, ax = plt.subplots(figsize=(6, 4.5)) # Smaller size for better page fit + labels = [item[0] for item in data] + values = [item[1] for item in data] + + # Professional color scheme - navy purple and gray tones + colors = ['#4B0082', '#9370DB', '#DDA0DD', '#E6E6FA', '#F0F0F0'] # Navy purple to light gray + + bars = ax.bar(labels, values, color=colors[:len(labels)]) # Use only needed colors + ax.set_title(f"{business_idea} - {chart_title}", fontsize=14, fontweight='bold', pad=15, color='#2F2F2F') # Dark gray title + + # Style the axes + ax.spines['top'].set_visible(False) + ax.spines['right'].set_visible(False) + ax.spines['left'].set_color('#CCCCCC') + ax.spines['bottom'].set_color('#CCCCCC') + ax.tick_params(colors='#2F2F2F') + + plt.xticks(rotation=45) + + plt.tight_layout() + + # Save to bytes + img_buffer = io.BytesIO() + plt.savefig(img_buffer, format='png', dpi=300, bbox_inches='tight') + img_buffer.seek(0) + chart_bytes = img_buffer.getvalue() + plt.close(fig) + + # logging.info(f"Successfully created chart: {chart_type} - {chart_title} ({len(chart_bytes)} bytes)") + return chart_bytes + + except Exception as e: + # logging.error(f"Failed to create chart {chart_type} - {chart_title}: {str(e)}") + return None + +# Add these helper functions after the existing helper functions, before the create_pdf_document function + +def generate_table_of_contents(sections: List[ExportSection], business_idea: str) -> List[Dict[str, Any]]: + """Generate a structured table of contents with page numbers following SECTION_ORDER""" + toc_items = [] + + # logging.info(f"Generating TOC for {len(sections)} sections") + # logging.info(f"Available section titles: {[s.title for s in sections]}") + + # Create a mapping from section titles to section objects for easy lookup + section_map = {section.title: section for section in sections if section.content and section.content.strip()} + # logging.info(f"Section map keys: {list(section_map.keys())}") + + # Add business plan sections in the predefined SECTION_ORDER + for i, expected_title in enumerate(SECTION_ORDER): + # Find the corresponding section - try multiple matching strategies + section = None + + # Strategy 1: Exact title match + section = section_map.get(expected_title) + + # Strategy 2: Case-insensitive match + if not section: + for section_title, section_obj in section_map.items(): + if section_title.lower() == expected_title.lower(): + section = section_obj + # logging.info(f"Found section '{expected_title}' via case-insensitive match: '{section_title}'") + break + + # Strategy 3: Partial match (e.g., "Company Profile" matches "Company Profile Section") + if not section: + for section_title, section_obj in section_map.items(): + if (expected_title.lower() in section_title.lower() or + section_title.lower() in expected_title.lower()): + section = section_obj + # logging.info(f"Found section '{expected_title}' via partial match: '{section_title}'") + break + + # Strategy 4: Key-based match (remove 'prompt_' prefix and match) + if not section: + for section_obj in sections: + if section_obj.key: + key_clean = section_obj.key.replace('prompt_', '') if section_obj.key.startswith('prompt_') else section_obj.key + if key_clean.lower() == expected_title.lower().replace(' ', '').replace('&', ''): + section = section_obj + # logging.info(f"Found section '{expected_title}' via key match: '{section_obj.key}'") + break + + if section: + # Each section starts on a new page, so page number is 3 + i (cover=1, TOC=2, sections start at 3) + page_number = 3 + i + toc_items.append({ + "title": expected_title, # Use the expected title for consistency + "level": 1, + "page": page_number, + "section_type": "section" + }) + # logging.info(f"Added section '{expected_title}' to TOC at page {page_number}") + + # Skip subheadings and chart titles - only include main section headings + else: + # logging.warning(f"Expected section '{expected_title}' not found in available sections") + pass + + # Add any remaining unmatched sections as fallback + matched_sections = {item["title"] for item in toc_items if item["level"] == 1} + unmatched_sections = [] + + for section in sections: + if section.content and section.content.strip(): + # Check if this section was already matched + section_matched = False + for expected_title in SECTION_ORDER: + if (expected_title.lower() in section.title.lower() or + section.title.lower() in expected_title.lower()): + section_matched = True + break + + if not section_matched: + unmatched_sections.append(section) + + # Add unmatched sections to TOC + for i, section in enumerate(unmatched_sections): + page_number = 3 + len(toc_items) + i # Continue from where we left off + toc_items.append({ + "title": section.title, + "level": 1, + "page": page_number, + "section_type": "section" + }) + # logging.info(f"Added unmatched section '{section.title}' to TOC at page {page_number}") + + # logging.info(f"Final TOC has {len(toc_items)} items (including {len(unmatched_sections)} unmatched sections)") + return toc_items + +def extract_business_name_from_content(sections: List[ExportSection]) -> str: + """Extract business name from markdown content""" + for section in sections: + if section.content: + lines = section.content.split('\n') + for i, line in enumerate(lines): + line = line.strip() + # Look for "Business Name:" pattern + if '**Business Name:**' in line or 'Business Name:' in line: + # Extract the business name from the line + if '**Business Name:**' in line: + business_name = line.split('**Business Name:**')[1].strip() + else: + business_name = line.split('Business Name:')[1].strip() + + # Keep markdown formatting - don't strip ** symbols + # business_name = re.sub(r'[*_`~]', '', business_name) + business_name = business_name.strip() + + if business_name: + return business_name + + # If not found on same line, check next line + if i + 1 < len(lines): + next_line = lines[i + 1].strip() + business_name = next_line # Keep ** symbols + business_name = business_name.strip() + if business_name: + return business_name + + return None + +def extract_subheadings(content: str) -> List[str]: + """Extract subheadings from markdown content""" + subheadings = [] + lines = content.split('\n') + + for line in lines: + line = line.strip() + # Look for various markdown header patterns + if (line.startswith('##') and not line.startswith('###')) or \ + line.startswith('**') and line.endswith('**') and len(line) > 4: + # Remove markdown formatting and clean up + if line.startswith('##'): + subheading = re.sub(r'^##+\s*', '', line) + else: + # Handle bold text that might be headings - keep ** symbols + subheading = line # Keep ** symbols instead of stripping them + + # subheading = re.sub(r'[*_`~]', '', subheading) # Commented out to preserve ** symbols + if subheading.strip() and len(subheading.strip()) > 3: # Avoid very short headings + subheadings.append(subheading.strip()) + + return subheadings + +def parse_content_into_groups(content: str) -> List[Dict[str, Any]]: + """Parse markdown content into subheading-content groups""" + if not content: + return [] + + lines = content.split('\n') + groups = [] + current_group = None + + for line in lines: + line = line.strip() + if not line: + if current_group: + current_group['content_lines'].append('') # Preserve empty lines + continue + + # Check if this is a heading (H1, H2, H3, H4, or **bold**) + is_heading = ( + line.startswith('# ') or # H1 + (line.startswith('## ') and not line.startswith('### ')) or # H2 + (line.startswith('### ') and not line.startswith('#### ')) or # H3 + (line.startswith('#### ') and not line.startswith('##### ')) or # H4 + # Bold headings - either standalone **text** or **text**: format + (line.startswith('**') and line.endswith('**') and len(line) > 4) or # Bold headings + (re.match(r'^\*\*.*?\*\*:', line) and len(line) > 4) or # Bold text before colon as heading + re.match(r'^\d+\.\d+\s+', line) # Numbered subheadings like "3.6 SWOT Analysis" + ) + + if is_heading: + # Save previous group if it exists + if current_group: + current_group['content'] = '\n'.join(current_group['content_lines']) + groups.append(current_group) + + # Clean markdown symbols BEFORE determining type and storing + clean_text = line + heading_type = 'text' + + if line.startswith('# '): + clean_text = line[2:].strip() # Remove "# " + heading_type = 'h1' + elif line.startswith('## '): + clean_text = line[3:].strip() # Remove "## " + heading_type = 'h2' + elif line.startswith('### '): + clean_text = line[4:].strip() # Remove "### " + heading_type = 'h3' + elif line.startswith('#### '): + clean_text = line[5:].strip() # Remove "#### " + heading_type = 'h4' + elif line.startswith('**') and line.endswith('**'): + clean_text = line.strip() # Keep ** markers + heading_type = 'bold' + elif re.match(r'^\*\*.*?\*\*:', line): + clean_text = line.strip() # Keep original line with ** and : + heading_type = 'bold' + elif re.match(r'^\d+\.\d+\s+', line): + clean_text = line.strip() # Keep numbered headings as-is + heading_type = 'numbered' + + # Start new group with cleaned text + current_group = { + 'subheading': line, # Keep original for reference + 'content_lines': [], + 'content': '', + 'subheading_clean': clean_text, # Pre-cleaned text + 'subheading_type': heading_type + } + else: + # Add to current group's content + if current_group: + current_group['content_lines'].append(line) + else: + # Content before any subheading - create a default group + if not groups or groups[-1].get('subheading') != '## Introduction': + current_group = { + 'subheading': '## Introduction', + 'content_lines': [line], + 'content': '', + 'subheading_clean': 'Introduction', + 'subheading_type': 'h2' + } + + # Don't forget the last group + if current_group: + current_group['content'] = '\n'.join(current_group['content_lines']) + groups.append(current_group) + + # Add metadata to each group + for i, group in enumerate(groups): + group['group_id'] = f"group_{i}" + group['content_length'] = len(group['content']) + group['word_count'] = len(group['content'].split()) if group['content'] else 0 + group['has_charts'] = 'CHART' in group['content'].upper() if group['content'] else False + group['line_count'] = len([line for line in group['content'].split('\n') if line.strip()]) + + return groups + +def extract_key_phrases(content: str) -> List[tuple]: + """Extract key phrases from content for grouping metadata""" + if not content: + return [] + + # Simple key phrase extraction + words = re.findall(r'\b\w+\b', content.lower()) + word_freq = {} + + for word in words: + if len(word) > 3: # Only words longer than 3 characters + word_freq[word] = word_freq.get(word, 0) + 1 + + # Return top 5 most frequent words + return sorted(word_freq.items(), key=lambda x: x[1], reverse=True)[:5] + +def get_content_groups_info(content: str) -> Dict[str, Any]: + """Get detailed information about content groups for analysis and debugging""" + if not content: + return {"groups": [], "summary": {"total_groups": 0, "total_content_length": 0}} + + groups = parse_content_into_groups(content) + + # Add key phrases to each group + for group in groups: + group['key_phrases'] = extract_key_phrases(group['content']) + + # Calculate summary statistics + total_content_length = sum(group['content_length'] for group in groups) + total_word_count = sum(group['word_count'] for group in groups) + groups_with_charts = sum(1 for group in groups if group['has_charts']) + + summary = { + "total_groups": len(groups), + "total_content_length": total_content_length, + "total_word_count": total_word_count, + "groups_with_charts": groups_with_charts, + "average_group_size": total_content_length / len(groups) if groups else 0, + "largest_group": max(groups, key=lambda g: g['content_length'])['subheading_clean'] if groups else None, + "smallest_group": min(groups, key=lambda g: g['content_length'])['subheading_clean'] if groups else None + } + + return { + "groups": groups, + "summary": summary + } + +def add_table_of_contents_page(pdf, toc_items: List[Dict[str, Any]], business_idea: str): + """Add a professional table of contents page following SECTION_ORDER""" + + # TOC items are already in the correct order from generate_table_of_contents + sorted_toc_items = toc_items + + # 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 + + # Page title - more compact + pdf.set_font("Arial", "B", 20) # Reduced from 24 to 20 + pdf.set_text_color(*primary_color) + pdf.set_xy(25, 20) # Reduced from 25 to 20 + pdf.cell(0, 15, "Table of Contents", ln=True) # Reduced from 18 to 15 + + # Calculate text width and make line autofit + text_width = pdf.get_string_width("Table of Contents") + line_width = min(text_width + 12, 160) # Add 12mm padding, max 160mm + + # Removed purple accent line under TOC title + + # TOC content - optimized for single page layout + y_position = 50 # Reduced from 60 to 50 + pages_used = 1 + + for item in sorted_toc_items: + # Only add new page if absolutely necessary (very close to bottom) + # Increased threshold significantly to force single-page TOC + if y_position > 290: # Very close to bottom margin + pdf.add_page() + y_position = 25 + pages_used += 1 + + # Indent based on level - better indentation + indent = 0 if item["level"] == 1 else 20 # Increased indent for subsections + + # Font and color based on level + if item["level"] == 1: + pdf.set_font("Arial", "B", 12) + pdf.set_text_color(*navy_color) # Navy purple for main entries + else: + pdf.set_font("Arial", "", 10) + pdf.set_text_color(*text_color) + + # Title + pdf.set_xy(25 + indent, y_position) + pdf.cell(120, 8, item["title"], ln=False) + + # Dots leading to page number + pdf.set_font("Arial", "", 8) + pdf.set_text_color(128, 128, 128) # Medium gray for dots + + # Calculate dots position + title_width = pdf.get_string_width(item["title"]) + dots_start = 25 + indent + title_width + 5 + dots_end = 160 + + # Draw dots + for x in range(int(dots_start), int(dots_end), 3): + pdf.set_xy(x, y_position + 3) + pdf.cell(1, 1, ".", ln=False) + + # Page number + pdf.set_xy(160, y_position) + pdf.cell(0, 8, str(item["page"]), ln=False) + + # Reduced spacing between items for better page utilization + y_position += 8 # Reduced from 10 to 8 for even tighter spacing + + # Add some spacing at the end + pdf.ln(20) + +def filter_empty_sections(sections: List[ExportSection]) -> List[ExportSection]: + """Filter out sections with no content to prevent empty pages""" + filtered_sections = [] + + for section in sections: + if section.content and section.content.strip(): + # Clean the content to check if it's actually meaningful + cleaned_content = clean_text_for_pdf(section.content) + if cleaned_content and len(cleaned_content.strip()) > 10: # At least 10 characters + filtered_sections.append(section) + # logging.info(f"Section '{section.title}' passed filtering with {len(cleaned_content)} characters") + else: + # logging.info(f"Section '{section.title}' filtered out - content too short: {len(cleaned_content) if cleaned_content else 0} chars") + pass + else: + # logging.info(f"Section '{section.title}' filtered out - no content") + pass + + # logging.info(f"Filtered sections: {len(filtered_sections)} out of {len(sections)} total") + return filtered_sections + + + +def remove_duplicate_headings(content: str) -> str: + """Remove duplicate headings from content""" + if not content: + return content + + lines = content.split('\n') + cleaned_lines = [] + seen_headings = set() + + for line in lines: + line = line.strip() + if not line: + cleaned_lines.append('') + continue + + # Check if this is any type of heading + if line.startswith('#'): + heading_text = re.sub(r'^#+\s*', '', line) + # heading_text = re.sub(r'[*_`~]', '', heading_text).strip() # Commented out to preserve ** symbols + heading_text = heading_text.strip() + + # Check for duplicates (case-insensitive) + if heading_text.lower() in seen_headings: + continue + + seen_headings.add(heading_text.lower()) + cleaned_lines.append(line) + else: + cleaned_lines.append(line) + + return '\n'.join(cleaned_lines) + + + +def render_markdown_to_pdf(pdf, content: str, primary_color, accent_color, text_color): + """Render markdown content to PDF with proper heading styling and page break logic""" + if not content: + return + + lines = content.split('\n') + current_y = pdf.get_y() + + for line in lines: + line = line.strip() + if not line: + # Check if we need a page break for empty line + if current_y + 10 > 280: # 280mm is roughly where we want to break + pdf.add_page() + current_y = 25 + else: + pdf.ln(3) # Consistent space for empty lines + current_y = pdf.get_y() + continue + + # Calculate estimated height needed for this line + estimated_height = 12 # Base height for text + + # Handle different heading levels + if line.startswith("# "): # H1 - Main heading (like "Company Profile") + estimated_height = 25 # Heading + line + spacing + if current_y + estimated_height > 280: + pdf.add_page() + current_y = 25 + + text = line[2:].strip() + pdf.set_font("Arial", "B", 20) # Bold, size 20 + pdf.set_text_color(*primary_color) # Black + pdf.set_xy(25, current_y) + pdf.multi_cell(160, 10, text) + + # Calculate text width and make line autofit + text_width = pdf.get_string_width(text) + line_width = min(text_width + 12, 160) # Add 12mm padding, max 160mm + + # Add subtle line under main heading - consistent with section titles + pdf.set_draw_color(221, 221, 221) # Lighter gray line (#DDDDDD) + pdf.set_line_width(0.3) # Thin line + pdf.line(25, current_y + 10, 25 + line_width, current_y + 10) + + current_y = pdf.get_y() + pdf.ln(8) # Consistent space after main heading + current_y = pdf.get_y() + + elif line.startswith("## ") or re.match(r'^\d+\.\d+\s+', line): # H2 - Subheading or numbered subheading + estimated_height = 20 # Subheading + line + spacing + if current_y + estimated_height > 280: + pdf.add_page() + current_y = 25 + + text = line[3:].strip() if line.startswith("## ") else line.strip() + pdf.set_font("Arial", "B", 16) # Bold, size 16 + pdf.set_text_color(*primary_color) # Black + pdf.set_xy(25, current_y) + pdf.multi_cell(160, 8, text) + + # 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 + + # Add subtle line under subheading - consistent styling + pdf.set_draw_color(221, 221, 221) # Lighter gray line (#DDDDDD) + pdf.set_line_width(0.3) # Thin line + pdf.line(25, current_y + 8, 25 + line_width, current_y + 8) + + current_y = pdf.get_y() + pdf.ln(6) # Consistent space after subheading + current_y = pdf.get_y() + + elif re.match(r'^\d+\.\d+\s+[A-Z]', line): # Numbered subheadings like "3.6 SWOT Analysis" + estimated_height = 20 # Subheading + line + spacing + if current_y + estimated_height > 280: + pdf.add_page() + current_y = 25 + + text = line.strip() + pdf.set_font("Arial", "B", 16) # Bold, size 16 + pdf.set_text_color(*primary_color) # Black + pdf.set_xy(25, current_y) + pdf.multi_cell(160, 8, text) + + + current_y = pdf.get_y() + pdf.ln(6) # Consistent space after subheading + current_y = pdf.get_y() + + elif line.startswith("### ") or line.startswith("#### "): # H3/H4 - Smaller subheadings + estimated_height = 18 # Subheading + spacing + if current_y + estimated_height > 280: + pdf.add_page() + current_y = 25 + + text = line[4:].strip() if line.startswith("### ") else line[5:].strip() + pdf.set_font("Arial", "B", 14) # Bold, size 14 + pdf.set_text_color(*primary_color) # Black + pdf.set_xy(25, current_y) + pdf.multi_cell(160, 8, text) + + current_y = pdf.get_y() + pdf.ln(4) # Consistent space after subheading + current_y = pdf.get_y() + + elif line.startswith("**") and line.endswith("**"): # Bold text that might be a heading + text = line.strip() # Keep ** markers + # Check if this looks like a main heading (no numbers, not too long) + if not re.match(r'^\d+\.', text) and len(text) < 50: + estimated_height = 22 # Heading + line + spacing + if current_y + estimated_height > 280: + pdf.add_page() + current_y = 25 + + pdf.set_font("Arial", "B", 18) # Bold, size 18 + pdf.set_text_color(*primary_color) # Black + pdf.set_xy(25, current_y) + pdf.multi_cell(160, 9, text) + + current_y = pdf.get_y() + pdf.ln(6) # Consistent space after heading + current_y = pdf.get_y() + else: + # Regular bold text + estimated_height = 12 + if current_y + estimated_height > 280: + pdf.add_page() + current_y = 25 + + pdf.set_font("Arial", "B", 12) # Bold, size 12 + pdf.set_text_color(*text_color) # Dark gray + pdf.set_xy(25, current_y) + pdf.multi_cell(160, 8, text) + current_y = pdf.get_y() + + else: # Normal paragraph text + # For long paragraphs, estimate height based on text length + estimated_height = max(12, len(line) // 80 * 12) # Rough estimate + if current_y + estimated_height > 280: + pdf.add_page() + current_y = 25 + + pdf.set_font("Arial", "", 12) # Regular font, size 12 + pdf.set_text_color(*text_color) # Dark gray + pdf.set_xy(25, current_y) + pdf.multi_cell(160, 8, line) + current_y = pdf.get_y() + +def calculate_group_height(pdf, group: Dict[str, Any]) -> int: + """Calculate the estimated height needed for a complete group""" + subheading_type = group['subheading_type'] + content_lines = group['content'].split('\n') if group['content'] else [] + + # Base height for subheading + if subheading_type == 'h1': + subheading_height = 25 # 20pt font + spacing + elif subheading_type == 'h2': + subheading_height = 20 # 16pt font + underline + spacing + elif subheading_type == 'h3': + subheading_height = 16 # 14pt font + spacing + elif subheading_type == 'h4': + subheading_height = 14 # 12pt font + spacing + elif subheading_type == 'bold': + subheading_height = 20 # 16pt font + spacing + elif subheading_type == 'numbered': + subheading_height = 20 # 16pt font + spacing + else: + subheading_height = 16 + + # Calculate content height + content_height = 0 + for line in content_lines: + if line.strip(): + # Estimate height based on line length (rough calculation) + line_height = max(12, len(line) // 80 * 12) # Rough estimate + content_height += line_height + else: + content_height += 3 # Empty line spacing + + # Add spacing between groups + group_spacing = 8 + + return subheading_height + content_height + group_spacing + +def should_break_page_for_group(pdf, group: Dict[str, Any], current_y: int, page_height: int = 280) -> bool: + """Determine if we should break to a new page for this group""" + estimated_height = calculate_group_height(pdf, group) + + # If the group is very large (more than half a page), always break + if estimated_height > page_height * 0.5: + return True + + # If the group won't fit on current page, break + if current_y + estimated_height > page_height: + return True + + # If we're close to the end of the page and the group is medium-sized, break + if current_y > page_height * 0.7 and estimated_height > 50: + return True + + return False + + + +def render_markdown_to_pdf_grouped(pdf, content: str, primary_color, accent_color, text_color): + """Render markdown content to PDF with subheading-content grouping and smart page breaks""" + if not content: + return + + print(f"DEBUG: render_markdown_to_pdf_grouped called with content length: {len(content)}") + print(f"DEBUG: FULL CONTENT:") + print("=" * 80) + print(content) + print("=" * 80) + + # Parse content into groups + groups = parse_content_into_groups(content) + + for group in groups: + subheading = group['subheading'] + group_content = group['content'] + subheading_type = group['subheading_type'] + + # Calculate if this group will fit on the current page + current_y = pdf.get_y() + page_height = 280 # Approximate usable page height + + # Use smart page break logic + if should_break_page_for_group(pdf, group, current_y, page_height): + pdf.add_page() + current_y = 25 + + # Render subheading based on type - using original styling + # Use the pre-cleaned text from the group + text = group['subheading_clean'] + + if subheading_type == 'h1': + pdf.set_font("Arial", "B", 20) # Bold, size 20 (original size) + pdf.set_text_color(*primary_color) # Black + pdf.set_xy(25, current_y) + pdf.multi_cell(160, 10, text) + + # Calculate text width and make line autofit + text_width = pdf.get_string_width(text) + line_width = min(text_width + 12, 160) # Add 12mm padding, max 160mm + + # Add subtle line under main heading - consistent with section titles + pdf.set_draw_color(221, 221, 221) # Lighter gray line (#DDDDDD) + pdf.set_line_width(0.3) # Thin line + pdf.line(25, current_y + 10, 25 + line_width, current_y + 10) + + current_y = pdf.get_y() + pdf.ln(8) # Consistent space after main heading + current_y = pdf.get_y() + + elif subheading_type == 'h2': + pdf.set_font("Arial", "B", 18) # Bold, size 18 (original size) + pdf.set_text_color(*primary_color) # Black + pdf.set_xy(25, current_y) + pdf.multi_cell(160, 9, text) + + # Add underline (original styling) + text_width = pdf.get_string_width(text) + line_width = min(text_width + 12, 160) + pdf.set_draw_color(221, 221, 221) # Light gray line + pdf.set_line_width(0.3) + pdf.line(25, current_y + 9, 25 + line_width, current_y + 9) + + current_y = pdf.get_y() + pdf.ln(6) # Space after subheading + current_y = pdf.get_y() + + elif subheading_type == 'h3': + pdf.set_font("Arial", "B", 14) # Bold, size 14 (original size) + pdf.set_text_color(*primary_color) # Black + pdf.set_xy(25, current_y) + pdf.multi_cell(160, 8, text) + + current_y = pdf.get_y() + pdf.ln(4) # Space after subheading + current_y = pdf.get_y() + + elif subheading_type == 'h4': + pdf.set_font("Arial", "B", 12) # Bold, size 12 (original size) + pdf.set_text_color(*primary_color) # Black + pdf.set_xy(25, current_y) + pdf.multi_cell(160, 7, text) + + current_y = pdf.get_y() + pdf.ln(3) # Space after subheading + current_y = pdf.get_y() + + elif subheading_type == 'bold': + line_height_heading = 9 # For size 18 heading + line_height_text = 8 # For size 12 text + + if not re.match(r'^\d+\.', text) and len(text) < 80: + # Main heading style + pdf.set_font("Arial", "B", 18) + pdf.set_text_color(*primary_color) + pdf.set_xy(25, current_y) + pdf.multi_cell(160, line_height_heading, text) + current_y = pdf.get_y() + pdf.ln(6) + current_y = pdf.get_y() + else: + # Regular bold text + pdf.set_font("Arial", "B", 12) + pdf.set_text_color(*text_color) + pdf.set_xy(25, current_y) + pdf.multi_cell(160, line_height_text, text) + current_y = pdf.get_y() + + elif subheading_type == 'numbered': + pdf.set_font("Arial", "B", 18) # Bold, size 18 (original size) + pdf.set_text_color(*primary_color) # Black + pdf.set_xy(25, current_y) + pdf.multi_cell(160, 9, text) + + # Add underline (original styling) + text_width = pdf.get_string_width(text) + line_width = min(text_width + 12, 160) + pdf.set_draw_color(221, 221, 221) # Light gray line + pdf.set_line_width(0.3) + pdf.line(25, current_y + 9, 25 + line_width, current_y + 9) + + current_y = pdf.get_y() + pdf.ln(6) # Space after subheading + current_y = pdf.get_y() + + # Render grouped content with original styling + if group_content: + content_lines = group_content.split('\n') + for line in content_lines: + if line.strip(): + # Render text normally (removed mixed text support) + pdf.set_font("Arial", "", 12) + pdf.set_text_color(*text_color) + pdf.set_xy(25, current_y) + pdf.multi_cell(160, 8, line) + current_y = pdf.get_y() + else: + # Empty line spacing (original) + pdf.ln(3) + current_y = pdf.get_y() + +# Canonical currency map for symbol resolution (matches TypeScript version) +canonical_currency_map = { + # Standard currency symbols + "$": "USD", + "£": "GBP", + "€": "EUR", + "¥": "JPY", + "₹": "INR", + "₽": "RUB", + "₩": "KRW", + "₪": "ILS", + "₺": "TRY", + "₴": "UAH", + "₼": "AZN", + "₸": "KZT", + "₾": "GEL", + "֏": "AMD", + "₲": "PYG", + "₡": "CRC", + "₣": "XPF", + "₦": "NGN", + "₵": "GHS", + "₨": "PKR", + "₱": "PHP", + "₫": "VND", + "₭": "LAK", + "៛": "KHR", + "৳": "BDT", + "؋": "AFN", + "﷼": "IRR", + + # Custom symbols + "‡": "CUSTOM_DAGGER", + "†": "CUSTOM_DAGGER_SINGLE", + + # Letter-based currencies + "R": "ZAR", # South African Rand + "C$": "CAD", # Canadian Dollar + "A$": "AUD", # Australian Dollar + "R$": "BRL", # Brazilian Real + "S/": "PEN", # Peruvian Sol + "Bs": "BOB", # Bolivian Boliviano + "Q": "GTQ", # Guatemalan Quetzal + "L": "HNL", # Honduran Lempira + "ƒ": "AWG", # Aruban Florin + "Vt": "VUV", # Vanuatu Vatu + "T$": "TOP", # Tongan Pa'anga + "T": "WST", # Samoan Tala + "лв": "BGN", # Bulgarian Lev + "Kč": "CZK", # Czech Koruna + "kr": "DKK", # Danish Krone + "Ft": "HUF", # Hungarian Forint + "zł": "PLN", # Polish Zloty + "lei": "RON", # Romanian Leu + "CHF": "CHF", # Swiss Franc + "KM": "BAM", # Bosnian Marka + "ден": "MKD", # North Macedonian Denar + "дин": "RSD", # Serbian Dinar + "so'm": "UZS", # Uzbekistani Som + "NT$": "TWD", # Taiwan Dollar + "HK$": "HKD", # Hong Kong Dollar + "S$": "SGD", # Singapore Dollar + "RM": "MYR", # Malaysian Ringgit + "฿": "THB", # Thai Baht + "Rp": "IDR", # Indonesian Rupiah + "K": "MMK", # Myanmar Kyat + "Rs": "LKR", # Sri Lankan Rupee + "Nu": "BTN", # Bhutanese Ngultrum + "MVR": "MVR", # Maldivian Rufiyaa + + # Middle Eastern currencies + "ع.د": "IQD", # Iraqi Dinar + "ل.ل": "LBP", # Lebanese Pound + "د.ا": "JOD", # Jordanian Dinar + "ر.س": "SAR", # Saudi Riyal + "د.إ": "AED", # UAE Dirham + "ر.ق": "QAR", # Qatari Riyal + "د.ك": "KWD", # Kuwaiti Dinar + "د.ب": "BHD", # Bahraini Dinar + "ر.ع.": "OMR", # Omani Rial + "ر.ي": "YER", # Yemeni Rial + "ج.م": "EGP", # Egyptian Pound + "ل.د": "LYD", # Libyan Dinar + "د.ت": "TND", # Tunisian Dinar + "د.ج": "DZD", # Algerian Dinar + "د.م.": "MAD", # Moroccan Dirham + "ج.س.": "SDG", # Sudanese Pound + "SSP": "SSP", # South Sudanese Pound + + # African currencies + "KSh": "KES", # Kenyan Shilling + "USh": "UGX", # Ugandan Shilling + "TSh": "TZS", # Tanzanian Shilling + "FRw": "RWF", # Rwandan Franc + "FBu": "BIF", # Burundian Franc + "MT": "MZN", # Mozambican Metical + "P": "BWP", # Botswanan Pula + "N$": "NAD", # Namibian Dollar + "Ar": "MGA", # Malagasy Ariary + "CF": "KMF", # Comorian Franc + "MK": "MWK", # Malawian Kwacha + "ZK": "ZMW", # Zambian Kwacha + "Kz": "AOA", # Angolan Kwanza + "FC": "CDF", # Congolese Franc + "FCFA": "XAF", # Central African Franc + "Db": "STD", # Sao Tomean Dobra + "D": "GMD", # Gambian Dalasi + "FG": "GNF", # Guinean Franc + "CFA": "XOF", # West African Franc + "Le": "SLL", # Sierra Leonean Leone + "L$": "LRD", # Liberian Dollar + "Br": "ETB", # Ethiopian Birr + "Nfk": "ERN", # Eritrean Nakfa + "Fdj": "DJF", # Djiboutian Franc + "S": "SOS", # Somali Shilling + + # Caribbean & Central American currencies + "J$": "JMD", # Jamaican Dollar + "BZ$": "BZD", # Belize Dollar + "TT$": "TTD", # Trinidad and Tobago Dollar + "Bds$": "BBD", # Barbadian Dollar + "EC$": "XCD", # East Caribbean Dollar + "GY$": "GYD", # Guyanese Dollar + "SRD": "SRD", # Surinamese Dollar + "RD$": "DOP", # Dominican Peso + + # Pacific Island currencies + "FJ$": "FJD", # Fijian Dollar + "PGK": "PGK", # Papua New Guinean Kina + "SI$": "SBD", # Solomon Islands Dollar + "WS$": "WST", # Samoan Tala + "VT": "VUV", # Vanuatu Vatu + + # European currencies + "ISK": "ISK", # Icelandic Krona + "Lt": "LTL", # Lithuanian Litas + "Ls": "LVL", # Latvian Lats + "EEK": "EEK", # Estonian Kroon + "NOK": "NOK", # Norwegian Krone + "SEK": "SEK", # Swedish Krona + "ALL": "ALL", # Albanian Lek + + # South American currencies + "Gs": "PYG", # Paraguayan Guarani + "Bs.S": "VES", # Venezuelan Bolívar Soberano + "Bs": "VEF", # Venezuelan Bolívar + "$U": "UYU", # Uruguayan Peso + "$a": "ARS", # Argentine Peso + "$b": "BOB", # Bolivian Boliviano + "CLP": "CLP", # Chilean Peso + "COP": "COP", # Colombian Peso + "MXN": "MXN", # Mexican Peso + + # Small Island Nations + "BMD": "BMD", # Bermudian Dollar + "BSD": "BSD", # Bahamian Dollar + "KYD": "KYD", # Cayman Islands Dollar + "TVD": "TVD", # Tuvaluan Dollar + "ZWL": "ZWL", # Zimbabwean Dollar + + # Additional African currencies + "LSL": "LSL", # Lesotho Loti + "SZL": "SZL", # Swazi Lilangeni + "CVE": "CVE", # Cape Verdean Escudo + "MRO": "MRO", # Mauritanian Ouguiya + "MRU": "MRU", # Mauritanian Ouguiya + "SCR": "SCR", # Seychellois Rupee + "SHP": "SHP", # Saint Helena Pound + + # Additional Asian currencies + "BND": "BND", # Brunei Dollar + "MOP": "MOP", # Macanese Pataca + "NPR": "NPR", # Nepalese Rupee + + # Additional Pacific currencies + "NZD": "NZD", # New Zealand Dollar + + # Alternative $ symbols for countries (context-dependent) + "$BMD": "BMD", # Bermudian Dollar + "$BSD": "BSD", # Bahamian Dollar + "$BZD": "BZD", # Belize Dollar + "$KYD": "KYD", # Cayman Islands Dollar + "$FJD": "FJD", # Fijian Dollar + "$GYD": "GYD", # Guyanese Dollar + "$JMD": "JMD", # Jamaican Dollar + "$LRD": "LRD", # Liberian Dollar + "$NAD": "NAD", # Namibian Dollar + "$SBD": "SBD", # Solomon Islands Dollar + "$SRD": "SRD", # Surinamese Dollar + "$TTD": "TTD", # Trinidad and Tobago Dollar + "$TVD": "TVD", # Tuvaluan Dollar + "$VUV": "VUV", # Vanuatu Vatu + "$WST": "WST", # Samoan Tala + "$XCD": "XCD", # East Caribbean Dollar + "$ZWL": "ZWL", # Zimbabwean Dollar + "$BWP": "BWP", # Botswanan Pula + "$LSL": "LSL", # Lesotho Loti + "$SZL": "SZL", # Swazi Lilangeni + "$ZAR": "ZAR", # South African Rand + "$HKD": "HKD", # Hong Kong Dollar + "$SGD": "SGD", # Singapore Dollar + "$TWD": "TWD", # Taiwan Dollar + "$CHF": "CHF", # Swiss Franc + "$DKK": "DKK", # Danish Krone + "$NOK": "NOK", # Norwegian Krone + "$SEK": "SEK", # Swedish Krona + "$ARS": "ARS", # Argentine Peso + "$BOB": "BOB", # Bolivian Boliviano + "$CLP": "CLP", # Chilean Peso + "$COP": "COP", # Colombian Peso + "$MXN": "MXN", # Mexican Peso + "$PEN": "PEN", # Peruvian Sol + "$UYU": "UYU", # Uruguayan Peso + "$VES": "VES", # Venezuelan Bolívar Soberano + + # Cryptocurrencies (for modern business plans) + "₿": "BTC", # Bitcoin + "Ξ": "ETH", # Ethereum + "Ł": "LTC", # Litecoin + + # Special Drawing Rights + "XDR": "XDR", # Special Drawing Rights + + # Precious Metals + "XAU": "XAU", # Gold (troy ounce) + "XAG": "XAG", # Silver (troy ounce) + "XPT": "XPT", # Platinum (troy ounce) + "XPD": "XPD", # Palladium (troy ounce) + + # Other + "XTS": "XTS", # Testing Currency Code + "XXX": "XXX", # No Currency + + # Missing currencies from canonical_currency_map.py + "ADP": "ADP", # Andorran Peseta + "AFA": "AFA", # Afghan Afghani (1927–2002) + "ALK": "ALK", # Albanian Lek (1946–1965) + "ANG": "ANG", # Netherlands Antillean Guilder + "AOK": "AOK", # Angolan Kwanza (1977–1991) + "AON": "AON", # Angolan New Kwanza (1990–2000) + "AOR": "AOR", # Angolan Readjusted Kwanza (1995–1999) + "ARA": "ARA", # Argentine Austral + "ARL": "ARL", # Argentine Peso Ley (1970–1983) + "ARM": "ARM", # Argentine Peso (1881–1970) + "ARP": "ARP", # Argentine Peso (1983–1985) + "ATS": "ATS", # Austrian Schilling + "AZM": "AZM", # Azerbaijani Manat (1993–2006) + "BAD": "BAD", # Bosnia-Herzegovina Dinar (1992–1994) + "BAN": "BAN", # Bosnia-Herzegovina New Dinar (1994–1997) + "BEC": "BEC", # Belgian Franc (convertible) + "BEF": "BEF", # Belgian Franc + "BEL": "BEL", # Belgian Franc (financial) + "BGL": "BGL", # Bulgarian Hard Lev + "BGM": "BGM", # Bulgarian Socialist Lev + "BGO": "BGO", # Bulgarian Lev (1879–1952) + "BOL": "BOL", # Bolivian Boliviano (1863–1963) + "BOP": "BOP", # Bolivian Peso + "BOV": "BOV", # Bolivian Mvdol + "BRB": "BRB", # Brazilian New Cruzeiro (1967–1986) + "BRC": "BRC", # Brazilian Cruzado (1986–1989) + "BRE": "BRE", # Brazilian Cruzeiro (1990–1993) + "BRN": "BRN", # Brazilian New Cruzado (1989–1990) + "BRR": "BRR", # Brazilian Cruzeiro (1993–1994) + "BRZ": "BRZ", # Brazilian Cruzeiro (1942–1967) + "BUK": "BUK", # Burmese Kyat + "BYB": "BYB", # Belarusian Ruble (1994–1999) + "BYN": "BYN", # Belarusian Ruble + "BYR": "BYR", # Belarusian Ruble (2000–2016) + "CA$": "CAD", # Canadian Dollar + "CHE": "CHE", # WIR Euro + "CHW": "CHW", # WIR Franc + "CLE": "CLE", # Chilean Escudo + "CLF": "CLF", # Chilean Unit of Account (UF) + "CNH": "CNH", # Chinese Yuan (offshore) + "CNX": "CNX", # Chinese People's Bank Dollar + "CN¥": "CNY", # Chinese Yuan + "COU": "COU", # Colombian Real Value Unit + "CSD": "CSD", # Serbian Dinar (2002–2006) + "CSK": "CSK", # Czechoslovak Hard Koruna + "CUC": "CUC", # Cuban Convertible Peso + "CUP": "CUP", # Cuban Peso + "CYP": "CYP", # Cypriot Pound + "DDM": "DDM", # East German Mark + "DEM": "DEM", # German Mark + "ECS": "ECS", # Ecuadorian Sucre + "ECV": "ECV", # Ecuadorian Unit of Constant Value + "ESA": "ESA", # Spanish Peseta (A account) + "ESB": "ESB", # Spanish Peseta (convertible account) + "ESP": "ESP", # Spanish Peseta + "FIM": "FIM", # Finnish Markka + "FKP": "FKP", # Falkland Islands Pound + "FRF": "FRF", # French Franc + "GEK": "GEK", # Georgian Kupon Larit + "GHC": "GHC", # Ghanaian Cedi (1979–2007) + "GIP": "GIP", # Gibraltar Pound + "GNS": "GNS", # Guinean Syli + "GQE": "GQE", # Equatorial Guinean Ekwele + "GRD": "GRD", # Greek Drachma + "GWE": "GWE", # Portuguese Guinea Escudo + "GWP": "GWP", # Guinea-Bissau Peso + "HRD": "HRD", # Croatian Dinar + "HRK": "HRK", # Croatian Kuna + "HTG": "HTG", # Haitian Gourde + "IEP": "IEP", # Irish Pound + "ILP": "ILP", # Israeli Pound + "ILR": "ILR", # Israeli Shekel (1980–1985) + "ISJ": "ISJ", # Icelandic Króna (1918–1981) + "ITL": "ITL", # Italian Lira + "KGS": "KGS", # Kyrgystani Som + "KPW": "KPW", # North Korean Won + "KRH": "KRH", # South Korean Hwan (1953–1962) + "KRO": "KRO", # South Korean Won (1945–1953) + "LTT": "LTT", # Lithuanian Talonas + "LUC": "LUC", # Luxembourgian Convertible Franc + "LUF": "LUF", # Luxembourgian Franc + "LUL": "LUL", # Luxembourg Financial Franc + "LVR": "LVR", # Latvian Ruble + "MAF": "MAF", # Moroccan Franc + "MCF": "MCF", # Monegasque Franc + "MDC": "MDC", # Moldovan Cupon + "MDL": "MDL", # Moldovan Leu + "MGF": "MGF", # Malagasy Franc + "MKN": "MKN", # Macedonian Denar (1992–1993) + "MLF": "MLF", # Malian Franc + "MNT": "MNT", # Mongolian Tugrik + "MTL": "MTL", # Maltese Lira + "MTP": "MTP", # Maltese Pound + "MUR": "MUR", # Mauritian Rupee + "MVP": "MVP", # Maldivian Rupee (1947–1981) + "MX$": "MXN", # Mexican Peso + "MXP": "MXP", # Mexican Silver Peso (1861–1992) + "MXV": "MXV", # Mexican Investment Unit + "MZE": "MZE", # Mozambican Escudo + "MZM": "MZM", # Mozambican Metical (1980–2006) + "NIC": "NIC", # Nicaraguan Córdoba (1988–1991) + "NIO": "NIO", # Nicaraguan Córdoba + "NLG": "NLG", # Dutch Guilder + "NZ$": "NZD", # New Zealand Dollar + "PAB": "PAB", # Panamanian Balboa + "PEI": "PEI", # Peruvian Inti + "PES": "PES", # Peruvian Sol (1863–1965) + "PLZ": "PLZ", # Polish Zloty (1950–1995) + "PTE": "PTE", # Portuguese Escudo + "RHD": "RHD", # Rhodesian Dollar + "ROL": "ROL", # Romanian Leu (1952–2006) + "RUR": "RUR", # Russian Ruble (1991–1998) + "SDD": "SDD", # Sudanese Dinar (1992–2007) + "SDP": "SDP", # Sudanese Pound (1957–1998) + "SIT": "SIT", # Slovenian Tolar + "SKK": "SKK", # Slovak Koruna + "SLE": "SLE", # Sierra Leonean Leone + "SRG": "SRG", # Surinamese Guilder + "STN": "STN", # São Tomé & Príncipe Dobra + "SUR": "SUR", # Soviet Rouble + "SVC": "SVC", # Salvadoran Colón + "SYP": "SYP", # Syrian Pound + "TJR": "TJR", # Tajikistani Ruble + "TJS": "TJS", # Tajikistani Somoni + "TMM": "TMM", # Turkmenistani Manat (1993–2009) + "TMT": "TMT", # Turkmenistani Manat + "TPE": "TPE", # Timorese Escudo + "TRL": "TRL", # Turkish Lira (1922–2005) + "UAK": "UAK", # Ukrainian Karbovanets + "UGS": "UGS", # Ugandan Shilling (1966–1987) + "USN": "USN", # US Dollar (Next day) + "USS": "USS", # US Dollar (Same day) + "UYI": "UYI", # Uruguayan Peso (Indexed Units) + "UYP": "UYP", # Uruguayan Peso (1975–1993) + "UYW": "UYW", # Uruguayan Nominal Wage Index Unit + "VEB": "VEB", # Venezuelan Bolívar (1871–2008) + "VED": "VED", # Bolívar Soberano + "VNN": "VNN", # Vietnamese Dong (1978–1985) + "XBA": "XBA", # European Composite Unit + "XBB": "XBB", # European Monetary Unit + "XBC": "XBC", # European Unit of Account (XBC) + "XBD": "XBD", # European Unit of Account (XBD) + "Cg.": "XCG", # Caribbean guilder + "XEU": "XEU", # European Currency Unit + "XFO": "XFO", # French Gold Franc + "XFU": "XFU", # French UIC-Franc + "F CFA": "XOF", # West African CFA Franc + "XRE": "XRE", # RINET Funds + "XSU": "XSU", # Sucre + "XUA": "XUA", # ADB Unit of Account + "¤": "XXX", # Unknown Currency + "YDD": "YDD", # Yemeni Dinar + "YUD": "YUD", # Yugoslavian Hard Dinar (1966–1990) + "YUM": "YUM", # Yugoslavian New Dinar (1994–2002) + "YUN": "YUN", # Yugoslavian Convertible Dinar (1990–1992) + "YUR": "YUR", # Yugoslavian Reformed Dinar (1992–1993) + "ZAL": "ZAL", # South African Rand (financial) + "ZMK": "ZMK", # Zambian Kwacha (1968–2012) + "ZRN": "ZRN", # Zairean New Zaire (1993–1998) + "ZRZ": "ZRZ", # Zairean Zaire (1971–1993) + "ZWD": "ZWD", # Zimbabwean Dollar (1980–2008) + "ZWG": "ZWG", # Zimbabwean Gold + "ZWR": "ZWR", # Zimbabwean Dollar (2008) + "US$": "USD", # US Dollar + "C$": "NIO", # Nicaraguan Córdoba + "TT$": "TTD", # Trinidad & Tobago Dollar + "BD$": "BBD", # Barbadian Dollar + "B$": "BND", # Brunei Dollar + "SI$": "SBD", # Solomon Islands Dollar + "G$": "GYD", # Guyanaese Dollar + "SR$": "SRD", # Surinamese Dollar + "CLP$": "CLP", # Chilean Peso + "Bs.": "VES", # Venezuelan Bolívar + "Rf": "MVR", # Maldivian Rufiyaa + "SSh": "SOS", # Somali Shilling + "GH₵": "GHS", # Ghanaian Cedi + "Br": "BYN", # Belarusian Ruble + "CFA F": "XOF", # West African CFA Franc + "CFAF": "XAF", # Central African CFA Franc + "JP¥": "JPY", # Japanese Yen + "元": "TWD", # New Taiwan Dollar + "TL": "TRY", # Turkish Lira + "лв.": "BGN", # Bulgarian Lev + "₮": "MNT", # Mongolian Tugrik + "﷼": "SAR", # Saudi Riyal + ".د.ب": "BHD", # Bahraini Dinar + "ل.س": "SYP", # Syrian Pound + "﷼‎": "IRR", # Iranian Rial + "ש״ח": "ILS", # Israeli New Shekel + "Fr": "CHF", # Swiss Franc + "CFP": "XPF", # CFP Franc + "E": "SZL", # Swazi Lilangeni + "Rf.": "MVR", # Maldivian Rufiyaa + "QR": "QAR", # Qatari Riyal + "SR": "SAR", # Saudi Riyal + "BD": "BHD", # Bahraini Dinar + "KD": "KWD", # Kuwaiti Dinar + "₯": "GRD", # Greek Drachma + "ЅМ": "TJS", # Tajikistani Somoni + "Br (BYN)": "BYN", # Belarusian Ruble + "B/.": "PAB", # Panamanian Balboa + "Gs.": "PYG", # Paraguayan Guarani + "Afl.": "AWG", # Aruban Florin + "NAƒ": "ANG", # Netherlands Antillean Guilder + "lei (MDL)": "MDL", # Moldovan Leu + "KM (BAM)": "BAM", # Bosnia-Herzegovina Convertible Mark + "ден (MKD)": "MKD", # Macedonian Denar + "kn": "HRK", # Croatian Kuna + "Z$": "ZWL", # Zimbabwean Dollar (2009–2024) + "RTGS$": "ZWL", # Zimbabwean Dollar (2009–2024) + "Skr": "SEK", # Swedish Krona + "Nkr": "NOK", # Norwegian Krone + "Dkr": "DKK", # Danish Krone + "Íkr": "ISK", # Icelandic Króna + "AU$": "AUD", # Australian Dollar + "SG$": "SGD", # Singapore Dollar + "BB$": "BBD", # Barbadian Dollar + "BS$": "BSD", # Bahamian Dollar + "KY$": "KYD", # Cayman Islands Dollar + "BM$": "BMD", # Bermudan Dollar + "JM$": "JMD", # Jamaican Dollar +} + +def convert_currency_symbols_to_iso(text: str) -> str: + """ + Convert currency symbols to ISO codes for PDF compatibility + Matches the TypeScript canonicalCurrencyMap logic + """ + import re # Import at function level to avoid scope issues + processed_text = text + + # Track all conversions for detailed logging + conversions_made = [] + total_conversions = 0 + + # Process each currency symbol in the canonical map + for symbol, iso_code in canonical_currency_map.items(): + if symbol in processed_text: + # Check if it's a single character + is_single_character = len(symbol) == 1 + is_unicode_currency = is_single_character and ord(symbol) > 127 # Non-ASCII + is_latin_single_letter = is_single_character and symbol.isalpha() and ord(symbol) < 128 + + # Handle multi-character symbols (like "Le", "KSh", etc.) + is_multi_char_symbol = len(symbol) > 1 + + # Skip plain Latin single letters (to avoid converting words like "A", "R", etc.) + # but DO convert single-character Unicode currency symbols like €, £, ¥, ؋, etc. + if not is_unicode_currency and is_latin_single_letter: + # Only convert if it's followed by a number (currency context) + # Pattern: R500, R 500, R500,000 etc. + pattern = rf'\b{re.escape(symbol)}\s*(\d{{1,3}}(?:,?\d{{3}})*(?:\.\d{{2}})?)\b' + matches = re.findall(pattern, processed_text) + + if matches: + old_text = processed_text + processed_text = re.sub(pattern, rf'{iso_code} \1', processed_text) + + if old_text != processed_text: + conversions_made.append(f"{symbol} → {iso_code} (context: {matches})") + total_conversions += len(matches) + continue + + # For all other symbols (Unicode currency symbols, multi-character symbols) + # Count occurrences before replacement + old_count = processed_text.count(symbol) + + # Replace all occurrences + old_text = processed_text + processed_text = processed_text.replace(symbol, iso_code) + + # Check if replacement actually happened + if old_text != processed_text: + conversions_made.append(f"{symbol} → {iso_code} ({old_count} instances)") + total_conversions += old_count + + return processed_text + +def clean_unicode_for_pdf(text: str) -> str: + # Replace problematic Unicode characters with ASCII equivalents + unicode_replacements = { + '\u2019': "'", # Right single quotation mark + '\u2018': "'", # Left single quotation mark + '\u201C': '"', # Left double quotation mark + '\u201D': '"', # Right double quotation mark + '\u2013': '-', # En dash + '\u2014': '--', # Em dash + '\u2022': '•', # Bullet + '\u2026': '...', # Horizontal ellipsis + '\u00A0': ' ', # Non-breaking space + '\u00B0': '°', # Degree sign + '\u00AE': '(R)', # Registered trademark + '\u2122': '(TM)', # Trademark + '\u00A9': '(C)', # Copyright + } + + for unicode_char, replacement in unicode_replacements.items(): + text = text.replace(unicode_char, replacement) + + # Simple text cleaning - keep basic punctuation and common symbols + text = ''.join(char for char in text if ord(char) < 128 or char in '•°$€£¥₹₩₽₪₺₴₼₸₾֏₲₡₣₦₵₨₱₫₭៛৳؋﷼%') + + return text + +def render_markdown_to_docx(doc, content: str): + """Render markdown content to Word document with proper heading styling""" + if not content: + return + + lines = content.split('\n') + + for line in lines: + line = line.strip() + if not line: + doc.add_paragraph() # Empty paragraph for spacing + continue + + # Handle different heading levels + if line.startswith("# "): # H1 - Main heading (like "Company Profile") + text = line[2:].strip() + doc.add_heading(text, level=1) + elif line.startswith("## ") or re.match(r'^\d+\.\d+\s+', line): # H2 - Subheading or numbered subheading + text = line[3:].strip() if line.startswith("## ") else line.strip() + doc.add_heading(text, level=2) + elif re.match(r'^\d+\.\d+\s+[A-Z]', line): # Numbered subheadings like "3.6 SWOT Analysis" + text = line.strip() + doc.add_heading(text, level=2) + elif line.startswith("### ") or line.startswith("#### "): # H3/H4 - Smaller subheadings + text = line[4:].strip() if line.startswith("### ") else line[5:].strip() + doc.add_heading(text, level=3) + elif line.startswith("**") and line.endswith("**"): # Bold text that might be a heading + text = line.strip() # Keep ** markers + # Check if this looks like a main heading (no numbers, not too long) + if not re.match(r'^\d+\.', text) and len(text) < 50: + doc.add_heading(text, level=1) # Treat as main heading + else: + # Regular bold text + doc.add_paragraph(text) + else: # Normal paragraph text + doc.add_paragraph(line) + +def render_markdown_to_docx_grouped(doc, content: str): + """Render markdown content to Word document with subheading-content grouping and smart page breaks""" + if not content: + return + + # Parse content into groups + groups = parse_content_into_groups(content) + + for i, group in enumerate(groups): + subheading = group['subheading'] + group_content = group['content'] + subheading_type = group['subheading_type'] + + # Add page break before group if it's not the first group and the previous group was large + if i > 0: + # Check if we should add a page break to keep groups together + # This is a simple heuristic - in Word, we rely more on the natural flow + # but we can add manual page breaks for very large groups + if group['content_length'] > 1000: # Large group threshold + doc.add_page_break() + + # Render subheading based on type - using original styling + # Use the pre-cleaned text from the group + text = group['subheading_clean'] + + if subheading_type == 'h1': + doc.add_heading(text, level=1) + elif subheading_type == 'h2': + doc.add_heading(text, level=2) + elif subheading_type == 'h3': + doc.add_heading(text, level=3) + elif subheading_type == 'h4': + doc.add_heading(text, level=4) + elif subheading_type == 'bold': + if not re.match(r'^\d+\.', text) and len(text) < 80: + paragraph = doc.add_paragraph(text) + paragraph.style = doc.styles['Heading 1'] # Apply heading style + else: + doc.add_paragraph(text) # Use simple paragraph + elif subheading_type == 'numbered': + doc.add_heading(text, level=2) + + # Render grouped content with original styling + if group_content: + content_lines = group_content.split('\n') + for line in content_lines: + if line.strip(): + # Simple text rendering (removed bold processing) + doc.add_paragraph(line) + else: + doc.add_paragraph() # Empty paragraph for spacing (original logic) + +# Removed process_bold_text_in_content function - now handled inline with proper bold rendering + + + +def clean_text_for_docx(text: str) -> str: + # Replace problematic Unicode characters with ASCII equivalents + unicode_replacements = { + '\u2019': "'", # Right single quotation mark + '\u2018': "'", # Left single quotation mark + '\u201C': '"', # Left double quotation mark + '\u201D': '"', # Right double quotation mark + '\u2013': '-', # En dash + '\u2014': '--', # Em dash + '\u2022': '•', # Bullet + '\u2026': '...', # Horizontal ellipsis + '\u00A0': ' ', # Non-breaking space + '\u00B0': '°', # Degree sign + '\u00AE': '(R)', # Registered trademark + '\u2122': '(TM)', # Trademark + '\u00A9': '(C)', # Copyright + } + + for unicode_char, replacement in unicode_replacements.items(): + text = text.replace(unicode_char, replacement) + + # Simple text cleaning - keep basic punctuation and common symbols + text = ''.join(char for char in text if ord(char) < 128 or char in '•°$€£¥₹₩₽₪₺₴₼₸₾֏₲₡₣₦₵₨₱₫₭៛৳؋﷼%') + + return text