Spaces:
Runtime error
Runtime error
Merge branch 'main' of https://huggingface.co/spaces/Zenaight/planify
Browse files- app/CurrencyMapping/__init__.py +0 -17
- app/CurrencyMapping/currency_mapping.py +0 -538
- app/DocumentGeneration/__init__.py +0 -56
- app/DocumentGeneration/chart_generator.py +0 -384
- app/DocumentGeneration/document_helpers.py +0 -159
- app/DocumentGeneration/grouped.py +0 -304
- app/DocumentGeneration/live_pdf_generator.py +0 -126
- app/DocumentGeneration/markdown_renderer.py +0 -759
- app/DocumentGeneration/mixed.py +0 -6
- app/DocumentGeneration/pdf_generator.py +0 -682
- app/DocumentGeneration/table_of_content.py +0 -205
- app/DocumentGeneration/word_generator.py +0 -279
- app/__init__.py +0 -29
- app/constants.py +0 -41
- app/main.py +0 -0
- app/models.py +0 -61
- app/utils.py +0 -42
- assets/logos/MBD logo.png +0 -0
- package-lock.json +0 -6
app/CurrencyMapping/__init__.py
DELETED
|
@@ -1,17 +0,0 @@
|
|
| 1 |
-
# Currency Mapping Package
|
| 2 |
-
|
| 3 |
-
import os
|
| 4 |
-
import sys
|
| 5 |
-
|
| 6 |
-
# Add the current directory to sys.path to handle the space in folder name
|
| 7 |
-
current_dir = os.path.dirname(__file__)
|
| 8 |
-
if current_dir not in sys.path:
|
| 9 |
-
sys.path.insert(0, current_dir)
|
| 10 |
-
|
| 11 |
-
# Import the main function for easy access
|
| 12 |
-
from currency_mapping import convert_currency_symbols_to_iso, canonical_currency_map
|
| 13 |
-
|
| 14 |
-
__all__ = [
|
| 15 |
-
'convert_currency_symbols_to_iso',
|
| 16 |
-
'canonical_currency_map'
|
| 17 |
-
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/CurrencyMapping/currency_mapping.py
DELETED
|
@@ -1,538 +0,0 @@
|
|
| 1 |
-
# Currency Mapping Module
|
| 2 |
-
# Contains canonical_currency_map dictionary and convert_currency_symbols_to_iso() function
|
| 3 |
-
|
| 4 |
-
# Canonical currency map for symbol resolution (matches TypeScript version)
|
| 5 |
-
canonical_currency_map = {
|
| 6 |
-
# Standard currency symbols
|
| 7 |
-
"$": "USD",
|
| 8 |
-
"£": "GBP",
|
| 9 |
-
"€": "EUR",
|
| 10 |
-
"¥": "JPY",
|
| 11 |
-
"₹": "INR",
|
| 12 |
-
"₽": "RUB",
|
| 13 |
-
"₩": "KRW",
|
| 14 |
-
"₪": "ILS",
|
| 15 |
-
"₺": "TRY",
|
| 16 |
-
"₴": "UAH",
|
| 17 |
-
"₼": "AZN",
|
| 18 |
-
"₸": "KZT",
|
| 19 |
-
"₾": "GEL",
|
| 20 |
-
"֏": "AMD",
|
| 21 |
-
"₲": "PYG",
|
| 22 |
-
"₡": "CRC",
|
| 23 |
-
"₣": "XPF",
|
| 24 |
-
"₦": "NGN",
|
| 25 |
-
"₵": "GHS",
|
| 26 |
-
"₨": "PKR",
|
| 27 |
-
"₱": "PHP",
|
| 28 |
-
"₫": "VND",
|
| 29 |
-
"₭": "LAK",
|
| 30 |
-
"៛": "KHR",
|
| 31 |
-
"৳": "BDT",
|
| 32 |
-
"؋": "AFN",
|
| 33 |
-
"﷼": "IRR",
|
| 34 |
-
|
| 35 |
-
# Custom symbols
|
| 36 |
-
"‡": "CUSTOM_DAGGER",
|
| 37 |
-
"†": "CUSTOM_DAGGER_SINGLE",
|
| 38 |
-
|
| 39 |
-
# Letter-based currencies
|
| 40 |
-
"R": "ZAR", # South African Rand
|
| 41 |
-
"C$": "CAD", # Canadian Dollar
|
| 42 |
-
"A$": "AUD", # Australian Dollar
|
| 43 |
-
"R$": "BRL", # Brazilian Real
|
| 44 |
-
"S/": "PEN", # Peruvian Sol
|
| 45 |
-
"Bs": "BOB", # Bolivian Boliviano
|
| 46 |
-
"Q": "GTQ", # Guatemalan Quetzal
|
| 47 |
-
"L": "HNL", # Honduran Lempira
|
| 48 |
-
"ƒ": "AWG", # Aruban Florin
|
| 49 |
-
"Vt": "VUV", # Vanuatu Vatu
|
| 50 |
-
"T$": "TOP", # Tongan Pa'anga
|
| 51 |
-
"T": "WST", # Samoan Tala
|
| 52 |
-
"лв": "BGN", # Bulgarian Lev
|
| 53 |
-
"Kč": "CZK", # Czech Koruna
|
| 54 |
-
"kr": "DKK", # Danish Krone
|
| 55 |
-
"Ft": "HUF", # Hungarian Forint
|
| 56 |
-
"zł": "PLN", # Polish Zloty
|
| 57 |
-
"lei": "RON", # Romanian Leu
|
| 58 |
-
"CHF": "CHF", # Swiss Franc
|
| 59 |
-
"KM": "BAM", # Bosnian Marka
|
| 60 |
-
"ден": "MKD", # North Macedonian Denar
|
| 61 |
-
"дин": "RSD", # Serbian Dinar
|
| 62 |
-
"so'm": "UZS", # Uzbekistani Som
|
| 63 |
-
"NT$": "TWD", # Taiwan Dollar
|
| 64 |
-
"HK$": "HKD", # Hong Kong Dollar
|
| 65 |
-
"S$": "SGD", # Singapore Dollar
|
| 66 |
-
"RM": "MYR", # Malaysian Ringgit
|
| 67 |
-
"฿": "THB", # Thai Baht
|
| 68 |
-
"Rp": "IDR", # Indonesian Rupiah
|
| 69 |
-
"K": "MMK", # Myanmar Kyat
|
| 70 |
-
"Rs": "LKR", # Sri Lankan Rupee
|
| 71 |
-
"Nu": "BTN", # Bhutanese Ngultrum
|
| 72 |
-
"MVR": "MVR", # Maldivian Rufiyaa
|
| 73 |
-
|
| 74 |
-
# Middle Eastern currencies
|
| 75 |
-
"ع.د": "IQD", # Iraqi Dinar
|
| 76 |
-
"ل.ل": "LBP", # Lebanese Pound
|
| 77 |
-
"د.ا": "JOD", # Jordanian Dinar
|
| 78 |
-
"ر.س": "SAR", # Saudi Riyal
|
| 79 |
-
"د.إ": "AED", # UAE Dirham
|
| 80 |
-
"ر.ق": "QAR", # Qatari Riyal
|
| 81 |
-
"د.ك": "KWD", # Kuwaiti Dinar
|
| 82 |
-
"د.ب": "BHD", # Bahraini Dinar
|
| 83 |
-
"ر.ع.": "OMR", # Omani Rial
|
| 84 |
-
"ر.ي": "YER", # Yemeni Rial
|
| 85 |
-
"ج.م": "EGP", # Egyptian Pound
|
| 86 |
-
"ل.د": "LYD", # Libyan Dinar
|
| 87 |
-
"د.ت": "TND", # Tunisian Dinar
|
| 88 |
-
"د.ج": "DZD", # Algerian Dinar
|
| 89 |
-
"د.م.": "MAD", # Moroccan Dirham
|
| 90 |
-
"ج.س.": "SDG", # Sudanese Pound
|
| 91 |
-
"SSP": "SSP", # South Sudanese Pound
|
| 92 |
-
|
| 93 |
-
# African currencies
|
| 94 |
-
"KSh": "KES", # Kenyan Shilling
|
| 95 |
-
"USh": "UGX", # Ugandan Shilling
|
| 96 |
-
"TSh": "TZS", # Tanzanian Shilling
|
| 97 |
-
"FRw": "RWF", # Rwandan Franc
|
| 98 |
-
"FBu": "BIF", # Burundian Franc
|
| 99 |
-
"MT": "MZN", # Mozambican Metical
|
| 100 |
-
"P": "BWP", # Botswanan Pula
|
| 101 |
-
"N$": "NAD", # Namibian Dollar
|
| 102 |
-
"Ar": "MGA", # Malagasy Ariary
|
| 103 |
-
"CF": "KMF", # Comorian Franc
|
| 104 |
-
"MK": "MWK", # Malawian Kwacha
|
| 105 |
-
"ZK": "ZMW", # Zambian Kwacha
|
| 106 |
-
"Kz": "AOA", # Angolan Kwanza
|
| 107 |
-
"FC": "CDF", # Congolese Franc
|
| 108 |
-
"FCFA": "XAF", # Central African Franc
|
| 109 |
-
"Db": "STD", # Sao Tomean Dobra
|
| 110 |
-
"D": "GMD", # Gambian Dalasi
|
| 111 |
-
"FG": "GNF", # Guinean Franc
|
| 112 |
-
"CFA": "XOF", # West African Franc
|
| 113 |
-
"Le": "SLL", # Sierra Leonean Leone
|
| 114 |
-
"L$": "LRD", # Liberian Dollar
|
| 115 |
-
"Br": "ETB", # Ethiopian Birr
|
| 116 |
-
"Nfk": "ERN", # Eritrean Nakfa
|
| 117 |
-
"Fdj": "DJF", # Djiboutian Franc
|
| 118 |
-
"S": "SOS", # Somali Shilling
|
| 119 |
-
|
| 120 |
-
# Caribbean & Central American currencies
|
| 121 |
-
"J$": "JMD", # Jamaican Dollar
|
| 122 |
-
"BZ$": "BZD", # Belize Dollar
|
| 123 |
-
"TT$": "TTD", # Trinidad and Tobago Dollar
|
| 124 |
-
"Bds$": "BBD", # Barbadian Dollar
|
| 125 |
-
"EC$": "XCD", # East Caribbean Dollar
|
| 126 |
-
"GY$": "GYD", # Guyanese Dollar
|
| 127 |
-
"SRD": "SRD", # Surinamese Dollar
|
| 128 |
-
"RD$": "DOP", # Dominican Peso
|
| 129 |
-
|
| 130 |
-
# Pacific Island currencies
|
| 131 |
-
"FJ$": "FJD", # Fijian Dollar
|
| 132 |
-
"PGK": "PGK", # Papua New Guinean Kina
|
| 133 |
-
"SI$": "SBD", # Solomon Islands Dollar
|
| 134 |
-
"WS$": "WST", # Samoan Tala
|
| 135 |
-
"VT": "VUV", # Vanuatu Vatu
|
| 136 |
-
|
| 137 |
-
# European currencies
|
| 138 |
-
"ISK": "ISK", # Icelandic Krona
|
| 139 |
-
"Lt": "LTL", # Lithuanian Litas
|
| 140 |
-
"Ls": "LVL", # Latvian Lats
|
| 141 |
-
"EEK": "EEK", # Estonian Kroon
|
| 142 |
-
"NOK": "NOK", # Norwegian Krone
|
| 143 |
-
"SEK": "SEK", # Swedish Krona
|
| 144 |
-
"ALL": "ALL", # Albanian Lek
|
| 145 |
-
|
| 146 |
-
# South American currencies
|
| 147 |
-
"Gs": "PYG", # Paraguayan Guarani
|
| 148 |
-
"Bs.S": "VES", # Venezuelan Bolívar Soberano
|
| 149 |
-
"Bs": "VEF", # Venezuelan Bolívar
|
| 150 |
-
"$U": "UYU", # Uruguayan Peso
|
| 151 |
-
"$a": "ARS", # Argentine Peso
|
| 152 |
-
"$b": "BOB", # Bolivian Boliviano
|
| 153 |
-
"CLP": "CLP", # Chilean Peso
|
| 154 |
-
"COP": "COP", # Colombian Peso
|
| 155 |
-
"MXN": "MXN", # Mexican Peso
|
| 156 |
-
|
| 157 |
-
# Small Island Nations
|
| 158 |
-
"BMD": "BMD", # Bermudian Dollar
|
| 159 |
-
"BSD": "BSD", # Bahamian Dollar
|
| 160 |
-
"KYD": "KYD", # Cayman Islands Dollar
|
| 161 |
-
"TVD": "TVD", # Tuvaluan Dollar
|
| 162 |
-
"ZWL": "ZWL", # Zimbabwean Dollar
|
| 163 |
-
|
| 164 |
-
# Additional African currencies
|
| 165 |
-
"LSL": "LSL", # Lesotho Loti
|
| 166 |
-
"SZL": "SZL", # Swazi Lilangeni
|
| 167 |
-
"CVE": "CVE", # Cape Verdean Escudo
|
| 168 |
-
"MRO": "MRO", # Mauritanian Ouguiya
|
| 169 |
-
"MRU": "MRU", # Mauritanian Ouguiya
|
| 170 |
-
"SCR": "SCR", # Seychellois Rupee
|
| 171 |
-
"SHP": "SHP", # Saint Helena Pound
|
| 172 |
-
|
| 173 |
-
# Additional Asian currencies
|
| 174 |
-
"BND": "BND", # Brunei Dollar
|
| 175 |
-
"MOP": "MOP", # Macanese Pataca
|
| 176 |
-
"NPR": "NPR", # Nepalese Rupee
|
| 177 |
-
|
| 178 |
-
# Additional Pacific currencies
|
| 179 |
-
"NZD": "NZD", # New Zealand Dollar
|
| 180 |
-
|
| 181 |
-
# Alternative $ symbols for countries (context-dependent)
|
| 182 |
-
"$BMD": "BMD", # Bermudian Dollar
|
| 183 |
-
"$BSD": "BSD", # Bahamian Dollar
|
| 184 |
-
"$BZD": "BZD", # Belize Dollar
|
| 185 |
-
"$KYD": "KYD", # Cayman Islands Dollar
|
| 186 |
-
"$FJD": "FJD", # Fijian Dollar
|
| 187 |
-
"$GYD": "GYD", # Guyanese Dollar
|
| 188 |
-
"$JMD": "JMD", # Jamaican Dollar
|
| 189 |
-
"$LRD": "LRD", # Liberian Dollar
|
| 190 |
-
"$NAD": "NAD", # Namibian Dollar
|
| 191 |
-
"$SBD": "SBD", # Solomon Islands Dollar
|
| 192 |
-
"$SRD": "SRD", # Surinamese Dollar
|
| 193 |
-
"$TTD": "TTD", # Trinidad and Tobago Dollar
|
| 194 |
-
"$TVD": "TVD", # Tuvaluan Dollar
|
| 195 |
-
"$VUV": "VUV", # Vanuatu Vatu
|
| 196 |
-
"$WST": "WST", # Samoan Tala
|
| 197 |
-
"$XCD": "XCD", # East Caribbean Dollar
|
| 198 |
-
"$ZWL": "ZWL", # Zimbabwean Dollar
|
| 199 |
-
"$BWP": "BWP", # Botswanan Pula
|
| 200 |
-
"$LSL": "LSL", # Lesotho Loti
|
| 201 |
-
"$SZL": "SZL", # Swazi Lilangeni
|
| 202 |
-
"$ZAR": "ZAR", # South African Rand
|
| 203 |
-
"$HKD": "HKD", # Hong Kong Dollar
|
| 204 |
-
"$SGD": "SGD", # Singapore Dollar
|
| 205 |
-
"$TWD": "TWD", # Taiwan Dollar
|
| 206 |
-
"$CHF": "CHF", # Swiss Franc
|
| 207 |
-
"$DKK": "DKK", # Danish Krone
|
| 208 |
-
"$NOK": "NOK", # Norwegian Krone
|
| 209 |
-
"$SEK": "SEK", # Swedish Krona
|
| 210 |
-
"$ARS": "ARS", # Argentine Peso
|
| 211 |
-
"$BOB": "BOB", # Bolivian Boliviano
|
| 212 |
-
"$CLP": "CLP", # Chilean Peso
|
| 213 |
-
"$COP": "COP", # Colombian Peso
|
| 214 |
-
"$MXN": "MXN", # Mexican Peso
|
| 215 |
-
"$PEN": "PEN", # Peruvian Sol
|
| 216 |
-
"$UYU": "UYU", # Uruguayan Peso
|
| 217 |
-
"$VES": "VES", # Venezuelan Bolívar Soberano
|
| 218 |
-
|
| 219 |
-
# Cryptocurrencies (for modern business plans)
|
| 220 |
-
"₿": "BTC", # Bitcoin
|
| 221 |
-
"Ξ": "ETH", # Ethereum
|
| 222 |
-
"Ł": "LTC", # Litecoin
|
| 223 |
-
|
| 224 |
-
# Special Drawing Rights
|
| 225 |
-
"XDR": "XDR", # Special Drawing Rights
|
| 226 |
-
|
| 227 |
-
# Precious Metals
|
| 228 |
-
"XAU": "XAU", # Gold (troy ounce)
|
| 229 |
-
"XAG": "XAG", # Silver (troy ounce)
|
| 230 |
-
"XPT": "XPT", # Platinum (troy ounce)
|
| 231 |
-
"XPD": "XPD", # Palladium (troy ounce)
|
| 232 |
-
|
| 233 |
-
# Other
|
| 234 |
-
"XTS": "XTS", # Testing Currency Code
|
| 235 |
-
"XXX": "XXX", # No Currency
|
| 236 |
-
|
| 237 |
-
# Missing currencies from canonical_currency_map.py
|
| 238 |
-
"ADP": "ADP", # Andorran Peseta
|
| 239 |
-
"AFA": "AFA", # Afghan Afghani (1927–2002)
|
| 240 |
-
"ALK": "ALK", # Albanian Lek (1946–1965)
|
| 241 |
-
"ANG": "ANG", # Netherlands Antillean Guilder
|
| 242 |
-
"AOK": "AOK", # Angolan Kwanza (1977–1991)
|
| 243 |
-
"AON": "AON", # Angolan New Kwanza (1990–2000)
|
| 244 |
-
"AOR": "AOR", # Angolan Readjusted Kwanza (1995–1999)
|
| 245 |
-
"ARA": "ARA", # Argentine Austral
|
| 246 |
-
"ARL": "ARL", # Argentine Peso Ley (1970–1983)
|
| 247 |
-
"ARM": "ARM", # Argentine Peso (1881–1970)
|
| 248 |
-
"ARP": "ARP", # Argentine Peso (1983–1985)
|
| 249 |
-
"ATS": "ATS", # Austrian Schilling
|
| 250 |
-
"AZM": "AZM", # Azerbaijani Manat (1993–2006)
|
| 251 |
-
"BAD": "BAD", # Bosnia-Herzegovina Dinar (1992–1994)
|
| 252 |
-
"BAN": "BAN", # Bosnia-Herzegovina New Dinar (1994–1997)
|
| 253 |
-
"BEC": "BEC", # Belgian Franc (convertible)
|
| 254 |
-
"BEF": "BEF", # Belgian Franc
|
| 255 |
-
"BEL": "BEL", # Belgian Franc (financial)
|
| 256 |
-
"BGL": "BGL", # Bulgarian Hard Lev
|
| 257 |
-
"BGM": "BGM", # Bulgarian Socialist Lev
|
| 258 |
-
"BGO": "BGO", # Bulgarian Lev (1879–1952)
|
| 259 |
-
"BOL": "BOL", # Bolivian Boliviano (1863–1963)
|
| 260 |
-
"BOP": "BOP", # Bolivian Peso
|
| 261 |
-
"BOV": "BOV", # Bolivian Mvdol
|
| 262 |
-
"BRB": "BRB", # Brazilian New Cruzeiro (1967–1986)
|
| 263 |
-
"BRC": "BRC", # Brazilian Cruzado (1986–1989)
|
| 264 |
-
"BRE": "BRE", # Brazilian Cruzeiro (1990–1993)
|
| 265 |
-
"BRN": "BRN", # Brazilian New Cruzado (1989–1990)
|
| 266 |
-
"BRR": "BRR", # Brazilian Cruzeiro (1993–1994)
|
| 267 |
-
"BRZ": "BRZ", # Brazilian Cruzeiro (1942–1967)
|
| 268 |
-
"BUK": "BUK", # Burmese Kyat
|
| 269 |
-
"BYB": "BYB", # Belarusian Ruble (1994–1999)
|
| 270 |
-
"BYN": "BYN", # Belarusian Ruble
|
| 271 |
-
"BYR": "BYR", # Belarusian Ruble (2000–2016)
|
| 272 |
-
"CA$": "CAD", # Canadian Dollar
|
| 273 |
-
"CHE": "CHE", # WIR Euro
|
| 274 |
-
"CHW": "CHW", # WIR Franc
|
| 275 |
-
"CLE": "CLE", # Chilean Escudo
|
| 276 |
-
"CLF": "CLF", # Chilean Unit of Account (UF)
|
| 277 |
-
"CNH": "CNH", # Chinese Yuan (offshore)
|
| 278 |
-
"CNX": "CNX", # Chinese People's Bank Dollar
|
| 279 |
-
"CN¥": "CNY", # Chinese Yuan
|
| 280 |
-
"COU": "COU", # Colombian Real Value Unit
|
| 281 |
-
"CSD": "CSD", # Serbian Dinar (2002–2006)
|
| 282 |
-
"CSK": "CSK", # Czechoslovak Hard Koruna
|
| 283 |
-
"CUC": "CUC", # Cuban Convertible Peso
|
| 284 |
-
"CUP": "CUP", # Cuban Peso
|
| 285 |
-
"CYP": "CYP", # Cypriot Pound
|
| 286 |
-
"DDM": "DDM", # East German Mark
|
| 287 |
-
"DEM": "DEM", # German Mark
|
| 288 |
-
"ECS": "ECS", # Ecuadorian Sucre
|
| 289 |
-
"ECV": "ECV", # Ecuadorian Unit of Constant Value
|
| 290 |
-
"ESA": "ESA", # Spanish Peseta (A account)
|
| 291 |
-
"ESB": "ESB", # Spanish Peseta (convertible account)
|
| 292 |
-
"ESP": "ESP", # Spanish Peseta
|
| 293 |
-
"FIM": "FIM", # Finnish Markka
|
| 294 |
-
"FKP": "FKP", # Falkland Islands Pound
|
| 295 |
-
"FRF": "FRF", # French Franc
|
| 296 |
-
"GEK": "GEK", # Georgian Kupon Larit
|
| 297 |
-
"GHC": "GHC", # Ghanaian Cedi (1979–2007)
|
| 298 |
-
"GIP": "GIP", # Gibraltar Pound
|
| 299 |
-
"GNS": "GNS", # Guinean Syli
|
| 300 |
-
"GQE": "GQE", # Equatorial Guinean Ekwele
|
| 301 |
-
"GRD": "GRD", # Greek Drachma
|
| 302 |
-
"GWE": "GWE", # Portuguese Guinea Escudo
|
| 303 |
-
"GWP": "GWP", # Guinea-Bissau Peso
|
| 304 |
-
"HRD": "HRD", # Croatian Dinar
|
| 305 |
-
"HRK": "HRK", # Croatian Kuna
|
| 306 |
-
"HTG": "HTG", # Haitian Gourde
|
| 307 |
-
"IEP": "IEP", # Irish Pound
|
| 308 |
-
"ILP": "ILP", # Israeli Pound
|
| 309 |
-
"ILR": "ILR", # Israeli Shekel (1980–1985)
|
| 310 |
-
"ISJ": "ISJ", # Icelandic Króna (1918–1981)
|
| 311 |
-
"ITL": "ITL", # Italian Lira
|
| 312 |
-
"KGS": "KGS", # Kyrgystani Som
|
| 313 |
-
"KPW": "KPW", # North Korean Won
|
| 314 |
-
"KRH": "KRH", # South Korean Hwan (1953–1962)
|
| 315 |
-
"KRO": "KRO", # South Korean Won (1945–1953)
|
| 316 |
-
"LTT": "LTT", # Lithuanian Talonas
|
| 317 |
-
"LUC": "LUC", # Luxembourgian Convertible Franc
|
| 318 |
-
"LUF": "LUF", # Luxembourgian Franc
|
| 319 |
-
"LUL": "LUL", # Luxembourg Financial Franc
|
| 320 |
-
"LVR": "LVR", # Latvian Ruble
|
| 321 |
-
"MAF": "MAF", # Moroccan Franc
|
| 322 |
-
"MCF": "MCF", # Monegasque Franc
|
| 323 |
-
"MDC": "MDC", # Moldovan Cupon
|
| 324 |
-
"MDL": "MDL", # Moldovan Leu
|
| 325 |
-
"MGF": "MGF", # Malagasy Franc
|
| 326 |
-
"MKN": "MKN", # Macedonian Denar (1992–1993)
|
| 327 |
-
"MLF": "MLF", # Malian Franc
|
| 328 |
-
"MNT": "MNT", # Mongolian Tugrik
|
| 329 |
-
"MTL": "MTL", # Maltese Lira
|
| 330 |
-
"MTP": "MTP", # Maltese Pound
|
| 331 |
-
"MUR": "MUR", # Mauritian Rupee
|
| 332 |
-
"MVP": "MVP", # Maldivian Rupee (1947–1981)
|
| 333 |
-
"MX$": "MXN", # Mexican Peso
|
| 334 |
-
"MXP": "MXP", # Mexican Silver Peso (1861–1992)
|
| 335 |
-
"MXV": "MXV", # Mexican Investment Unit
|
| 336 |
-
"MZE": "MZE", # Mozambican Escudo
|
| 337 |
-
"MZM": "MZM", # Mozambican Metical (1980–2006)
|
| 338 |
-
"NIC": "NIC", # Nicaraguan Córdoba (1988–1991)
|
| 339 |
-
"NIO": "NIO", # Nicaraguan Córdoba
|
| 340 |
-
"NLG": "NLG", # Dutch Guilder
|
| 341 |
-
"NZ$": "NZD", # New Zealand Dollar
|
| 342 |
-
"PAB": "PAB", # Panamanian Balboa
|
| 343 |
-
"PEI": "PEI", # Peruvian Inti
|
| 344 |
-
"PES": "PES", # Peruvian Sol (1863–1965)
|
| 345 |
-
"PLZ": "PLZ", # Polish Zloty (1950–1995)
|
| 346 |
-
"PTE": "PTE", # Portuguese Escudo
|
| 347 |
-
"RHD": "RHD", # Rhodesian Dollar
|
| 348 |
-
"ROL": "ROL", # Romanian Leu (1952–2006)
|
| 349 |
-
"RUR": "RUR", # Russian Ruble (1991–1998)
|
| 350 |
-
"SDD": "SDD", # Sudanese Dinar (1992–2007)
|
| 351 |
-
"SDP": "SDP", # Sudanese Pound (1957–1998)
|
| 352 |
-
"SIT": "SIT", # Slovenian Tolar
|
| 353 |
-
"SKK": "SKK", # Slovak Koruna
|
| 354 |
-
"SLE": "SLE", # Sierra Leonean Leone
|
| 355 |
-
"SRG": "SRG", # Surinamese Guilder
|
| 356 |
-
"STN": "STN", # São Tomé & Príncipe Dobra
|
| 357 |
-
"SUR": "SUR", # Soviet Rouble
|
| 358 |
-
"SVC": "SVC", # Salvadoran Colón
|
| 359 |
-
"SYP": "SYP", # Syrian Pound
|
| 360 |
-
"TJR": "TJR", # Tajikistani Ruble
|
| 361 |
-
"TJS": "TJS", # Tajikistani Somoni
|
| 362 |
-
"TMM": "TMM", # Turkmenistani Manat (1993–2009)
|
| 363 |
-
"TMT": "TMT", # Turkmenistani Manat
|
| 364 |
-
"TPE": "TPE", # Timorese Escudo
|
| 365 |
-
"TRL": "TRL", # Turkish Lira (1922–2005)
|
| 366 |
-
"UAK": "UAK", # Ukrainian Karbovanets
|
| 367 |
-
"UGS": "UGS", # Ugandan Shilling (1966–1987)
|
| 368 |
-
"USN": "USN", # US Dollar (Next day)
|
| 369 |
-
"USS": "USS", # US Dollar (Same day)
|
| 370 |
-
"UYI": "UYI", # Uruguayan Peso (Indexed Units)
|
| 371 |
-
"UYP": "UYP", # Uruguayan Peso (1975–1993)
|
| 372 |
-
"UYW": "UYW", # Uruguayan Nominal Wage Index Unit
|
| 373 |
-
"VEB": "VEB", # Venezuelan Bolívar (1871–2008)
|
| 374 |
-
"VED": "VED", # Bolívar Soberano
|
| 375 |
-
"VNN": "VNN", # Vietnamese Dong (1978–1985)
|
| 376 |
-
"XBA": "XBA", # European Composite Unit
|
| 377 |
-
"XBB": "XBB", # European Monetary Unit
|
| 378 |
-
"XBC": "XBC", # European Unit of Account (XBC)
|
| 379 |
-
"XBD": "XBD", # European Unit of Account (XBD)
|
| 380 |
-
"Cg.": "XCG", # Caribbean guilder
|
| 381 |
-
"XEU": "XEU", # European Currency Unit
|
| 382 |
-
"XFO": "XFO", # French Gold Franc
|
| 383 |
-
"XFU": "XFU", # French UIC-Franc
|
| 384 |
-
"F CFA": "XOF", # West African CFA Franc
|
| 385 |
-
"XRE": "XRE", # RINET Funds
|
| 386 |
-
"XSU": "XSU", # Sucre
|
| 387 |
-
"XUA": "XUA", # ADB Unit of Account
|
| 388 |
-
"¤": "XXX", # Unknown Currency
|
| 389 |
-
"YDD": "YDD", # Yemeni Dinar
|
| 390 |
-
"YUD": "YUD", # Yugoslavian Hard Dinar (1966–1990)
|
| 391 |
-
"YUM": "YUM", # Yugoslavian New Dinar (1994–2002)
|
| 392 |
-
"YUN": "YUN", # Yugoslavian Convertible Dinar (1990–1992)
|
| 393 |
-
"YUR": "YUR", # Yugoslavian Reformed Dinar (1992–1993)
|
| 394 |
-
"ZAL": "ZAL", # South African Rand (financial)
|
| 395 |
-
"ZMK": "ZMK", # Zambian Kwacha (1968–2012)
|
| 396 |
-
"ZRN": "ZRN", # Zairean New Zaire (1993–1998)
|
| 397 |
-
"ZRZ": "ZRZ", # Zairean Zaire (1971–1993)
|
| 398 |
-
"ZWD": "ZWD", # Zimbabwean Dollar (1980–2008)
|
| 399 |
-
"ZWG": "ZWG", # Zimbabwean Gold
|
| 400 |
-
"ZWR": "ZWR", # Zimbabwean Dollar (2008)
|
| 401 |
-
"US$": "USD", # US Dollar
|
| 402 |
-
"C$": "NIO", # Nicaraguan Córdoba
|
| 403 |
-
"TT$": "TTD", # Trinidad & Tobago Dollar
|
| 404 |
-
"BD$": "BBD", # Barbadian Dollar
|
| 405 |
-
"B$": "BND", # Brunei Dollar
|
| 406 |
-
"SI$": "SBD", # Solomon Islands Dollar
|
| 407 |
-
"G$": "GYD", # Guyanaese Dollar
|
| 408 |
-
"SR$": "SRD", # Surinamese Dollar
|
| 409 |
-
"CLP$": "CLP", # Chilean Peso
|
| 410 |
-
"Bs.": "VES", # Venezuelan Bolívar
|
| 411 |
-
"Rf": "MVR", # Maldivian Rufiyaa
|
| 412 |
-
"SSh": "SOS", # Somali Shilling
|
| 413 |
-
"GH₵": "GHS", # Ghanaian Cedi
|
| 414 |
-
"Br": "BYN", # Belarusian Ruble
|
| 415 |
-
"CFA F": "XOF", # West African CFA Franc
|
| 416 |
-
"CFAF": "XAF", # Central African CFA Franc
|
| 417 |
-
"JP¥": "JPY", # Japanese Yen
|
| 418 |
-
"元": "TWD", # New Taiwan Dollar
|
| 419 |
-
"TL": "TRY", # Turkish Lira
|
| 420 |
-
"лв.": "BGN", # Bulgarian Lev
|
| 421 |
-
"₮": "MNT", # Mongolian Tugrik
|
| 422 |
-
"﷼": "SAR", # Saudi Riyal
|
| 423 |
-
".د.ب": "BHD", # Bahraini Dinar
|
| 424 |
-
"ل.س": "SYP", # Syrian Pound
|
| 425 |
-
"﷼": "IRR", # Iranian Rial
|
| 426 |
-
"ש״ח": "ILS", # Israeli New Shekel
|
| 427 |
-
"Fr": "CHF", # Swiss Franc
|
| 428 |
-
"CFP": "XPF", # CFP Franc
|
| 429 |
-
"E": "SZL", # Swazi Lilangeni
|
| 430 |
-
"Rf.": "MVR", # Maldivian Rufiyaa
|
| 431 |
-
"QR": "QAR", # Qatari Riyal
|
| 432 |
-
"SR": "SAR", # Saudi Riyal
|
| 433 |
-
"BD": "BHD", # Bahraini Dinar
|
| 434 |
-
"KD": "KWD", # Kuwaiti Dinar
|
| 435 |
-
"₯": "GRD", # Greek Drachma
|
| 436 |
-
"ЅМ": "TJS", # Tajikistani Somoni
|
| 437 |
-
"Br (BYN)": "BYN", # Belarusian Ruble
|
| 438 |
-
"B/.": "PAB", # Panamanian Balboa
|
| 439 |
-
"Gs.": "PYG", # Paraguayan Guarani
|
| 440 |
-
"Afl.": "AWG", # Aruban Florin
|
| 441 |
-
"NAƒ": "ANG", # Netherlands Antillean Guilder
|
| 442 |
-
"lei (MDL)": "MDL", # Moldovan Leu
|
| 443 |
-
"KM (BAM)": "BAM", # Bosnia-Herzegovina Convertible Mark
|
| 444 |
-
"ден (MKD)": "MKD", # Macedonian Denar
|
| 445 |
-
"kn": "HRK", # Croatian Kuna
|
| 446 |
-
"Z$": "ZWL", # Zimbabwean Dollar (2009–2024)
|
| 447 |
-
"RTGS$": "ZWL", # Zimbabwean Dollar (2009–2024)
|
| 448 |
-
"Skr": "SEK", # Swedish Krona
|
| 449 |
-
"Nkr": "NOK", # Norwegian Krone
|
| 450 |
-
"Dkr": "DKK", # Danish Krone
|
| 451 |
-
"Íkr": "ISK", # Icelandic Króna
|
| 452 |
-
"AU$": "AUD", # Australian Dollar
|
| 453 |
-
"SG$": "SGD", # Singapore Dollar
|
| 454 |
-
"BB$": "BBD", # Barbadian Dollar
|
| 455 |
-
"BS$": "BSD", # Bahamian Dollar
|
| 456 |
-
"KY$": "KYD", # Cayman Islands Dollar
|
| 457 |
-
"BM$": "BMD", # Bermudan Dollar
|
| 458 |
-
"JM$": "JMD", # Jamaican Dollar
|
| 459 |
-
}
|
| 460 |
-
|
| 461 |
-
def convert_currency_symbols_to_iso(text: str) -> str:
|
| 462 |
-
"""
|
| 463 |
-
Convert currency symbols to ISO codes for PDF compatibility
|
| 464 |
-
Matches the TypeScript canonicalCurrencyMap logic
|
| 465 |
-
"""
|
| 466 |
-
import re # Import at function level to avoid scope issues
|
| 467 |
-
processed_text = text
|
| 468 |
-
|
| 469 |
-
# Track all conversions for detailed logging
|
| 470 |
-
conversions_made = []
|
| 471 |
-
total_conversions = 0
|
| 472 |
-
|
| 473 |
-
# Process each currency symbol in the canonical map
|
| 474 |
-
for symbol, iso_code in canonical_currency_map.items():
|
| 475 |
-
if symbol in processed_text:
|
| 476 |
-
# Check if it's a single character
|
| 477 |
-
is_single_character = len(symbol) == 1
|
| 478 |
-
is_unicode_currency = is_single_character and ord(symbol) > 127 # Non-ASCII
|
| 479 |
-
is_latin_single_letter = is_single_character and symbol.isalpha() and ord(symbol) < 128
|
| 480 |
-
|
| 481 |
-
# Handle multi-character symbols (like "Le", "KSh", etc.)
|
| 482 |
-
is_multi_char_symbol = len(symbol) > 1
|
| 483 |
-
|
| 484 |
-
# Skip plain Latin single letters (to avoid converting words like "A", "R", etc.)
|
| 485 |
-
# but DO convert single-character Unicode currency symbols like €, £, ¥, ؋, etc.
|
| 486 |
-
if not is_unicode_currency and is_latin_single_letter:
|
| 487 |
-
# Only convert if it's followed by a number (currency context)
|
| 488 |
-
# Pattern: R500, R 500, R500,000 etc.
|
| 489 |
-
pattern = rf'\b{re.escape(symbol)}\s*(\d{{1,3}}(?:,?\d{{3}})*(?:\.\d{{2}})?)\b'
|
| 490 |
-
matches = re.findall(pattern, processed_text)
|
| 491 |
-
|
| 492 |
-
if matches:
|
| 493 |
-
old_text = processed_text
|
| 494 |
-
processed_text = re.sub(pattern, rf'{iso_code} \1', processed_text)
|
| 495 |
-
|
| 496 |
-
if old_text != processed_text:
|
| 497 |
-
conversions_made.append(f"{symbol} → {iso_code} (context: {matches})")
|
| 498 |
-
total_conversions += len(matches)
|
| 499 |
-
continue
|
| 500 |
-
|
| 501 |
-
# For multi-character symbols like "Ar", "Le", etc., be very careful
|
| 502 |
-
# Only convert if they appear as standalone words or in currency contexts
|
| 503 |
-
if is_multi_char_symbol and len(symbol) == 2 and symbol.isalpha():
|
| 504 |
-
# Only convert if it's a standalone word or followed by a number
|
| 505 |
-
# Pattern: \bAr\b or \bAr\s*\d or \bLe\b or \bLe\s*\d
|
| 506 |
-
standalone_pattern = rf'\b{re.escape(symbol)}\b'
|
| 507 |
-
currency_pattern = rf'\b{re.escape(symbol)}\s*(\d{{1,3}}(?:,?\d{{3}})*(?:\.\d{{2}})?)'
|
| 508 |
-
|
| 509 |
-
# Check for standalone occurrences first
|
| 510 |
-
standalone_matches = re.findall(standalone_pattern, processed_text)
|
| 511 |
-
currency_matches = re.findall(currency_pattern, processed_text)
|
| 512 |
-
|
| 513 |
-
if standalone_matches or currency_matches:
|
| 514 |
-
old_text = processed_text
|
| 515 |
-
# Replace standalone occurrences
|
| 516 |
-
processed_text = re.sub(standalone_pattern, iso_code, processed_text)
|
| 517 |
-
# Replace currency context occurrences
|
| 518 |
-
processed_text = re.sub(currency_pattern, rf'{iso_code} \1', processed_text)
|
| 519 |
-
|
| 520 |
-
if old_text != processed_text:
|
| 521 |
-
conversions_made.append(f"{symbol} → {iso_code} (standalone/currency context)")
|
| 522 |
-
total_conversions += len(standalone_matches) + len(currency_matches)
|
| 523 |
-
continue
|
| 524 |
-
|
| 525 |
-
# For all other symbols (Unicode currency symbols, multi-character symbols)
|
| 526 |
-
# Count occurrences before replacement
|
| 527 |
-
old_count = processed_text.count(symbol)
|
| 528 |
-
|
| 529 |
-
# Replace all occurrences
|
| 530 |
-
old_text = processed_text
|
| 531 |
-
processed_text = processed_text.replace(symbol, iso_code)
|
| 532 |
-
|
| 533 |
-
# Check if replacement actually happened
|
| 534 |
-
if old_text != processed_text:
|
| 535 |
-
conversions_made.append(f"{symbol} → {iso_code} ({old_count} instances)")
|
| 536 |
-
total_conversions += old_count
|
| 537 |
-
|
| 538 |
-
return processed_text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/DocumentGeneration/__init__.py
DELETED
|
@@ -1,56 +0,0 @@
|
|
| 1 |
-
# Document Generation Package
|
| 2 |
-
|
| 3 |
-
import os
|
| 4 |
-
import sys
|
| 5 |
-
|
| 6 |
-
# Add the current directory to sys.path to handle the space in folder name
|
| 7 |
-
current_dir = os.path.dirname(__file__)
|
| 8 |
-
if current_dir not in sys.path:
|
| 9 |
-
sys.path.insert(0, current_dir)
|
| 10 |
-
|
| 11 |
-
# Import all the main functions for easy access
|
| 12 |
-
from pdf_generator import create_pdf_document
|
| 13 |
-
from word_generator import create_word_document
|
| 14 |
-
from chart_generator import parse_embedded_chart_data, create_chart_from_data
|
| 15 |
-
from table_of_content import generate_table_of_contents_after_content, add_table_of_contents_page
|
| 16 |
-
from document_helpers import (
|
| 17 |
-
extract_business_name_from_content,
|
| 18 |
-
filter_empty_sections,
|
| 19 |
-
extract_subheadings,
|
| 20 |
-
remove_duplicate_headings
|
| 21 |
-
)
|
| 22 |
-
from grouped import (
|
| 23 |
-
parse_content_into_groups,
|
| 24 |
-
extract_key_phrases,
|
| 25 |
-
get_content_groups_info
|
| 26 |
-
)
|
| 27 |
-
from markdown_renderer import (
|
| 28 |
-
clean_text_for_pdf,
|
| 29 |
-
render_markdown_to_pdf,
|
| 30 |
-
render_markdown_to_pdf_grouped,
|
| 31 |
-
render_markdown_to_docx,
|
| 32 |
-
render_markdown_to_docx_grouped,
|
| 33 |
-
clean_text_for_docx
|
| 34 |
-
)
|
| 35 |
-
|
| 36 |
-
__all__ = [
|
| 37 |
-
'create_pdf_document',
|
| 38 |
-
'create_word_document',
|
| 39 |
-
'parse_embedded_chart_data',
|
| 40 |
-
'create_chart_from_data',
|
| 41 |
-
'generate_table_of_contents_after_content',
|
| 42 |
-
'add_table_of_contents_page',
|
| 43 |
-
'extract_business_name_from_content',
|
| 44 |
-
'filter_empty_sections',
|
| 45 |
-
'extract_subheadings',
|
| 46 |
-
'parse_content_into_groups',
|
| 47 |
-
'extract_key_phrases',
|
| 48 |
-
'get_content_groups_info',
|
| 49 |
-
'remove_duplicate_headings',
|
| 50 |
-
'clean_text_for_pdf',
|
| 51 |
-
'render_markdown_to_pdf',
|
| 52 |
-
'render_markdown_to_pdf_grouped',
|
| 53 |
-
'render_markdown_to_docx',
|
| 54 |
-
'render_markdown_to_docx_grouped',
|
| 55 |
-
'clean_text_for_docx'
|
| 56 |
-
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/DocumentGeneration/chart_generator.py
DELETED
|
@@ -1,384 +0,0 @@
|
|
| 1 |
-
# Chart Generator Module
|
| 2 |
-
# Contains parse_embedded_chart_data() and create_chart_from_data() functions
|
| 3 |
-
|
| 4 |
-
import re
|
| 5 |
-
import io
|
| 6 |
-
import os
|
| 7 |
-
import sys
|
| 8 |
-
import base64
|
| 9 |
-
import matplotlib.pyplot as plt
|
| 10 |
-
import matplotlib.patches as patches
|
| 11 |
-
from typing import List, Dict, Any, Optional
|
| 12 |
-
|
| 13 |
-
# Add the parent directory to sys.path to import from models.py
|
| 14 |
-
sys.path.append(os.path.dirname(os.path.dirname(__file__)))
|
| 15 |
-
from models import ExportSection
|
| 16 |
-
|
| 17 |
-
def parse_embedded_chart_data(sections: List[ExportSection]) -> List[Dict[str, Any]]:
|
| 18 |
-
"""Parse chart data embedded in section content using CHARTDATA markers"""
|
| 19 |
-
charts = []
|
| 20 |
-
|
| 21 |
-
for section in sections:
|
| 22 |
-
if section.content:
|
| 23 |
-
# Look for chart data markers - handle both formats
|
| 24 |
-
chart_matches = re.findall(r'<!--CHART_DATA_START-->(.*?)<!--CHART_DATA_END-->', section.content, re.DOTALL)
|
| 25 |
-
|
| 26 |
-
# Also check for alternative markers as fallback
|
| 27 |
-
alt_chart_matches = re.findall(r'<!--CHARTDATASTART-->(.*?)<!--CHARTDATAEND-->', section.content, re.DOTALL)
|
| 28 |
-
chart_matches.extend(alt_chart_matches)
|
| 29 |
-
|
| 30 |
-
# logging.info(f"Section '{section.title}': Found {len(chart_matches)} chart data markers")
|
| 31 |
-
|
| 32 |
-
for chart_data in chart_matches:
|
| 33 |
-
try:
|
| 34 |
-
# Parse the chart data array
|
| 35 |
-
chart_array = eval(chart_data.strip())
|
| 36 |
-
# logging.info(f"Parsed chart array with {len(chart_array)} items")
|
| 37 |
-
# logging.info(f"Chart array type: {type(chart_array)}")
|
| 38 |
-
|
| 39 |
-
# Each chart is an array with: [Section, ChartType, Title, Period, Data]
|
| 40 |
-
for chart_item in chart_array:
|
| 41 |
-
if len(chart_item) >= 5:
|
| 42 |
-
section_name = chart_item[0]
|
| 43 |
-
chart_type = chart_item[1] # DC=Doughnut, BG=Bar Graph, etc.
|
| 44 |
-
chart_title = chart_item[2]
|
| 45 |
-
period = chart_item[3]
|
| 46 |
-
data = chart_item[4]
|
| 47 |
-
|
| 48 |
-
chart_info = {
|
| 49 |
-
"section": section_name,
|
| 50 |
-
"type": chart_type,
|
| 51 |
-
"title": chart_title,
|
| 52 |
-
"period": period,
|
| 53 |
-
"data": data,
|
| 54 |
-
"source_section": section.title
|
| 55 |
-
}
|
| 56 |
-
|
| 57 |
-
charts.append(chart_info)
|
| 58 |
-
# logging.info(f"Added chart: {chart_type} - {chart_title} for section {section_name}")
|
| 59 |
-
else:
|
| 60 |
-
# logging.warning(f"Chart item has insufficient data: {len(chart_item)} items, expected 5")
|
| 61 |
-
pass
|
| 62 |
-
|
| 63 |
-
except Exception as e:
|
| 64 |
-
# logging.error(f"Failed to parse chart data for section '{section.title}': {str(e)}")
|
| 65 |
-
continue
|
| 66 |
-
|
| 67 |
-
# Additional debugging: Check for any chart-related content
|
| 68 |
-
if not chart_matches:
|
| 69 |
-
# Look for any chart-related markers or text
|
| 70 |
-
chart_indicators = re.findall(r'<!--.*?CHART.*?-->', section.content, re.DOTALL)
|
| 71 |
-
if chart_indicators:
|
| 72 |
-
# logging.info(f"Section '{section.title}': Found chart indicators: {chart_indicators}")
|
| 73 |
-
pass
|
| 74 |
-
|
| 75 |
-
# Check for general chart text
|
| 76 |
-
if 'CHART' in section.content.upper() or 'chart' in section.content.lower():
|
| 77 |
-
# logging.info(f"Section '{section.title}': Contains chart-related text")
|
| 78 |
-
pass
|
| 79 |
-
|
| 80 |
-
# logging.info(f"Total charts parsed: {len(charts)}")
|
| 81 |
-
return charts
|
| 82 |
-
|
| 83 |
-
def create_enhanced_chart_style(chart_type, data, title, business_idea):
|
| 84 |
-
"""Create charts with enhanced professional styling"""
|
| 85 |
-
# Set modern style
|
| 86 |
-
plt.style.use('default')
|
| 87 |
-
|
| 88 |
-
# Design palette from image
|
| 89 |
-
colors = ['#6A2EAB', '#5A2E9B', '#C0C0C0', '#FF8C00', '#FFD700', '#696969', '#FF4500']
|
| 90 |
-
|
| 91 |
-
if chart_type == "DC": # Doughnut Chart
|
| 92 |
-
fig, ax = plt.subplots(figsize=(14, 10))
|
| 93 |
-
|
| 94 |
-
# Extract labels and values from data
|
| 95 |
-
labels = [item[0] for item in data]
|
| 96 |
-
values = [item[1] for item in data]
|
| 97 |
-
|
| 98 |
-
# Create doughnut chart with enhanced styling
|
| 99 |
-
wedges, texts, autotexts = ax.pie(values, labels=labels, colors=colors[:len(values)],
|
| 100 |
-
autopct='%1.1f%%', startangle=90,
|
| 101 |
-
wedgeprops=dict(width=0.6, edgecolor='white', linewidth=2),
|
| 102 |
-
textprops={'fontsize': 18})
|
| 103 |
-
|
| 104 |
-
# Enhanced title styling
|
| 105 |
-
ax.set_title(title, fontsize=24, fontweight='bold', pad=20, color='#374151')
|
| 106 |
-
|
| 107 |
-
# Add legend with enhanced styling
|
| 108 |
-
legend = ax.legend(wedges, labels, title="Categories",
|
| 109 |
-
loc="center left", bbox_to_anchor=(1, 0, 0.5, 1),
|
| 110 |
-
fontsize=16, title_fontsize=18)
|
| 111 |
-
legend.get_title().set_color('#FF6B6B')
|
| 112 |
-
|
| 113 |
-
# Add business name watermark
|
| 114 |
-
fig.text(0.5, 0.02, f"Generated for: {business_idea}",
|
| 115 |
-
ha='center', va='bottom', fontsize=8, color='#9CA3AF', style='italic')
|
| 116 |
-
|
| 117 |
-
plt.tight_layout()
|
| 118 |
-
return fig
|
| 119 |
-
|
| 120 |
-
elif chart_type == "BG": # Bar Graph
|
| 121 |
-
fig, ax = plt.subplots(figsize=(16, 10))
|
| 122 |
-
|
| 123 |
-
# Extract labels and values
|
| 124 |
-
labels = [item[0] for item in data]
|
| 125 |
-
values = [item[1] for item in data]
|
| 126 |
-
|
| 127 |
-
# Create bar chart with enhanced styling
|
| 128 |
-
bars = ax.bar(labels, values, color=colors[:len(values)],
|
| 129 |
-
edgecolor='white', linewidth=1, alpha=0.8)
|
| 130 |
-
|
| 131 |
-
# Add value labels on bars
|
| 132 |
-
for bar, value in zip(bars, values):
|
| 133 |
-
height = bar.get_height()
|
| 134 |
-
ax.text(bar.get_x() + bar.get_width()/2., height + max(values)*0.01,
|
| 135 |
-
f'{value}', ha='center', va='bottom', fontweight='bold', fontsize=18)
|
| 136 |
-
|
| 137 |
-
# Enhanced styling
|
| 138 |
-
ax.set_title(title, fontsize=24, fontweight='bold', pad=20, color='#374151')
|
| 139 |
-
ax.set_xlabel('Categories', fontsize=18, color='#6B7280')
|
| 140 |
-
ax.set_ylabel('Values', fontsize=18, color='#6B7280')
|
| 141 |
-
|
| 142 |
-
# Rotate x-axis labels for better readability
|
| 143 |
-
plt.setp(ax.get_xticklabels(), rotation=45, ha='right', fontsize=16)
|
| 144 |
-
plt.setp(ax.get_yticklabels(), fontsize=16)
|
| 145 |
-
|
| 146 |
-
# Add grid for better readability
|
| 147 |
-
ax.yaxis.grid(True, alpha=0.3)
|
| 148 |
-
ax.set_axisbelow(True)
|
| 149 |
-
|
| 150 |
-
# Add business name watermark
|
| 151 |
-
fig.text(0.5, 0.02, f"Generated for: {business_idea}",
|
| 152 |
-
ha='center', va='bottom', fontsize=8, color='#9CA3AF', style='italic')
|
| 153 |
-
|
| 154 |
-
plt.tight_layout()
|
| 155 |
-
return fig
|
| 156 |
-
|
| 157 |
-
elif chart_type == "LG": # Line Graph
|
| 158 |
-
fig, ax = plt.subplots(figsize=(16, 10))
|
| 159 |
-
|
| 160 |
-
# Extract data
|
| 161 |
-
x_values = [item[0] for item in data]
|
| 162 |
-
y_values = [item[1] for item in data]
|
| 163 |
-
|
| 164 |
-
# Create line chart with enhanced styling
|
| 165 |
-
ax.plot(x_values, y_values, color='#FF6B6B', linewidth=3, marker='o',
|
| 166 |
-
markersize=8, markerfacecolor='white', markeredgecolor='#FF6B6B')
|
| 167 |
-
|
| 168 |
-
# Fill area under line
|
| 169 |
-
ax.fill_between(x_values, y_values, alpha=0.3, color='#4ECDC4')
|
| 170 |
-
|
| 171 |
-
# Enhanced styling
|
| 172 |
-
ax.set_title(title, fontsize=24, fontweight='bold', pad=20, color='#374151')
|
| 173 |
-
ax.set_xlabel('Time Period', fontsize=18, color='#6B7280')
|
| 174 |
-
ax.set_ylabel('Values', fontsize=18, color='#6B7280')
|
| 175 |
-
|
| 176 |
-
# Add grid
|
| 177 |
-
ax.grid(True, alpha=0.3)
|
| 178 |
-
ax.set_axisbelow(True)
|
| 179 |
-
|
| 180 |
-
# Increase tick label font sizes
|
| 181 |
-
plt.setp(ax.get_xticklabels(), fontsize=16)
|
| 182 |
-
plt.setp(ax.get_yticklabels(), fontsize=16)
|
| 183 |
-
|
| 184 |
-
# Add business name watermark
|
| 185 |
-
fig.text(0.5, 0.02, f"Generated for: {business_idea}",
|
| 186 |
-
ha='center', va='bottom', fontsize=8, color='#9CA3AF', style='italic')
|
| 187 |
-
|
| 188 |
-
plt.tight_layout()
|
| 189 |
-
return fig
|
| 190 |
-
|
| 191 |
-
elif chart_type == "PG": # Pie Graph
|
| 192 |
-
fig, ax = plt.subplots(figsize=(14, 10))
|
| 193 |
-
|
| 194 |
-
# Extract labels and values
|
| 195 |
-
labels = [item[0] for item in data]
|
| 196 |
-
values = [item[1] for item in data]
|
| 197 |
-
|
| 198 |
-
# Create pie chart with enhanced styling
|
| 199 |
-
wedges, texts, autotexts = ax.pie(values, labels=labels, colors=colors[:len(values)],
|
| 200 |
-
autopct='%1.1f%%', startangle=90,
|
| 201 |
-
wedgeprops=dict(edgecolor='white', linewidth=2),
|
| 202 |
-
textprops={'fontsize': 18})
|
| 203 |
-
|
| 204 |
-
# Enhanced title styling
|
| 205 |
-
ax.set_title(title, fontsize=24, fontweight='bold', pad=20, color='#374151')
|
| 206 |
-
|
| 207 |
-
# Add business name watermark
|
| 208 |
-
fig.text(0.5, 0.02, f"Generated for: {business_idea}",
|
| 209 |
-
ha='center', va='bottom', fontsize=8, color='#9CA3AF', style='italic')
|
| 210 |
-
|
| 211 |
-
plt.tight_layout()
|
| 212 |
-
return fig
|
| 213 |
-
|
| 214 |
-
return None
|
| 215 |
-
|
| 216 |
-
def create_chart_from_data(chart_info: Dict[str, Any], business_idea: str) -> bytes:
|
| 217 |
-
"""Create a matplotlib chart from parsed chart data with enhanced styling"""
|
| 218 |
-
try:
|
| 219 |
-
chart_type = chart_info["type"]
|
| 220 |
-
chart_title = chart_info["title"]
|
| 221 |
-
data = chart_info["data"]
|
| 222 |
-
|
| 223 |
-
# logging.info(f"Creating chart: {chart_type} - {chart_title} with {len(data)} data points")
|
| 224 |
-
|
| 225 |
-
# Use enhanced styling function
|
| 226 |
-
fig = create_enhanced_chart_style(chart_type, data, chart_title, business_idea)
|
| 227 |
-
|
| 228 |
-
if fig is None:
|
| 229 |
-
# Fallback to original method if enhanced styling fails
|
| 230 |
-
if chart_type == "DC": # Doughnut Chart
|
| 231 |
-
fig, ax = plt.subplots(figsize=(14, 10)) # Larger size for better visibility
|
| 232 |
-
|
| 233 |
-
# Extract labels and values from data
|
| 234 |
-
labels = [item[0] for item in data]
|
| 235 |
-
values = [item[1] for item in data]
|
| 236 |
-
|
| 237 |
-
# High contrast color scheme
|
| 238 |
-
colors = ['#6A2EAB', '#5A2E9B', '#C0C0C0', '#FF8C00', '#FFD700', '#696969', '#FF4500']
|
| 239 |
-
|
| 240 |
-
# Create doughnut chart
|
| 241 |
-
wedges, texts, autotexts = ax.pie(values, labels=labels, colors=colors[:len(values)],
|
| 242 |
-
autopct='%1.1f%%', startangle=90,
|
| 243 |
-
wedgeprops=dict(width=0.6, edgecolor='white', linewidth=2),
|
| 244 |
-
textprops={'fontsize': 18})
|
| 245 |
-
|
| 246 |
-
# Enhanced title
|
| 247 |
-
ax.set_title(chart_title, fontsize=22, fontweight='bold', pad=15, color='#374151')
|
| 248 |
-
|
| 249 |
-
# Add legend
|
| 250 |
-
ax.legend(wedges, labels, title="Categories", loc="center left", bbox_to_anchor=(1, 0, 0.5, 1), fontsize=16)
|
| 251 |
-
|
| 252 |
-
plt.tight_layout()
|
| 253 |
-
elif chart_type == "BG": # Bar Graph
|
| 254 |
-
fig, ax = plt.subplots(figsize=(16, 10))
|
| 255 |
-
|
| 256 |
-
# Extract labels and values
|
| 257 |
-
labels = [item[0] for item in data]
|
| 258 |
-
values = [item[1] for item in data]
|
| 259 |
-
|
| 260 |
-
# High contrast color scheme
|
| 261 |
-
colors = ['#6A2EAB', '#5A2E9B', '#C0C0C0', '#FF8C00', '#FFD700', '#696969', '#FF4500']
|
| 262 |
-
|
| 263 |
-
# Create bar chart
|
| 264 |
-
bars = ax.bar(labels, values, color=colors[:len(values)],
|
| 265 |
-
edgecolor='white', linewidth=1, alpha=0.8)
|
| 266 |
-
|
| 267 |
-
# Add value labels on bars
|
| 268 |
-
for bar, value in zip(bars, values):
|
| 269 |
-
height = bar.get_height()
|
| 270 |
-
ax.text(bar.get_x() + bar.get_width()/2., height + max(values)*0.01,
|
| 271 |
-
f'{value}', ha='center', va='bottom', fontweight='bold', fontsize=18)
|
| 272 |
-
|
| 273 |
-
ax.set_title(chart_title, fontsize=22, fontweight='bold', pad=15, color='#374151')
|
| 274 |
-
ax.set_xlabel('Categories', fontsize=18, color='#6B7280')
|
| 275 |
-
ax.set_ylabel('Values', fontsize=18, color='#6B7280')
|
| 276 |
-
|
| 277 |
-
# Rotate x-axis labels
|
| 278 |
-
plt.setp(ax.get_xticklabels(), rotation=45, ha='right', fontsize=16)
|
| 279 |
-
plt.setp(ax.get_yticklabels(), fontsize=16)
|
| 280 |
-
|
| 281 |
-
# Add grid
|
| 282 |
-
ax.yaxis.grid(True, alpha=0.3)
|
| 283 |
-
ax.set_axisbelow(True)
|
| 284 |
-
|
| 285 |
-
plt.tight_layout()
|
| 286 |
-
elif chart_type == "LG": # Line Graph
|
| 287 |
-
fig, ax = plt.subplots(figsize=(16, 10))
|
| 288 |
-
|
| 289 |
-
# Extract data
|
| 290 |
-
x_values = [item[0] for item in data]
|
| 291 |
-
y_values = [item[1] for item in data]
|
| 292 |
-
|
| 293 |
-
# Create line chart
|
| 294 |
-
ax.plot(x_values, y_values, color='#FF6B6B', linewidth=3, marker='o',
|
| 295 |
-
markersize=8, markerfacecolor='white', markeredgecolor='#FF6B6B')
|
| 296 |
-
|
| 297 |
-
# Fill area under line
|
| 298 |
-
ax.fill_between(x_values, y_values, alpha=0.3, color='#4ECDC4')
|
| 299 |
-
|
| 300 |
-
ax.set_title(chart_title, fontsize=22, fontweight='bold', pad=15, color='#374151')
|
| 301 |
-
ax.set_xlabel('Time Period', fontsize=18, color='#6B7280')
|
| 302 |
-
ax.set_ylabel('Values', fontsize=18, color='#6B7280')
|
| 303 |
-
|
| 304 |
-
# Add grid
|
| 305 |
-
ax.grid(True, alpha=0.3)
|
| 306 |
-
ax.set_axisbelow(True)
|
| 307 |
-
|
| 308 |
-
# Increase tick label font sizes
|
| 309 |
-
plt.setp(ax.get_xticklabels(), fontsize=16)
|
| 310 |
-
plt.setp(ax.get_yticklabels(), fontsize=16)
|
| 311 |
-
|
| 312 |
-
plt.tight_layout()
|
| 313 |
-
elif chart_type == "PG": # Pie Graph
|
| 314 |
-
fig, ax = plt.subplots(figsize=(14, 10))
|
| 315 |
-
|
| 316 |
-
# Extract labels and values
|
| 317 |
-
labels = [item[0] for item in data]
|
| 318 |
-
values = [item[1] for item in data]
|
| 319 |
-
|
| 320 |
-
# High contrast color scheme
|
| 321 |
-
colors = ['#6A2EAB', '#5A2E9B', '#C0C0C0', '#FF8C00', '#FFD700', '#696969', '#FF4500']
|
| 322 |
-
|
| 323 |
-
# Create pie chart
|
| 324 |
-
wedges, texts, autotexts = ax.pie(values, labels=labels, colors=colors[:len(values)],
|
| 325 |
-
autopct='%1.1f%%', startangle=90,
|
| 326 |
-
wedgeprops=dict(edgecolor='white', linewidth=2),
|
| 327 |
-
textprops={'fontsize': 18})
|
| 328 |
-
|
| 329 |
-
ax.set_title(chart_title, fontsize=22, fontweight='bold', pad=15, color='#374151')
|
| 330 |
-
|
| 331 |
-
plt.tight_layout()
|
| 332 |
-
else:
|
| 333 |
-
# logging.warning(f"Unknown chart type: {chart_type}")
|
| 334 |
-
return None
|
| 335 |
-
|
| 336 |
-
# Save to bytes
|
| 337 |
-
buffer = io.BytesIO()
|
| 338 |
-
fig.savefig(buffer, format='png', dpi=300, bbox_inches='tight',
|
| 339 |
-
facecolor='white', edgecolor='none')
|
| 340 |
-
buffer.seek(0)
|
| 341 |
-
chart_bytes = buffer.getvalue()
|
| 342 |
-
buffer.close()
|
| 343 |
-
|
| 344 |
-
# Close the figure to free memory
|
| 345 |
-
plt.close(fig)
|
| 346 |
-
|
| 347 |
-
return chart_bytes
|
| 348 |
-
|
| 349 |
-
except Exception as e:
|
| 350 |
-
# logging.error(f"Failed to create chart: {str(e)}")
|
| 351 |
-
return None
|
| 352 |
-
|
| 353 |
-
def fix_base64_padding(base64_string: str) -> str:
|
| 354 |
-
"""Fix common base64 padding issues"""
|
| 355 |
-
# Remove any whitespace or newlines
|
| 356 |
-
base64_string = base64_string.strip()
|
| 357 |
-
|
| 358 |
-
# Add padding if needed
|
| 359 |
-
missing_padding = len(base64_string) % 4
|
| 360 |
-
if missing_padding:
|
| 361 |
-
base64_string += '=' * (4 - missing_padding)
|
| 362 |
-
|
| 363 |
-
return base64_string
|
| 364 |
-
|
| 365 |
-
def robust_base64_decode(base64_string: str) -> Optional[bytes]:
|
| 366 |
-
"""Robustly decode base64 string with error handling and padding correction"""
|
| 367 |
-
try:
|
| 368 |
-
# First try direct decoding
|
| 369 |
-
return base64.b64decode(base64_string)
|
| 370 |
-
except Exception as e1:
|
| 371 |
-
try:
|
| 372 |
-
# Try with padding correction
|
| 373 |
-
fixed_string = fix_base64_padding(base64_string)
|
| 374 |
-
return base64.b64decode(fixed_string)
|
| 375 |
-
except Exception as e2:
|
| 376 |
-
try:
|
| 377 |
-
# Try with URL-safe base64
|
| 378 |
-
return base64.urlsafe_b64decode(fix_base64_padding(base64_string))
|
| 379 |
-
except Exception as e3:
|
| 380 |
-
try:
|
| 381 |
-
# Try with standard base64 ignoring padding
|
| 382 |
-
return base64.b64decode(base64_string + '==', validate=False)
|
| 383 |
-
except Exception as e4:
|
| 384 |
-
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/DocumentGeneration/document_helpers.py
DELETED
|
@@ -1,159 +0,0 @@
|
|
| 1 |
-
# Document Helper Functions
|
| 2 |
-
# Contains helper functions like filter_empty_sections(), extract_business_name_from_content(), etc.
|
| 3 |
-
|
| 4 |
-
import re
|
| 5 |
-
import os
|
| 6 |
-
import sys
|
| 7 |
-
from typing import List, Dict, Any
|
| 8 |
-
|
| 9 |
-
# Add the parent directory to sys.path to import from models.py
|
| 10 |
-
sys.path.append(os.path.dirname(os.path.dirname(__file__)))
|
| 11 |
-
from models import ExportSection
|
| 12 |
-
|
| 13 |
-
def extract_business_name_from_content(sections: List[ExportSection]) -> str:
|
| 14 |
-
"""Extract business name from markdown content"""
|
| 15 |
-
for section in sections:
|
| 16 |
-
if section.content:
|
| 17 |
-
lines = section.content.split('\n')
|
| 18 |
-
for i, line in enumerate(lines):
|
| 19 |
-
line = line.strip()
|
| 20 |
-
# Look for "Business Name:" pattern
|
| 21 |
-
if '**Business Name:**' in line or 'Business Name:' in line:
|
| 22 |
-
# Extract the business name from the line
|
| 23 |
-
if '**Business Name:**' in line:
|
| 24 |
-
business_name = line.split('**Business Name:**')[1].strip()
|
| 25 |
-
else:
|
| 26 |
-
business_name = line.split('Business Name:')[1].strip()
|
| 27 |
-
|
| 28 |
-
# Keep markdown formatting - don't strip ** symbols
|
| 29 |
-
# business_name = re.sub(r'[*_`~]', '', business_name)
|
| 30 |
-
business_name = business_name.strip()
|
| 31 |
-
|
| 32 |
-
if business_name:
|
| 33 |
-
return business_name
|
| 34 |
-
|
| 35 |
-
# If not found on same line, check next line
|
| 36 |
-
if i + 1 < len(lines):
|
| 37 |
-
next_line = lines[i + 1].strip()
|
| 38 |
-
business_name = next_line # Keep ** symbols
|
| 39 |
-
business_name = business_name.strip()
|
| 40 |
-
if business_name:
|
| 41 |
-
return business_name
|
| 42 |
-
|
| 43 |
-
return None
|
| 44 |
-
|
| 45 |
-
def extract_subheadings(content: str) -> List[str]:
|
| 46 |
-
"""Extract subheadings from markdown content"""
|
| 47 |
-
subheadings = []
|
| 48 |
-
lines = content.split('\n')
|
| 49 |
-
|
| 50 |
-
for line in lines:
|
| 51 |
-
line = line.strip()
|
| 52 |
-
# Look for various markdown header patterns
|
| 53 |
-
if (line.startswith('##') and not line.startswith('###')) or \
|
| 54 |
-
line.startswith('**') and line.endswith('**') and len(line) > 4:
|
| 55 |
-
# Remove markdown formatting and clean up
|
| 56 |
-
if line.startswith('##'):
|
| 57 |
-
subheading = re.sub(r'^##+\s*', '', line)
|
| 58 |
-
else:
|
| 59 |
-
# Handle bold text that might be headings - keep ** symbols
|
| 60 |
-
subheading = line # Keep ** symbols instead of stripping them
|
| 61 |
-
|
| 62 |
-
# subheading = re.sub(r'[*_`~]', '', subheading) # Commented out to preserve ** symbols
|
| 63 |
-
if subheading.strip() and len(subheading.strip()) > 3: # Avoid very short headings
|
| 64 |
-
subheadings.append(subheading.strip())
|
| 65 |
-
|
| 66 |
-
return subheadings
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
def filter_empty_sections(sections: List[ExportSection]) -> List[ExportSection]:
|
| 79 |
-
"""Filter out sections with no content to prevent empty pages"""
|
| 80 |
-
filtered_sections = []
|
| 81 |
-
|
| 82 |
-
for section in sections:
|
| 83 |
-
if section.content and section.content.strip():
|
| 84 |
-
# Clean the content to check if it's actually meaningful
|
| 85 |
-
cleaned_content = section.content # No clean_text_for_pdf import, so use raw content
|
| 86 |
-
if cleaned_content and len(cleaned_content.strip()) > 10: # At least 10 characters
|
| 87 |
-
filtered_sections.append(section)
|
| 88 |
-
# logging.info(f"Section '{section.title}' passed filtering with {len(cleaned_content)} characters")
|
| 89 |
-
else:
|
| 90 |
-
# logging.info(f"Section '{section.title}' filtered out - content too short: {len(cleaned_content) if cleaned_content else 0} chars")
|
| 91 |
-
pass
|
| 92 |
-
else:
|
| 93 |
-
# logging.info(f"Section '{section.title}' filtered out - no content")
|
| 94 |
-
pass
|
| 95 |
-
|
| 96 |
-
# logging.info(f"Filtered sections: {len(filtered_sections)} out of {len(sections)} total")
|
| 97 |
-
return filtered_sections
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
def remove_duplicate_headings(content: str) -> str:
|
| 101 |
-
"""Remove duplicate headings from content"""
|
| 102 |
-
if not content:
|
| 103 |
-
return content
|
| 104 |
-
|
| 105 |
-
lines = content.split('\n')
|
| 106 |
-
cleaned_lines = []
|
| 107 |
-
seen_headings = set()
|
| 108 |
-
|
| 109 |
-
for line in lines:
|
| 110 |
-
line = line.strip()
|
| 111 |
-
if not line:
|
| 112 |
-
cleaned_lines.append('')
|
| 113 |
-
continue
|
| 114 |
-
|
| 115 |
-
# Check if this is any type of heading
|
| 116 |
-
is_heading = False
|
| 117 |
-
heading_text = ""
|
| 118 |
-
|
| 119 |
-
# Markdown headings (# ## ### etc.)
|
| 120 |
-
if line.startswith('#'):
|
| 121 |
-
heading_text = re.sub(r'^#+\s*', '', line).strip()
|
| 122 |
-
is_heading = True
|
| 123 |
-
|
| 124 |
-
# Bold headings (**text**)
|
| 125 |
-
elif line.startswith('**') and line.endswith('**') and len(line) > 4:
|
| 126 |
-
heading_text = line[2:-2].strip() # Remove ** symbols
|
| 127 |
-
is_heading = True
|
| 128 |
-
|
| 129 |
-
# Check for numbered headings (e.g., "8.1 Management Team")
|
| 130 |
-
elif re.match(r'^\d+\.?\d*\s+', line):
|
| 131 |
-
heading_text = re.sub(r'^\d+\.?\d*\s+', '', line).strip()
|
| 132 |
-
is_heading = True
|
| 133 |
-
|
| 134 |
-
if is_heading and heading_text:
|
| 135 |
-
# Normalize the heading text for comparison
|
| 136 |
-
normalized_text = heading_text.lower().strip()
|
| 137 |
-
|
| 138 |
-
# Also check for variations with "and" vs "&"
|
| 139 |
-
variations = [
|
| 140 |
-
normalized_text,
|
| 141 |
-
normalized_text.replace(' & ', ' and '),
|
| 142 |
-
normalized_text.replace(' and ', ' & '),
|
| 143 |
-
normalized_text.replace('&', 'and'),
|
| 144 |
-
normalized_text.replace('and', '&')
|
| 145 |
-
]
|
| 146 |
-
|
| 147 |
-
# Check if any variation has been seen before
|
| 148 |
-
is_duplicate = any(var in seen_headings for var in variations)
|
| 149 |
-
|
| 150 |
-
if not is_duplicate:
|
| 151 |
-
# Add all variations to seen set
|
| 152 |
-
for var in variations:
|
| 153 |
-
seen_headings.add(var)
|
| 154 |
-
cleaned_lines.append(line)
|
| 155 |
-
# Skip duplicate headings
|
| 156 |
-
else:
|
| 157 |
-
cleaned_lines.append(line)
|
| 158 |
-
|
| 159 |
-
return '\n'.join(cleaned_lines)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/DocumentGeneration/grouped.py
DELETED
|
@@ -1,304 +0,0 @@
|
|
| 1 |
-
import re
|
| 2 |
-
from typing import List, Dict, Any
|
| 3 |
-
|
| 4 |
-
def calculate_group_height(pdf, group: Dict[str, Any]) -> int:
|
| 5 |
-
"""Calculate the estimated height needed for a complete group"""
|
| 6 |
-
subheading_type = group['subheading_type']
|
| 7 |
-
content_lines = group['content'].split('\n') if group['content'] else []
|
| 8 |
-
|
| 9 |
-
# Base height for subheading
|
| 10 |
-
if subheading_type == 'h1':
|
| 11 |
-
subheading_height = 25 # 20pt font + spacing
|
| 12 |
-
elif subheading_type == 'h2':
|
| 13 |
-
subheading_height = 20 # 16pt font + underline + spacing
|
| 14 |
-
elif subheading_type == 'h3':
|
| 15 |
-
subheading_height = 16 # 14pt font + spacing
|
| 16 |
-
elif subheading_type == 'h4':
|
| 17 |
-
subheading_height = 14 # 12pt font + spacing
|
| 18 |
-
elif subheading_type == 'bold':
|
| 19 |
-
subheading_height = 20 # 16pt font + spacing
|
| 20 |
-
elif subheading_type == 'numbered':
|
| 21 |
-
subheading_height = 20 # 16pt font + spacing
|
| 22 |
-
else:
|
| 23 |
-
subheading_height = 16
|
| 24 |
-
|
| 25 |
-
# Calculate content height more accurately
|
| 26 |
-
content_height = 0
|
| 27 |
-
for line in content_lines:
|
| 28 |
-
if line.strip():
|
| 29 |
-
# More accurate line height calculation
|
| 30 |
-
words = line.split()
|
| 31 |
-
if len(words) > 0:
|
| 32 |
-
# Estimate characters per line (roughly 80-90 chars per line)
|
| 33 |
-
chars_per_line = 85
|
| 34 |
-
estimated_lines = max(1, len(line) // chars_per_line + 1)
|
| 35 |
-
line_height = estimated_lines * 8 # 8pt per line
|
| 36 |
-
content_height += line_height
|
| 37 |
-
else:
|
| 38 |
-
content_height += 8 # Empty line
|
| 39 |
-
else:
|
| 40 |
-
content_height += 3 # Empty line spacing
|
| 41 |
-
|
| 42 |
-
# Add spacing between groups
|
| 43 |
-
group_spacing = 8
|
| 44 |
-
|
| 45 |
-
total_height = subheading_height + content_height + group_spacing
|
| 46 |
-
|
| 47 |
-
print(f"DEBUG: Group '{group.get('subheading_clean', 'Unknown')[:30]}...' height calculation:")
|
| 48 |
-
print(f" Subheading height: {subheading_height}")
|
| 49 |
-
print(f" Content height: {content_height}")
|
| 50 |
-
print(f" Group spacing: {group_spacing}")
|
| 51 |
-
print(f" Total height: {total_height}")
|
| 52 |
-
|
| 53 |
-
return total_height
|
| 54 |
-
|
| 55 |
-
def should_break_page_for_group(pdf, group: Dict[str, Any], current_y: int, page_height: int = 280) -> bool:
|
| 56 |
-
"""Determine if we should break to a new page for this group"""
|
| 57 |
-
estimated_height = calculate_group_height(pdf, group)
|
| 58 |
-
|
| 59 |
-
print(f"DEBUG: should_break_page_for_group called")
|
| 60 |
-
print(f"DEBUG: Subheading: '{group.get('subheading_clean', 'Unknown')[:30]}...'")
|
| 61 |
-
print(f"DEBUG: Current Y: {current_y}")
|
| 62 |
-
print(f"DEBUG: Estimated height: {estimated_height}")
|
| 63 |
-
print(f"DEBUG: Page height: {page_height}")
|
| 64 |
-
print(f"DEBUG: Available space: {page_height - current_y}")
|
| 65 |
-
print(f"DEBUG: Will fit? {current_y + estimated_height <= page_height}")
|
| 66 |
-
|
| 67 |
-
# CRITICAL FIX: Only break if the group absolutely won't fit
|
| 68 |
-
# This prevents splitting subheadings from their content
|
| 69 |
-
|
| 70 |
-
# If the group won't fit on current page, break
|
| 71 |
-
if current_y + estimated_height > page_height:
|
| 72 |
-
print(f"DEBUG: -> BREAKING: Group won't fit ({current_y} + {estimated_height} > {page_height})")
|
| 73 |
-
return True
|
| 74 |
-
|
| 75 |
-
# If we're very close to the end of the page (less than 50mm remaining) and group is significant, break
|
| 76 |
-
if current_y > page_height - 50 and estimated_height > 30:
|
| 77 |
-
print(f"DEBUG: -> BREAKING: Very close to end of page ({current_y} > {page_height - 50}) and group significant ({estimated_height} > 30)")
|
| 78 |
-
return True
|
| 79 |
-
|
| 80 |
-
print(f"DEBUG: -> NO BREAK: Group will fit on current page")
|
| 81 |
-
return False
|
| 82 |
-
|
| 83 |
-
def clean_markdown_from_line(line: str) -> str:
|
| 84 |
-
"""Clean markdown formatting from a single line"""
|
| 85 |
-
if not line:
|
| 86 |
-
return ""
|
| 87 |
-
|
| 88 |
-
cleaned = line
|
| 89 |
-
|
| 90 |
-
# Remove bold, italic formatting
|
| 91 |
-
cleaned = re.sub(r'\*\*(.*?)\*\*', r'\1', cleaned) # Remove bold
|
| 92 |
-
cleaned = re.sub(r'\*(.*?)\*', r'\1', cleaned) # Remove italic
|
| 93 |
-
cleaned = re.sub(r'`(.*?)`', r'\1', cleaned) # Remove code
|
| 94 |
-
cleaned = re.sub(r'~~(.*?)~~', r'\1', cleaned) # Remove strikethrough
|
| 95 |
-
|
| 96 |
-
# Remove emphasis markers
|
| 97 |
-
cleaned = re.sub(r'_{1,2}(.*?)_{1,2}', r'\1', cleaned) # Remove underscores
|
| 98 |
-
cleaned = re.sub(r'\*{1,2}(.*?)\*{1,2}', r'\1', cleaned) # Remove asterisks
|
| 99 |
-
|
| 100 |
-
# Remove links (keep text)
|
| 101 |
-
cleaned = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', cleaned) # Remove links, keep text
|
| 102 |
-
|
| 103 |
-
# Remove any remaining HTML-like tags
|
| 104 |
-
cleaned = re.sub(r'<[^>]+>', '', cleaned)
|
| 105 |
-
|
| 106 |
-
# CRITICAL FIX: Replace multiple spaces with single space to prevent double spaces
|
| 107 |
-
cleaned = re.sub(r' +', ' ', cleaned) # Replace multiple spaces with single space
|
| 108 |
-
|
| 109 |
-
return cleaned.strip()
|
| 110 |
-
|
| 111 |
-
def parse_content_into_groups(content: str) -> List[Dict[str, Any]]:
|
| 112 |
-
"""Parse markdown content into subheading-content groups"""
|
| 113 |
-
if not content:
|
| 114 |
-
return []
|
| 115 |
-
|
| 116 |
-
print(f"DEBUG: parse_content_into_groups called with content length: {len(content)}")
|
| 117 |
-
|
| 118 |
-
lines = content.split('\n')
|
| 119 |
-
groups = []
|
| 120 |
-
current_group = None
|
| 121 |
-
|
| 122 |
-
for i, line in enumerate(lines):
|
| 123 |
-
line = line.strip()
|
| 124 |
-
if not line:
|
| 125 |
-
if current_group:
|
| 126 |
-
current_group['content_lines'].append('') # Preserve empty lines
|
| 127 |
-
continue
|
| 128 |
-
|
| 129 |
-
# Check if this is a heading (H1, H2, H3, H4, or numbered)
|
| 130 |
-
is_heading = (
|
| 131 |
-
line.startswith('# ') or # H1
|
| 132 |
-
(line.startswith('## ') and not line.startswith('### ')) or # H2
|
| 133 |
-
(line.startswith('### ') and not line.startswith('#### ')) or # H3
|
| 134 |
-
(line.startswith('#### ') and not line.startswith('##### ')) or # H4
|
| 135 |
-
re.match(r'^\d+\.\d+\s+', line) # Numbered subheadings like "3.6 SWOT Analysis"
|
| 136 |
-
)
|
| 137 |
-
|
| 138 |
-
# Check if this is bold text
|
| 139 |
-
is_bold_text = (
|
| 140 |
-
(line.startswith('**') and line.endswith('**') and len(line) > 4) or # Bold headings
|
| 141 |
-
(re.match(r'^\*\*.*?\*\*:', line) and len(line) > 4) # Bold text before colon as heading
|
| 142 |
-
)
|
| 143 |
-
|
| 144 |
-
# Check if this is a bold list item (e.g., "- **Item**")
|
| 145 |
-
is_bold_list_item = re.match(r'^[-*]\s*\*\*(.*?)\*\*$', line)
|
| 146 |
-
|
| 147 |
-
# Special handling for bold text following numbered subheadings
|
| 148 |
-
should_create_new_group = False
|
| 149 |
-
|
| 150 |
-
if is_heading:
|
| 151 |
-
# Always create new group for regular headings
|
| 152 |
-
should_create_new_group = True
|
| 153 |
-
print(f"DEBUG: Line {i+1} '{line[:50]}...' - Regular heading, creating new group")
|
| 154 |
-
elif is_bold_text:
|
| 155 |
-
# Check if we should treat this bold text as content for a numbered subheading
|
| 156 |
-
if groups and groups[-1].get('subheading_type') == 'h3':
|
| 157 |
-
# Check if the previous group's subheading looks like a numbered subheading
|
| 158 |
-
previous_subheading = groups[-1].get('subheading', '')
|
| 159 |
-
if re.match(r'^\d+\.\d+\s+', previous_subheading):
|
| 160 |
-
# This is content for a numbered subheading, add to previous group
|
| 161 |
-
groups[-1]['content_lines'].append(line)
|
| 162 |
-
print(f"DEBUG: Line {i+1} '{line[:50]}...' - Adding to previous numbered subheading '{previous_subheading[:30]}...'")
|
| 163 |
-
continue # Skip creating new group
|
| 164 |
-
else:
|
| 165 |
-
# Not a numbered subheading, treat as new heading
|
| 166 |
-
should_create_new_group = True
|
| 167 |
-
print(f"DEBUG: Line {i+1} '{line[:50]}...' - Treating as NEW HEADING (not numbered subheading)")
|
| 168 |
-
else:
|
| 169 |
-
# No previous group or not a numbered subheading, treat as new heading
|
| 170 |
-
should_create_new_group = True
|
| 171 |
-
print(f"DEBUG: Line {i+1} '{line[:50]}...' - Treating as NEW HEADING (no numbered subheading)")
|
| 172 |
-
elif is_bold_list_item:
|
| 173 |
-
# If it's a bold list item, treat it as a new heading
|
| 174 |
-
should_create_new_group = True
|
| 175 |
-
print(f"DEBUG: Line {i+1} '{line[:50]}...' - Bold list item, creating new group")
|
| 176 |
-
|
| 177 |
-
if should_create_new_group:
|
| 178 |
-
# Save previous group if it exists
|
| 179 |
-
if current_group:
|
| 180 |
-
current_group['content'] = '\n'.join(current_group['content_lines'])
|
| 181 |
-
groups.append(current_group)
|
| 182 |
-
print(f"DEBUG: Saved group '{current_group.get('subheading_clean', 'Unknown')[:30]}...' with {len(current_group['content_lines'])} content lines")
|
| 183 |
-
|
| 184 |
-
# Clean markdown symbols BEFORE determining type and storing
|
| 185 |
-
clean_text = line
|
| 186 |
-
heading_type = 'text'
|
| 187 |
-
|
| 188 |
-
if line.startswith('# '):
|
| 189 |
-
clean_text = line[2:].strip() # Remove "# "
|
| 190 |
-
heading_type = 'h1'
|
| 191 |
-
elif line.startswith('## '):
|
| 192 |
-
clean_text = line[3:].strip() # Remove "## "
|
| 193 |
-
heading_type = 'h2'
|
| 194 |
-
elif line.startswith('### '):
|
| 195 |
-
clean_text = line[4:].strip() # Remove "### "
|
| 196 |
-
heading_type = 'h3'
|
| 197 |
-
elif line.startswith('#### '):
|
| 198 |
-
clean_text = line[5:].strip() # Remove "#### "
|
| 199 |
-
heading_type = 'h4'
|
| 200 |
-
elif re.match(r'^\d+\.\d+\s+', line):
|
| 201 |
-
clean_text = line.strip() # Keep numbered headings as-is
|
| 202 |
-
heading_type = 'numbered'
|
| 203 |
-
elif is_bold_text:
|
| 204 |
-
# Remove ** markers from bold text
|
| 205 |
-
clean_text = clean_markdown_from_line(line.strip())
|
| 206 |
-
heading_type = 'bold'
|
| 207 |
-
elif is_bold_list_item:
|
| 208 |
-
# Extract the bold text from list item and treat as H3 subheading
|
| 209 |
-
# Remove the list marker and ** markers
|
| 210 |
-
clean_text = re.sub(r'^[-*]\s*\*\*(.*?)\*\*', r'\1', line.strip())
|
| 211 |
-
heading_type = 'h3' # Treat as H3 subheading
|
| 212 |
-
|
| 213 |
-
print(f"DEBUG: Creating new group '{clean_text[:30]}...' (type: {heading_type})")
|
| 214 |
-
|
| 215 |
-
# Start new group with cleaned text
|
| 216 |
-
current_group = {
|
| 217 |
-
'subheading': line, # Keep original for reference
|
| 218 |
-
'content_lines': [],
|
| 219 |
-
'content': '',
|
| 220 |
-
'subheading_clean': clean_text, # Pre-cleaned text
|
| 221 |
-
'subheading_type': heading_type
|
| 222 |
-
}
|
| 223 |
-
else:
|
| 224 |
-
# Add to current group's content - clean the line
|
| 225 |
-
if current_group:
|
| 226 |
-
cleaned_line = clean_markdown_from_line(line)
|
| 227 |
-
current_group['content_lines'].append(cleaned_line)
|
| 228 |
-
if i < 5: # Log first few content lines
|
| 229 |
-
print(f"DEBUG: Added content line to group '{current_group.get('subheading_clean', 'Unknown')[:30]}...': '{cleaned_line[:50]}...'")
|
| 230 |
-
else:
|
| 231 |
-
# Content before any subheading - create a default group
|
| 232 |
-
if not groups or groups[-1].get('subheading') != '## Introduction':
|
| 233 |
-
cleaned_line = clean_markdown_from_line(line)
|
| 234 |
-
current_group = {
|
| 235 |
-
'subheading': '## Introduction',
|
| 236 |
-
'content_lines': [cleaned_line],
|
| 237 |
-
'content': '',
|
| 238 |
-
'subheading_clean': 'Introduction',
|
| 239 |
-
'subheading_type': 'h2'
|
| 240 |
-
}
|
| 241 |
-
|
| 242 |
-
# Don't forget the last group
|
| 243 |
-
if current_group:
|
| 244 |
-
current_group['content'] = '\n'.join(current_group['content_lines'])
|
| 245 |
-
groups.append(current_group)
|
| 246 |
-
print(f"DEBUG: Saved final group '{current_group.get('subheading_clean', 'Unknown')[:30]}...' with {len(current_group['content_lines'])} content lines")
|
| 247 |
-
|
| 248 |
-
# Add metadata to each group
|
| 249 |
-
for i, group in enumerate(groups):
|
| 250 |
-
group['group_id'] = f"group_{i}"
|
| 251 |
-
group['content_length'] = len(group['content'])
|
| 252 |
-
group['word_count'] = len(group['content'].split()) if group['content'] else 0
|
| 253 |
-
group['has_charts'] = 'CHART' in group['content'].upper() if group['content'] else False
|
| 254 |
-
group['line_count'] = len([line for line in group['content'].split('\n') if line.strip()])
|
| 255 |
-
|
| 256 |
-
print(f"DEBUG: Created {len(groups)} groups total")
|
| 257 |
-
return groups
|
| 258 |
-
|
| 259 |
-
def extract_key_phrases(content: str) -> List[tuple]:
|
| 260 |
-
"""Extract key phrases from content for grouping metadata"""
|
| 261 |
-
if not content:
|
| 262 |
-
return []
|
| 263 |
-
|
| 264 |
-
# Simple key phrase extraction
|
| 265 |
-
words = re.findall(r'\b\w+\b', content.lower())
|
| 266 |
-
word_freq = {}
|
| 267 |
-
|
| 268 |
-
for word in words:
|
| 269 |
-
if len(word) > 3: # Only words longer than 3 characters
|
| 270 |
-
word_freq[word] = word_freq.get(word, 0) + 1
|
| 271 |
-
|
| 272 |
-
# Return top 5 most frequent words
|
| 273 |
-
return sorted(word_freq.items(), key=lambda x: x[1], reverse=True)[:5]
|
| 274 |
-
|
| 275 |
-
def get_content_groups_info(content: str) -> Dict[str, Any]:
|
| 276 |
-
"""Get detailed information about content groups for analysis and debugging"""
|
| 277 |
-
if not content:
|
| 278 |
-
return {"groups": [], "summary": {"total_groups": 0, "total_content_length": 0}}
|
| 279 |
-
|
| 280 |
-
groups = parse_content_into_groups(content)
|
| 281 |
-
|
| 282 |
-
# Add key phrases to each group
|
| 283 |
-
for group in groups:
|
| 284 |
-
group['key_phrases'] = extract_key_phrases(group['content'])
|
| 285 |
-
|
| 286 |
-
# Calculate summary statistics
|
| 287 |
-
total_content_length = sum(group['content_length'] for group in groups)
|
| 288 |
-
total_word_count = sum(group['word_count'] for group in groups)
|
| 289 |
-
groups_with_charts = sum(1 for group in groups if group['has_charts'])
|
| 290 |
-
|
| 291 |
-
summary = {
|
| 292 |
-
"total_groups": len(groups),
|
| 293 |
-
"total_content_length": total_content_length,
|
| 294 |
-
"total_word_count": total_word_count,
|
| 295 |
-
"groups_with_charts": groups_with_charts,
|
| 296 |
-
"average_group_size": total_content_length / len(groups) if groups else 0,
|
| 297 |
-
"largest_group": max(groups, key=lambda g: g['content_length'])['subheading_clean'] if groups else None,
|
| 298 |
-
"smallest_group": min(groups, key=lambda g: g['content_length'])['subheading_clean'] if groups else None
|
| 299 |
-
}
|
| 300 |
-
|
| 301 |
-
return {
|
| 302 |
-
"groups": groups,
|
| 303 |
-
"summary": summary
|
| 304 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/DocumentGeneration/live_pdf_generator.py
DELETED
|
@@ -1,126 +0,0 @@
|
|
| 1 |
-
# Live PDF Generator Module
|
| 2 |
-
# Enables real-time PDF updates as content changes
|
| 3 |
-
|
| 4 |
-
import asyncio
|
| 5 |
-
import io
|
| 6 |
-
import tempfile
|
| 7 |
-
import os
|
| 8 |
-
from typing import Dict, Any, Optional, AsyncGenerator
|
| 9 |
-
from fpdf import FPDF
|
| 10 |
-
import base64
|
| 11 |
-
|
| 12 |
-
import sys
|
| 13 |
-
import os
|
| 14 |
-
sys.path.append(os.path.dirname(__file__))
|
| 15 |
-
from pdf_generator import create_pdf_document
|
| 16 |
-
from models import ExportRequest
|
| 17 |
-
|
| 18 |
-
class LivePDFGenerator:
|
| 19 |
-
"""Generates and streams PDF updates in real-time"""
|
| 20 |
-
|
| 21 |
-
def __init__(self):
|
| 22 |
-
self.active_sessions: Dict[str, 'PDFSession'] = {}
|
| 23 |
-
|
| 24 |
-
async def create_live_session(self, business_plan_id: str, initial_request: ExportRequest) -> str:
|
| 25 |
-
"""Create a new live PDF session"""
|
| 26 |
-
session_id = f"pdf_session_{business_plan_id}_{int(asyncio.get_event_loop().time())}"
|
| 27 |
-
|
| 28 |
-
session = PDFSession(session_id, business_plan_id, initial_request)
|
| 29 |
-
self.active_sessions[session_id] = session
|
| 30 |
-
|
| 31 |
-
# Generate initial PDF
|
| 32 |
-
await session.update_pdf(initial_request)
|
| 33 |
-
|
| 34 |
-
return session_id
|
| 35 |
-
|
| 36 |
-
async def update_session(self, session_id: str, updated_request: ExportRequest) -> bool:
|
| 37 |
-
"""Update an existing PDF session with new content"""
|
| 38 |
-
if session_id not in self.active_sessions:
|
| 39 |
-
return False
|
| 40 |
-
|
| 41 |
-
session = self.active_sessions[session_id]
|
| 42 |
-
await session.update_pdf(updated_request)
|
| 43 |
-
return True
|
| 44 |
-
|
| 45 |
-
async def stream_pdf_updates(self, session_id: str) -> AsyncGenerator[bytes, None]:
|
| 46 |
-
"""Stream PDF updates as they occur"""
|
| 47 |
-
if session_id not in self.active_sessions:
|
| 48 |
-
return
|
| 49 |
-
|
| 50 |
-
session = self.active_sessions[session_id]
|
| 51 |
-
async for pdf_chunk in session.stream_updates():
|
| 52 |
-
yield pdf_chunk
|
| 53 |
-
|
| 54 |
-
def close_session(self, session_id: str):
|
| 55 |
-
"""Close and cleanup a PDF session"""
|
| 56 |
-
if session_id in self.active_sessions:
|
| 57 |
-
del self.active_sessions[session_id]
|
| 58 |
-
|
| 59 |
-
class PDFSession:
|
| 60 |
-
"""Individual PDF session that manages real-time updates"""
|
| 61 |
-
|
| 62 |
-
def __init__(self, session_id: str, business_plan_id: str, initial_request: ExportRequest):
|
| 63 |
-
self.session_id = session_id
|
| 64 |
-
self.business_plan_id = business_plan_id
|
| 65 |
-
self.current_request = initial_request
|
| 66 |
-
self.update_queue = asyncio.Queue()
|
| 67 |
-
self.is_active = True
|
| 68 |
-
self.last_pdf_bytes: Optional[bytes] = None
|
| 69 |
-
|
| 70 |
-
async def update_pdf(self, new_request: ExportRequest):
|
| 71 |
-
"""Update the PDF with new content"""
|
| 72 |
-
self.current_request = new_request
|
| 73 |
-
|
| 74 |
-
try:
|
| 75 |
-
# Generate new PDF
|
| 76 |
-
pdf_bytes, filename = create_pdf_document(new_request)
|
| 77 |
-
self.last_pdf_bytes = pdf_bytes
|
| 78 |
-
|
| 79 |
-
# Notify subscribers of update
|
| 80 |
-
await self.update_queue.put({
|
| 81 |
-
'type': 'pdf_update',
|
| 82 |
-
'data': base64.b64encode(pdf_bytes).decode('utf-8'),
|
| 83 |
-
'filename': filename,
|
| 84 |
-
'timestamp': asyncio.get_event_loop().time()
|
| 85 |
-
})
|
| 86 |
-
|
| 87 |
-
except Exception as e:
|
| 88 |
-
# Notify subscribers of error
|
| 89 |
-
await self.update_queue.put({
|
| 90 |
-
'type': 'pdf_error',
|
| 91 |
-
'error': str(e),
|
| 92 |
-
'timestamp': asyncio.get_event_loop().time()
|
| 93 |
-
})
|
| 94 |
-
|
| 95 |
-
async def stream_updates(self) -> AsyncGenerator[bytes, None]:
|
| 96 |
-
"""Stream PDF updates as they occur"""
|
| 97 |
-
while self.is_active:
|
| 98 |
-
try:
|
| 99 |
-
# Wait for updates with timeout
|
| 100 |
-
update = await asyncio.wait_for(self.update_queue.get(), timeout=30.0)
|
| 101 |
-
|
| 102 |
-
if update['type'] == 'pdf_update':
|
| 103 |
-
# Send SSE event for PDF update
|
| 104 |
-
event_data = f"event: pdf_update\ndata: {{\"data\": \"{update['data']}\", \"filename\": \"{update['filename']}\"}}\n\n"
|
| 105 |
-
yield event_data.encode('utf-8')
|
| 106 |
-
|
| 107 |
-
elif update['type'] == 'pdf_error':
|
| 108 |
-
# Send SSE event for error
|
| 109 |
-
error_data = f"event: pdf_error\ndata: {{\"error\": \"{update['error']}\"}}\n\n"
|
| 110 |
-
yield error_data.encode('utf-8')
|
| 111 |
-
|
| 112 |
-
except asyncio.TimeoutError:
|
| 113 |
-
# Send keep-alive
|
| 114 |
-
yield b": keep-alive\n\n"
|
| 115 |
-
except Exception as e:
|
| 116 |
-
# Send error event
|
| 117 |
-
error_data = f"event: stream_error\ndata: {str(e)}\n\n"
|
| 118 |
-
yield error_data.encode('utf-8')
|
| 119 |
-
break
|
| 120 |
-
|
| 121 |
-
def close(self):
|
| 122 |
-
"""Close this session"""
|
| 123 |
-
self.is_active = False
|
| 124 |
-
|
| 125 |
-
# Global instance
|
| 126 |
-
live_pdf_generator = LivePDFGenerator()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/DocumentGeneration/markdown_renderer.py
DELETED
|
@@ -1,759 +0,0 @@
|
|
| 1 |
-
# Markdown Renderer Module
|
| 2 |
-
# Contains all markdown rendering functions for both PDF and Word
|
| 3 |
-
|
| 4 |
-
import re
|
| 5 |
-
import base64
|
| 6 |
-
import os
|
| 7 |
-
import sys
|
| 8 |
-
import tempfile
|
| 9 |
-
from typing import List, Dict, Any, Optional
|
| 10 |
-
|
| 11 |
-
# Import from the same directory (Document Generation)
|
| 12 |
-
from grouped import parse_content_into_groups, should_break_page_for_group
|
| 13 |
-
from chart_generator import create_chart_from_data
|
| 14 |
-
|
| 15 |
-
def clean_markdown_text(text: str) -> str:
|
| 16 |
-
"""Clean markdown formatting for PDF generation - PRESERVE HEADINGS"""
|
| 17 |
-
if not text:
|
| 18 |
-
return ""
|
| 19 |
-
|
| 20 |
-
# Remove markdown formatting BUT PRESERVE HEADING STRUCTURE
|
| 21 |
-
cleaned = text
|
| 22 |
-
|
| 23 |
-
# PRESERVE HEADINGS - don't strip the # markers, keep them for rendering
|
| 24 |
-
# We'll handle styling at render time instead of stripping them
|
| 25 |
-
|
| 26 |
-
# Remove bold, italic formatting - strip ** or * symbols
|
| 27 |
-
cleaned = re.sub(r'\*\*(.*?)\*\*', r'\1', cleaned) # Remove bold
|
| 28 |
-
cleaned = re.sub(r'\*(.*?)\*', r'\1', cleaned) # Remove italic
|
| 29 |
-
cleaned = re.sub(r'`(.*?)`', r'\1', cleaned) # Remove code
|
| 30 |
-
cleaned = re.sub(r'~~(.*?)~~', r'\1', cleaned) # Remove strikethrough
|
| 31 |
-
|
| 32 |
-
# Remove links (keep text)
|
| 33 |
-
cleaned = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', cleaned) # Remove links, keep text
|
| 34 |
-
cleaned = re.sub(r'!\[([^\]]*)\]\([^)]+\)', '', cleaned) # Remove images
|
| 35 |
-
|
| 36 |
-
# Remove list markers (more aggressive)
|
| 37 |
-
cleaned = re.sub(r'^\s*[-*+]\s*', '', cleaned, flags=re.MULTILINE) # Remove list markers
|
| 38 |
-
cleaned = re.sub(r'^\s*\d+\.\s*', '', cleaned, flags=re.MULTILINE) # Remove numbered list markers
|
| 39 |
-
cleaned = re.sub(r'^\s*[-*+]\s*', '', cleaned, flags=re.MULTILINE) # Remove any remaining list markers
|
| 40 |
-
|
| 41 |
-
# Remove blockquotes
|
| 42 |
-
cleaned = re.sub(r'^\s*>\s*', '', cleaned, flags=re.MULTILINE) # Remove blockquotes
|
| 43 |
-
|
| 44 |
-
# Remove horizontal rules
|
| 45 |
-
cleaned = re.sub(r'^\s*[-*_]{3,}\s*$', '', cleaned, flags=re.MULTILINE) # Remove horizontal rules
|
| 46 |
-
|
| 47 |
-
# Remove code blocks
|
| 48 |
-
cleaned = re.sub(r'```[\s\S]*?```', '', cleaned) # Remove code blocks
|
| 49 |
-
cleaned = re.sub(r'`.*?`', '', cleaned) # Remove any remaining inline code
|
| 50 |
-
|
| 51 |
-
# Remove emphasis markers - strip ** or * symbols
|
| 52 |
-
cleaned = re.sub(r'_{1,2}(.*?)_{1,2}', r'\1', cleaned) # Remove underscores
|
| 53 |
-
cleaned = re.sub(r'\*{1,2}(.*?)\*{1,2}', r'\1', cleaned) # Remove asterisks
|
| 54 |
-
|
| 55 |
-
# PRESERVE CHART DATA MARKERS for inline processing
|
| 56 |
-
# DO NOT REMOVE chart markers - they will be processed inline
|
| 57 |
-
# cleaned = re.sub(r'<!--CHARTDATASTART-->.*?<!--CHARTDATAEND-->', '', cleaned, flags=re.DOTALL)
|
| 58 |
-
# cleaned = re.sub(r'<!--CHARTDATASTART-->.*$', '', cleaned, flags=re.DOTALL)
|
| 59 |
-
# cleaned = re.sub(r'<!--CHARTDATAEND-->', '', cleaned)
|
| 60 |
-
|
| 61 |
-
# REMOVE CHART DATA MARKERS for PDF rendering since they're not being processed
|
| 62 |
-
cleaned = re.sub(r'<!--CHART_DATA_START-->.*?<!--CHART_DATA_END-->', '', cleaned, flags=re.DOTALL)
|
| 63 |
-
cleaned = re.sub(r'<!--CHARTDATASTART-->.*?<!--CHARTDATAEND-->', '', cleaned, flags=re.DOTALL)
|
| 64 |
-
|
| 65 |
-
# Remove any remaining HTML-like tags
|
| 66 |
-
cleaned = re.sub(r'<[^>]+>', '', cleaned)
|
| 67 |
-
|
| 68 |
-
# Clean up extra whitespace and formatting
|
| 69 |
-
cleaned = re.sub(r'\n\s*\n\s*\n+', '\n\n', cleaned) # Remove excessive line breaks
|
| 70 |
-
cleaned = re.sub(r'^\s+', '', cleaned, flags=re.MULTILINE) # Remove leading whitespace
|
| 71 |
-
cleaned = re.sub(r'\s+$', '', cleaned, flags=re.MULTILINE) # Remove trailing whitespace
|
| 72 |
-
cleaned = re.sub(r' +', ' ', cleaned) # Replace multiple spaces with single space
|
| 73 |
-
|
| 74 |
-
# Final cleanup
|
| 75 |
-
cleaned = cleaned.strip()
|
| 76 |
-
|
| 77 |
-
return cleaned
|
| 78 |
-
|
| 79 |
-
def clean_text_for_pdf(text: str) -> str:
|
| 80 |
-
"""Clean text specifically for PDF generation, handling Unicode issues"""
|
| 81 |
-
if not text:
|
| 82 |
-
return ""
|
| 83 |
-
|
| 84 |
-
# First clean markdown
|
| 85 |
-
cleaned = clean_markdown_text(text)
|
| 86 |
-
|
| 87 |
-
# Replace problematic Unicode characters with ASCII equivalents
|
| 88 |
-
unicode_replacements = {
|
| 89 |
-
'\u2019': "'", # Right single quotation mark
|
| 90 |
-
'\u2018': "'", # Left single quotation mark
|
| 91 |
-
'\u201C': '"', # Left double quotation mark
|
| 92 |
-
'\u201D': '"', # Right double quotation mark
|
| 93 |
-
'\u2013': '-', # En dash
|
| 94 |
-
'\u2014': '--', # Em dash
|
| 95 |
-
'\u2022': '•', # Bullet
|
| 96 |
-
'\u2026': '...', # Horizontal ellipsis
|
| 97 |
-
'\u00A0': ' ', # Non-breaking space
|
| 98 |
-
'\u00B0': '°', # Degree sign
|
| 99 |
-
'\u00AE': '(R)', # Registered trademark
|
| 100 |
-
'\u2122': '(TM)', # Trademark
|
| 101 |
-
'\u00A9': '(C)', # Copyright
|
| 102 |
-
}
|
| 103 |
-
|
| 104 |
-
for unicode_char, replacement in unicode_replacements.items():
|
| 105 |
-
cleaned = cleaned.replace(unicode_char, replacement)
|
| 106 |
-
|
| 107 |
-
# Additional space normalization after Unicode replacements to prevent double spaces
|
| 108 |
-
cleaned = re.sub(r' +', ' ', cleaned) # Replace multiple spaces with single space
|
| 109 |
-
|
| 110 |
-
return cleaned
|
| 111 |
-
|
| 112 |
-
def process_content_with_inline_charts(doc, content: str, business_idea: str):
|
| 113 |
-
"""Process content and render charts inline where they appear in the text"""
|
| 114 |
-
try:
|
| 115 |
-
from docx.shared import Inches
|
| 116 |
-
except ImportError:
|
| 117 |
-
raise Exception("python-docx is not installed. Please install it with: pip install python-docx")
|
| 118 |
-
|
| 119 |
-
# Split content by chart markers - handle both formats
|
| 120 |
-
chart_pattern = r'<!--CHART_DATA_START-->(.*?)<!--CHART_DATA_END-->'
|
| 121 |
-
parts = re.split(chart_pattern, content, flags=re.DOTALL)
|
| 122 |
-
|
| 123 |
-
# If no charts found, try alternative format
|
| 124 |
-
if len(parts) == 1:
|
| 125 |
-
chart_pattern = r'<!--CHARTDATASTART-->(.*?)<!--CHARTDATAEND-->'
|
| 126 |
-
parts = re.split(chart_pattern, content, flags=re.DOTALL)
|
| 127 |
-
|
| 128 |
-
for i, part in enumerate(parts):
|
| 129 |
-
if i % 2 == 0: # Regular content
|
| 130 |
-
if part.strip():
|
| 131 |
-
render_markdown_to_docx_grouped(doc, part.strip())
|
| 132 |
-
else: # Chart data
|
| 133 |
-
try:
|
| 134 |
-
chart_array = eval(part.strip())
|
| 135 |
-
for chart_item in chart_array:
|
| 136 |
-
if len(chart_item) >= 5:
|
| 137 |
-
chart_type = chart_item[1]
|
| 138 |
-
chart_title = chart_item[2]
|
| 139 |
-
data = chart_item[4]
|
| 140 |
-
|
| 141 |
-
# Add chart title
|
| 142 |
-
doc.add_heading(f"{chart_title}", level=2)
|
| 143 |
-
|
| 144 |
-
# Create and add chart
|
| 145 |
-
chart_info = {
|
| 146 |
-
"type": chart_type,
|
| 147 |
-
"title": chart_title,
|
| 148 |
-
"data": data
|
| 149 |
-
}
|
| 150 |
-
|
| 151 |
-
chart_bytes = create_chart_from_data(chart_info, business_idea)
|
| 152 |
-
|
| 153 |
-
if chart_bytes:
|
| 154 |
-
with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as tmp_img:
|
| 155 |
-
tmp_img.write(chart_bytes)
|
| 156 |
-
temp_img_path = tmp_img.name
|
| 157 |
-
|
| 158 |
-
# Add image to Word document
|
| 159 |
-
doc.add_picture(temp_img_path, width=Inches(6.0)) # 6 inches width
|
| 160 |
-
|
| 161 |
-
# Clean up temp file
|
| 162 |
-
os.remove(temp_img_path)
|
| 163 |
-
except Exception as e:
|
| 164 |
-
# If chart processing fails, just render the raw content
|
| 165 |
-
if part.strip():
|
| 166 |
-
render_markdown_to_docx_grouped(doc, part.strip())
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
def render_markdown_to_pdf(pdf, content: str, primary_color, accent_color, text_color):
|
| 170 |
-
"""Render markdown content to PDF with proper heading styling and page break logic"""
|
| 171 |
-
if not content:
|
| 172 |
-
return
|
| 173 |
-
|
| 174 |
-
lines = content.split('\n')
|
| 175 |
-
current_y = pdf.get_y()
|
| 176 |
-
|
| 177 |
-
for line in lines:
|
| 178 |
-
line = line.strip()
|
| 179 |
-
if not line:
|
| 180 |
-
# Check if we need a page break for empty line
|
| 181 |
-
if current_y + 10 > 280: # 280mm is roughly where we want to break
|
| 182 |
-
pdf.add_page()
|
| 183 |
-
current_y = 25
|
| 184 |
-
else:
|
| 185 |
-
pdf.ln(3) # Consistent space for empty lines
|
| 186 |
-
current_y = pdf.get_y()
|
| 187 |
-
continue
|
| 188 |
-
|
| 189 |
-
# Calculate estimated height needed for this line
|
| 190 |
-
estimated_height = 12 # Base height for text
|
| 191 |
-
|
| 192 |
-
# Handle different heading levels
|
| 193 |
-
if line.startswith("# "): # H1 - Main heading (like "Company Profile")
|
| 194 |
-
estimated_height = 25 # Heading + line + spacing
|
| 195 |
-
if current_y + estimated_height > 280:
|
| 196 |
-
pdf.add_page()
|
| 197 |
-
current_y = 25
|
| 198 |
-
|
| 199 |
-
text = line[2:].strip()
|
| 200 |
-
pdf.set_font("Arial", "B", 20) # Bold, size 20
|
| 201 |
-
pdf.set_text_color(*primary_color) # Black
|
| 202 |
-
pdf.set_xy(25, current_y)
|
| 203 |
-
pdf.multi_cell(160, 10, text)
|
| 204 |
-
|
| 205 |
-
# Calculate text width and make line autofit
|
| 206 |
-
text_width = pdf.get_string_width(text)
|
| 207 |
-
line_width = min(text_width + 12, 160) # Add 12mm padding, max 160mm
|
| 208 |
-
|
| 209 |
-
# Add subtle line under main heading - consistent with section titles
|
| 210 |
-
pdf.set_draw_color(221, 221, 221) # Lighter gray line (#DDDDDD)
|
| 211 |
-
pdf.set_line_width(0.3) # Thin line
|
| 212 |
-
pdf.line(25, current_y + 10, 25 + line_width, current_y + 10)
|
| 213 |
-
|
| 214 |
-
current_y = pdf.get_y()
|
| 215 |
-
pdf.ln(8) # Consistent space after main heading
|
| 216 |
-
current_y = pdf.get_y()
|
| 217 |
-
|
| 218 |
-
elif line.startswith("## ") or re.match(r'^\d+\.\d+\s+', line): # H2 - Subheading or numbered subheading
|
| 219 |
-
estimated_height = 20 # Subheading + line + spacing
|
| 220 |
-
if current_y + estimated_height > 280:
|
| 221 |
-
pdf.add_page()
|
| 222 |
-
current_y = 25
|
| 223 |
-
|
| 224 |
-
text = line[3:].strip() if line.startswith("## ") else line.strip()
|
| 225 |
-
pdf.set_font("Arial", "B", 16) # Bold, size 16
|
| 226 |
-
pdf.set_text_color(*primary_color) # Black
|
| 227 |
-
pdf.set_xy(25, current_y)
|
| 228 |
-
pdf.multi_cell(160, 8, text)
|
| 229 |
-
|
| 230 |
-
# Calculate text width and make line autofit
|
| 231 |
-
text_width = pdf.get_string_width(text)
|
| 232 |
-
line_width = min(text_width + 10, 160) # Add 10mm padding, max 160mm
|
| 233 |
-
|
| 234 |
-
# Add subtle line under subheading - consistent styling
|
| 235 |
-
pdf.set_draw_color(221, 221, 221) # Lighter gray line (#DDDDDD)
|
| 236 |
-
pdf.set_line_width(0.3) # Thin line
|
| 237 |
-
pdf.line(25, current_y + 8, 25 + line_width, current_y + 8)
|
| 238 |
-
|
| 239 |
-
current_y = pdf.get_y()
|
| 240 |
-
pdf.ln(6) # Consistent space after subheading
|
| 241 |
-
current_y = pdf.get_y()
|
| 242 |
-
|
| 243 |
-
elif re.match(r'^\d+\.\d+\s+[A-Z]', line): # Numbered subheadings like "3.6 SWOT Analysis"
|
| 244 |
-
estimated_height = 20 # Subheading + line + spacing
|
| 245 |
-
if current_y + estimated_height > 280:
|
| 246 |
-
pdf.add_page()
|
| 247 |
-
current_y = 25
|
| 248 |
-
|
| 249 |
-
text = line.strip()
|
| 250 |
-
pdf.set_font("Arial", "B", 16) # Bold, size 16
|
| 251 |
-
pdf.set_text_color(*primary_color) # Black
|
| 252 |
-
pdf.set_xy(25, current_y)
|
| 253 |
-
pdf.multi_cell(160, 8, text)
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
current_y = pdf.get_y()
|
| 257 |
-
pdf.ln(6) # Consistent space after subheading
|
| 258 |
-
current_y = pdf.get_y()
|
| 259 |
-
|
| 260 |
-
elif line.startswith("### ") or line.startswith("#### "): # H3/H4 - Smaller subheadings
|
| 261 |
-
estimated_height = 18 # Subheading + spacing
|
| 262 |
-
if current_y + estimated_height > 280:
|
| 263 |
-
pdf.add_page()
|
| 264 |
-
current_y = 25
|
| 265 |
-
|
| 266 |
-
text = line[4:].strip() if line.startswith("### ") else line[5:].strip()
|
| 267 |
-
pdf.set_font("Arial", "B", 14) # Bold, size 14
|
| 268 |
-
pdf.set_text_color(*primary_color) # Black
|
| 269 |
-
pdf.set_xy(25, current_y)
|
| 270 |
-
pdf.multi_cell(160, 8, text)
|
| 271 |
-
|
| 272 |
-
current_y = pdf.get_y()
|
| 273 |
-
pdf.ln(4) # Consistent space after subheading
|
| 274 |
-
current_y = pdf.get_y()
|
| 275 |
-
|
| 276 |
-
elif line.startswith("**") and line.endswith("**"): # Bold text that might be a heading
|
| 277 |
-
text = line.strip() # Keep ** markers
|
| 278 |
-
# Check if this looks like a main heading (no numbers, not too long)
|
| 279 |
-
if not re.match(r'^\d+\.', text) and len(text) < 50:
|
| 280 |
-
estimated_height = 22 # Heading + line + spacing
|
| 281 |
-
if current_y + estimated_height > 280:
|
| 282 |
-
pdf.add_page()
|
| 283 |
-
current_y = 25
|
| 284 |
-
|
| 285 |
-
pdf.set_font("Arial", "B", 18) # Bold, size 18
|
| 286 |
-
pdf.set_text_color(*primary_color) # Black
|
| 287 |
-
pdf.set_xy(25, current_y)
|
| 288 |
-
pdf.multi_cell(160, 9, text)
|
| 289 |
-
|
| 290 |
-
current_y = pdf.get_y()
|
| 291 |
-
pdf.ln(6) # Consistent space after heading
|
| 292 |
-
current_y = pdf.get_y()
|
| 293 |
-
else:
|
| 294 |
-
# Regular bold text
|
| 295 |
-
estimated_height = 12
|
| 296 |
-
if current_y + estimated_height > 280:
|
| 297 |
-
pdf.add_page()
|
| 298 |
-
current_y = 25
|
| 299 |
-
|
| 300 |
-
pdf.set_font("Arial", "B", 12) # Bold, size 12
|
| 301 |
-
pdf.set_text_color(*text_color) # Dark gray
|
| 302 |
-
pdf.set_xy(25, current_y)
|
| 303 |
-
pdf.multi_cell(160, 8, text)
|
| 304 |
-
current_y = pdf.get_y()
|
| 305 |
-
|
| 306 |
-
else: # Normal paragraph text
|
| 307 |
-
# For long paragraphs, estimate height based on text length
|
| 308 |
-
estimated_height = max(12, len(line) // 80 * 12) # Rough estimate
|
| 309 |
-
if current_y + estimated_height > 280:
|
| 310 |
-
pdf.add_page()
|
| 311 |
-
current_y = 25
|
| 312 |
-
|
| 313 |
-
pdf.set_font("Arial", "", 12) # Regular font, size 12
|
| 314 |
-
pdf.set_text_color(*text_color) # Dark gray
|
| 315 |
-
pdf.set_xy(25, current_y)
|
| 316 |
-
pdf.multi_cell(160, 8, line, align='L') # Left align to prevent text justification
|
| 317 |
-
current_y = pdf.get_y()
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
def render_markdown_to_pdf_grouped(pdf, content: str, primary_color, accent_color, text_color):
|
| 321 |
-
"""Render markdown content to PDF with subheading-content grouping and smart page breaks"""
|
| 322 |
-
if not content:
|
| 323 |
-
return
|
| 324 |
-
|
| 325 |
-
print(f"DEBUG: render_markdown_to_pdf_grouped called with content length: {len(content)}")
|
| 326 |
-
print(f"DEBUG: FULL CONTENT:")
|
| 327 |
-
print("=" * 80)
|
| 328 |
-
print(content)
|
| 329 |
-
print("=" * 80)
|
| 330 |
-
|
| 331 |
-
# Parse content into groups
|
| 332 |
-
groups = parse_content_into_groups(content)
|
| 333 |
-
|
| 334 |
-
for group in groups:
|
| 335 |
-
subheading = group['subheading']
|
| 336 |
-
group_content = group['content']
|
| 337 |
-
subheading_type = group['subheading_type']
|
| 338 |
-
|
| 339 |
-
# Get current Y position from PDF
|
| 340 |
-
current_y = pdf.get_y()
|
| 341 |
-
page_height = 280 # Approximate usable page height
|
| 342 |
-
|
| 343 |
-
# Use smart page break logic
|
| 344 |
-
if should_break_page_for_group(pdf, group, current_y, page_height):
|
| 345 |
-
pdf.add_page()
|
| 346 |
-
current_y = 25
|
| 347 |
-
pdf.set_y(current_y) # Set the PDF's Y position
|
| 348 |
-
|
| 349 |
-
# Render subheading based on type - using original styling
|
| 350 |
-
# Use the pre-cleaned text from the group
|
| 351 |
-
text = group['subheading_clean']
|
| 352 |
-
|
| 353 |
-
if subheading_type == 'h1':
|
| 354 |
-
pdf.set_font("Arial", "B", 20) # Bold, size 20 (original size)
|
| 355 |
-
pdf.set_text_color(*primary_color) # Black
|
| 356 |
-
pdf.set_xy(25, current_y)
|
| 357 |
-
pdf.multi_cell(160, 10, text)
|
| 358 |
-
|
| 359 |
-
# Calculate text width and make line autofit
|
| 360 |
-
text_width = pdf.get_string_width(text)
|
| 361 |
-
line_width = min(text_width + 12, 160) # Add 12mm padding, max 160mm
|
| 362 |
-
|
| 363 |
-
# Add subtle line under main heading - consistent with section titles
|
| 364 |
-
pdf.set_draw_color(221, 221, 221) # Lighter gray line (#DDDDDD)
|
| 365 |
-
pdf.set_line_width(0.3) # Thin line
|
| 366 |
-
pdf.line(25, current_y + 10, 25 + line_width, current_y + 10)
|
| 367 |
-
|
| 368 |
-
current_y = pdf.get_y()
|
| 369 |
-
pdf.ln(8) # Consistent space after main heading
|
| 370 |
-
current_y = pdf.get_y()
|
| 371 |
-
|
| 372 |
-
elif subheading_type == 'h2':
|
| 373 |
-
pdf.set_font("Arial", "B", 18) # Bold, size 18 (original size)
|
| 374 |
-
pdf.set_text_color(*primary_color) # Black
|
| 375 |
-
pdf.set_xy(25, current_y)
|
| 376 |
-
pdf.multi_cell(160, 9, text)
|
| 377 |
-
|
| 378 |
-
# Add underline (original styling)
|
| 379 |
-
text_width = pdf.get_string_width(text)
|
| 380 |
-
line_width = min(text_width + 12, 160)
|
| 381 |
-
pdf.set_draw_color(221, 221, 221) # Light gray line
|
| 382 |
-
pdf.set_line_width(0.3)
|
| 383 |
-
pdf.line(25, current_y + 9, 25 + line_width, current_y + 9)
|
| 384 |
-
|
| 385 |
-
current_y = pdf.get_y()
|
| 386 |
-
pdf.ln(6) # Space after subheading
|
| 387 |
-
current_y = pdf.get_y()
|
| 388 |
-
|
| 389 |
-
elif subheading_type == 'h3':
|
| 390 |
-
pdf.set_font("Arial", "B", 14) # Bold, size 14 (original size)
|
| 391 |
-
pdf.set_text_color(*primary_color) # Black
|
| 392 |
-
pdf.set_xy(25, current_y)
|
| 393 |
-
pdf.multi_cell(160, 8, text)
|
| 394 |
-
|
| 395 |
-
current_y = pdf.get_y()
|
| 396 |
-
pdf.ln(4) # Space after subheading
|
| 397 |
-
current_y = pdf.get_y()
|
| 398 |
-
|
| 399 |
-
elif subheading_type == 'h4':
|
| 400 |
-
pdf.set_font("Arial", "B", 12) # Bold, size 12 (original size)
|
| 401 |
-
pdf.set_text_color(*primary_color) # Black
|
| 402 |
-
pdf.set_xy(25, current_y)
|
| 403 |
-
pdf.multi_cell(160, 7, text)
|
| 404 |
-
|
| 405 |
-
current_y = pdf.get_y()
|
| 406 |
-
pdf.ln(3) # Space after subheading
|
| 407 |
-
current_y = pdf.get_y()
|
| 408 |
-
|
| 409 |
-
elif subheading_type == 'bold':
|
| 410 |
-
line_height_heading = 9 # For size 18 heading
|
| 411 |
-
line_height_text = 8 # For size 12 text
|
| 412 |
-
|
| 413 |
-
if not re.match(r'^\d+\.', text) and len(text) < 80:
|
| 414 |
-
# Main heading style
|
| 415 |
-
pdf.set_font("Arial", "B", 12) # Bold, size 12
|
| 416 |
-
pdf.set_text_color(*primary_color) # Black
|
| 417 |
-
pdf.set_xy(25, current_y)
|
| 418 |
-
pdf.multi_cell(160, 8, text) # Multi-cell for text
|
| 419 |
-
current_y = pdf.get_y() # Get updated Y position
|
| 420 |
-
pdf.ln(6)
|
| 421 |
-
current_y = pdf.get_y()
|
| 422 |
-
else:
|
| 423 |
-
# Regular bold text
|
| 424 |
-
pdf.set_font("Arial", "B", 12) # Bold, size 12
|
| 425 |
-
pdf.set_text_color(*text_color) # Dark gray
|
| 426 |
-
pdf.set_xy(25, current_y)
|
| 427 |
-
pdf.multi_cell(160, 8, text) # Multi-cell for text
|
| 428 |
-
current_y = pdf.get_y() # Get updated Y position
|
| 429 |
-
|
| 430 |
-
elif subheading_type == 'numbered':
|
| 431 |
-
pdf.set_font("Arial", "B", 18) # Bold, size 18 (original size)
|
| 432 |
-
pdf.set_text_color(*primary_color) # Black
|
| 433 |
-
pdf.set_xy(25, current_y)
|
| 434 |
-
pdf.multi_cell(160, 9, text)
|
| 435 |
-
|
| 436 |
-
# Add underline (original styling)
|
| 437 |
-
text_width = pdf.get_string_width(text)
|
| 438 |
-
line_width = min(text_width + 12, 160)
|
| 439 |
-
pdf.set_draw_color(221, 221, 221) # Light gray line
|
| 440 |
-
pdf.set_line_width(0.3)
|
| 441 |
-
pdf.line(25, current_y + 9, 25 + line_width, current_y + 9)
|
| 442 |
-
|
| 443 |
-
current_y = pdf.get_y()
|
| 444 |
-
pdf.ln(6) # Space after subheading
|
| 445 |
-
current_y = pdf.get_y()
|
| 446 |
-
|
| 447 |
-
# Render grouped content with simple styling
|
| 448 |
-
if group_content:
|
| 449 |
-
content_lines = group_content.split('\n')
|
| 450 |
-
for line in content_lines:
|
| 451 |
-
if line.strip():
|
| 452 |
-
# Check if line contains text ending with ":" that should be bold
|
| 453 |
-
if ':' in line:
|
| 454 |
-
# Split the line by ":" to separate the label from the content
|
| 455 |
-
parts = line.split(':', 1) # Split only on first ":"
|
| 456 |
-
if len(parts) == 2:
|
| 457 |
-
label = parts[0].strip()
|
| 458 |
-
content = parts[1].strip()
|
| 459 |
-
|
| 460 |
-
# Render label in bold
|
| 461 |
-
pdf.set_font("Arial", "B", 12) # Bold
|
| 462 |
-
pdf.set_text_color(*text_color)
|
| 463 |
-
pdf.set_xy(25, current_y)
|
| 464 |
-
pdf.multi_cell(160, 8, label + ":")
|
| 465 |
-
current_y = pdf.get_y()
|
| 466 |
-
|
| 467 |
-
# Render content in regular font
|
| 468 |
-
if content:
|
| 469 |
-
pdf.set_font("Arial", "", 12) # Regular
|
| 470 |
-
pdf.set_text_color(*text_color)
|
| 471 |
-
pdf.set_xy(25, current_y)
|
| 472 |
-
pdf.multi_cell(160, 8, content, align='L') # Left align to prevent text justification
|
| 473 |
-
current_y = pdf.get_y()
|
| 474 |
-
else:
|
| 475 |
-
# No content after ":", just render the whole line
|
| 476 |
-
pdf.set_font("Arial", "B", 12) # Bold for labels ending with ":"
|
| 477 |
-
pdf.set_text_color(*text_color)
|
| 478 |
-
pdf.set_xy(25, current_y)
|
| 479 |
-
pdf.multi_cell(160, 8, line, align='L') # Left align to prevent text justification
|
| 480 |
-
current_y = pdf.get_y()
|
| 481 |
-
elif ' - ' in line:
|
| 482 |
-
# Split the line by " - " to separate the label from the content
|
| 483 |
-
parts = line.split(' - ', 1) # Split only on first " - "
|
| 484 |
-
if len(parts) == 2:
|
| 485 |
-
label = parts[0].strip()
|
| 486 |
-
content = parts[1].strip()
|
| 487 |
-
|
| 488 |
-
# Render label in bold
|
| 489 |
-
pdf.set_font("Arial", "B", 12) # Bold
|
| 490 |
-
pdf.set_text_color(*text_color)
|
| 491 |
-
pdf.set_xy(25, current_y)
|
| 492 |
-
pdf.multi_cell(160, 8, label + " -")
|
| 493 |
-
current_y = pdf.get_y()
|
| 494 |
-
|
| 495 |
-
# Render content in regular font
|
| 496 |
-
if content:
|
| 497 |
-
pdf.set_font("Arial", "", 12) # Regular
|
| 498 |
-
pdf.set_text_color(*text_color)
|
| 499 |
-
pdf.set_xy(25, current_y)
|
| 500 |
-
pdf.multi_cell(160, 8, content, align='L') # Left align to prevent text justification
|
| 501 |
-
current_y = pdf.get_y()
|
| 502 |
-
else:
|
| 503 |
-
# No content after " - ", just render the whole line
|
| 504 |
-
pdf.set_font("Arial", "B", 12) # Bold for labels ending with " -"
|
| 505 |
-
pdf.set_text_color(*text_color)
|
| 506 |
-
pdf.set_xy(25, current_y)
|
| 507 |
-
pdf.multi_cell(160, 8, line, align='L') # Left align to prevent text justification
|
| 508 |
-
current_y = pdf.get_y()
|
| 509 |
-
else:
|
| 510 |
-
# Regular paragraph rendering without mixed text processing
|
| 511 |
-
pdf.set_font("Arial", "", 12)
|
| 512 |
-
pdf.set_text_color(*text_color)
|
| 513 |
-
pdf.set_xy(25, current_y)
|
| 514 |
-
pdf.multi_cell(160, 8, line, align='L') # Left align to prevent text justification
|
| 515 |
-
current_y = pdf.get_y() # Update current_y after each line
|
| 516 |
-
else:
|
| 517 |
-
# Empty line spacing
|
| 518 |
-
pdf.ln(3)
|
| 519 |
-
current_y = pdf.get_y() # Update current_y after spacing
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
def render_markdown_to_docx(doc, content: str):
|
| 523 |
-
"""Render markdown content to Word document with proper heading styling"""
|
| 524 |
-
if not content:
|
| 525 |
-
return
|
| 526 |
-
|
| 527 |
-
lines = content.split('\n')
|
| 528 |
-
|
| 529 |
-
for line in lines:
|
| 530 |
-
line = line.strip()
|
| 531 |
-
if not line:
|
| 532 |
-
doc.add_paragraph() # Empty paragraph for spacing
|
| 533 |
-
continue
|
| 534 |
-
|
| 535 |
-
# Handle different heading levels
|
| 536 |
-
if line.startswith("# "): # H1 - Main heading (like "Company Profile")
|
| 537 |
-
text = line[2:].strip()
|
| 538 |
-
doc.add_heading(text, level=1)
|
| 539 |
-
elif line.startswith("## ") or re.match(r'^\d+\.\d+\s+', line): # H2 - Subheading or numbered subheading
|
| 540 |
-
text = line[3:].strip() if line.startswith("## ") else line.strip()
|
| 541 |
-
doc.add_heading(text, level=2)
|
| 542 |
-
elif re.match(r'^\d+\.\d+\s+[A-Z]', line): # Numbered subheadings like "3.6 SWOT Analysis"
|
| 543 |
-
text = line.strip()
|
| 544 |
-
doc.add_heading(text, level=2)
|
| 545 |
-
elif line.startswith("### ") or line.startswith("#### "): # H3/H4 - Smaller subheadings
|
| 546 |
-
text = line[4:].strip() if line.startswith("### ") else line[5:].strip()
|
| 547 |
-
doc.add_heading(text, level=3)
|
| 548 |
-
elif line.startswith("**") and line.endswith("**"): # Bold text that might be a heading
|
| 549 |
-
text = line.strip() # Keep ** markers
|
| 550 |
-
# Check if this looks like a main heading (no numbers, not too long)
|
| 551 |
-
if not re.match(r'^\d+\.', text) and len(text) < 50:
|
| 552 |
-
doc.add_heading(text, level=1) # Treat as main heading
|
| 553 |
-
else:
|
| 554 |
-
# Regular bold text
|
| 555 |
-
doc.add_paragraph(text)
|
| 556 |
-
else: # Normal paragraph text
|
| 557 |
-
# Check if line contains text ending with ":" that should be bold
|
| 558 |
-
if ':' in line:
|
| 559 |
-
# Split the line by ":" to separate the label from the content
|
| 560 |
-
parts = line.split(':', 1) # Split only on first ":"
|
| 561 |
-
if len(parts) == 2:
|
| 562 |
-
label = parts[0].strip()
|
| 563 |
-
content = parts[1].strip()
|
| 564 |
-
|
| 565 |
-
# Create paragraph with mixed formatting
|
| 566 |
-
paragraph = doc.add_paragraph()
|
| 567 |
-
# Add label in bold
|
| 568 |
-
run = paragraph.add_run(label + ":")
|
| 569 |
-
run.bold = True
|
| 570 |
-
# Add content in regular font
|
| 571 |
-
if content:
|
| 572 |
-
run = paragraph.add_run(" " + content)
|
| 573 |
-
run.bold = False
|
| 574 |
-
else:
|
| 575 |
-
# No content after ":", just render the whole line in bold
|
| 576 |
-
paragraph = doc.add_paragraph(line)
|
| 577 |
-
paragraph.runs[0].bold = True
|
| 578 |
-
elif ' - ' in line:
|
| 579 |
-
# Split the line by " - " to separate the label from the content
|
| 580 |
-
parts = line.split(' - ', 1) # Split only on first " - "
|
| 581 |
-
if len(parts) == 2:
|
| 582 |
-
label = parts[0].strip()
|
| 583 |
-
content = parts[1].strip()
|
| 584 |
-
|
| 585 |
-
# Create paragraph with mixed formatting
|
| 586 |
-
paragraph = doc.add_paragraph()
|
| 587 |
-
# Add label in bold
|
| 588 |
-
run = paragraph.add_run(label + " -")
|
| 589 |
-
run.bold = True
|
| 590 |
-
# Add content in regular font
|
| 591 |
-
if content:
|
| 592 |
-
run = paragraph.add_run(" " + content)
|
| 593 |
-
run.bold = False
|
| 594 |
-
else:
|
| 595 |
-
# No content after " - ", just render the whole line in bold
|
| 596 |
-
paragraph = doc.add_paragraph(line)
|
| 597 |
-
paragraph.runs[0].bold = True
|
| 598 |
-
else:
|
| 599 |
-
doc.add_paragraph(line)
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
def render_markdown_to_docx_grouped(doc, content: str):
|
| 603 |
-
"""Render markdown content to Word document with subheading-content grouping and smart page breaks"""
|
| 604 |
-
if not content:
|
| 605 |
-
return
|
| 606 |
-
|
| 607 |
-
# Parse content into groups
|
| 608 |
-
groups = parse_content_into_groups(content)
|
| 609 |
-
|
| 610 |
-
for i, group in enumerate(groups):
|
| 611 |
-
subheading = group['subheading']
|
| 612 |
-
group_content = group['content']
|
| 613 |
-
subheading_type = group['subheading_type']
|
| 614 |
-
|
| 615 |
-
# Add page break before group if it's not the first group and the previous group was large
|
| 616 |
-
if i > 0:
|
| 617 |
-
# Check if we should add a page break to keep groups together
|
| 618 |
-
# This is a simple heuristic - in Word, we rely more on the natural flow
|
| 619 |
-
# but we can add manual page breaks for very large groups
|
| 620 |
-
if group['content_length'] > 1000: # Large group threshold
|
| 621 |
-
doc.add_page_break()
|
| 622 |
-
|
| 623 |
-
# Render subheading based on type - using original styling
|
| 624 |
-
# Use the pre-cleaned text from the group
|
| 625 |
-
text = group['subheading_clean']
|
| 626 |
-
|
| 627 |
-
if subheading_type == 'h1':
|
| 628 |
-
doc.add_heading(text, level=1)
|
| 629 |
-
elif subheading_type == 'h2':
|
| 630 |
-
doc.add_heading(text, level=2)
|
| 631 |
-
elif subheading_type == 'h3':
|
| 632 |
-
doc.add_heading(text, level=3)
|
| 633 |
-
elif subheading_type == 'h4':
|
| 634 |
-
doc.add_heading(text, level=4)
|
| 635 |
-
elif subheading_type == 'bold':
|
| 636 |
-
if not re.match(r'^\d+\.', text) and len(text) < 80:
|
| 637 |
-
# Simple paragraph with bold text
|
| 638 |
-
paragraph = doc.add_paragraph(text)
|
| 639 |
-
paragraph.style = doc.styles['Heading 1'] # Apply heading style
|
| 640 |
-
else:
|
| 641 |
-
# Regular paragraph
|
| 642 |
-
doc.add_paragraph(text)
|
| 643 |
-
elif subheading_type == 'numbered':
|
| 644 |
-
doc.add_heading(text, level=2)
|
| 645 |
-
|
| 646 |
-
# Render grouped content with simple styling
|
| 647 |
-
if group_content:
|
| 648 |
-
content_lines = group_content.split('\n')
|
| 649 |
-
for line in content_lines:
|
| 650 |
-
if line.strip():
|
| 651 |
-
# Check if line contains text ending with ":" that should be bold
|
| 652 |
-
if ':' in line:
|
| 653 |
-
# Split the line by ":" to separate the label from the content
|
| 654 |
-
parts = line.split(':', 1) # Split only on first ":"
|
| 655 |
-
if len(parts) == 2:
|
| 656 |
-
label = parts[0].strip()
|
| 657 |
-
content = parts[1].strip()
|
| 658 |
-
|
| 659 |
-
# Create paragraph with mixed formatting
|
| 660 |
-
paragraph = doc.add_paragraph()
|
| 661 |
-
# Add label in bold
|
| 662 |
-
run = paragraph.add_run(label + ":")
|
| 663 |
-
run.bold = True
|
| 664 |
-
# Add content in regular font
|
| 665 |
-
if content:
|
| 666 |
-
run = paragraph.add_run(" " + content)
|
| 667 |
-
run.bold = False
|
| 668 |
-
else:
|
| 669 |
-
# No content after ":", just render the whole line in bold
|
| 670 |
-
paragraph = doc.add_paragraph(line)
|
| 671 |
-
paragraph.runs[0].bold = True
|
| 672 |
-
elif ' - ' in line:
|
| 673 |
-
# Split the line by " - " to separate the label from the content
|
| 674 |
-
parts = line.split(' - ', 1) # Split only on first " - "
|
| 675 |
-
if len(parts) == 2:
|
| 676 |
-
label = parts[0].strip()
|
| 677 |
-
content = parts[1].strip()
|
| 678 |
-
|
| 679 |
-
# Create paragraph with mixed formatting
|
| 680 |
-
paragraph = doc.add_paragraph()
|
| 681 |
-
# Add label in bold
|
| 682 |
-
run = paragraph.add_run(label + " -")
|
| 683 |
-
run.bold = True
|
| 684 |
-
# Add content in regular font
|
| 685 |
-
if content:
|
| 686 |
-
run = paragraph.add_run(" " + content)
|
| 687 |
-
run.bold = False
|
| 688 |
-
else:
|
| 689 |
-
# No content after " - ", just render the whole line in bold
|
| 690 |
-
paragraph = doc.add_paragraph(line)
|
| 691 |
-
paragraph.runs[0].bold = True
|
| 692 |
-
else:
|
| 693 |
-
# Simple paragraph addition without mixed text processing
|
| 694 |
-
doc.add_paragraph(line)
|
| 695 |
-
else:
|
| 696 |
-
doc.add_paragraph() # Empty paragraph for spacing
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
def clean_text_for_docx(text: str) -> str:
|
| 701 |
-
# Replace problematic Unicode characters with ASCII equivalents
|
| 702 |
-
unicode_replacements = {
|
| 703 |
-
'\u2019': "'", # Right single quotation mark
|
| 704 |
-
'\u2018': "'", # Left single quotation mark
|
| 705 |
-
'\u201C': '"', # Left double quotation mark
|
| 706 |
-
'\u201D': '"', # Right double quotation mark
|
| 707 |
-
'\u2013': '-', # En dash
|
| 708 |
-
'\u2014': '--', # Em dash
|
| 709 |
-
'\u2022': '•', # Bullet
|
| 710 |
-
'\u2026': '...', # Horizontal ellipsis
|
| 711 |
-
'\u00A0': ' ', # Non-breaking space
|
| 712 |
-
'\u00B0': '°', # Degree sign
|
| 713 |
-
'\u00AE': '(R)', # Registered trademark
|
| 714 |
-
'\u2122': '(TM)', # Trademark
|
| 715 |
-
'\u00A9': '(C)', # Copyright
|
| 716 |
-
}
|
| 717 |
-
|
| 718 |
-
for unicode_char, replacement in unicode_replacements.items():
|
| 719 |
-
text = text.replace(unicode_char, replacement)
|
| 720 |
-
|
| 721 |
-
# Simple text cleaning - keep basic punctuation and common symbols
|
| 722 |
-
text = ''.join(char for char in text if ord(char) < 128 or char in '•°$€£¥₹₩₽₪₺₴₼₸₾֏₲₡₣₦₵₨₱₫₭៛৳؋﷼%')
|
| 723 |
-
|
| 724 |
-
return text
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
def clean_unicode_for_pdf(text: str) -> str:
|
| 735 |
-
# Replace problematic Unicode characters with ASCII equivalents
|
| 736 |
-
unicode_replacements = {
|
| 737 |
-
'\u2019': "'", # Right single quotation mark
|
| 738 |
-
'\u2018': "'", # Left single quotation mark
|
| 739 |
-
'\u201C': '"', # Left double quotation mark
|
| 740 |
-
'\u201D': '"', # Right double quotation mark
|
| 741 |
-
'\u2013': '-', # En dash
|
| 742 |
-
'\u2014': '--', # Em dash
|
| 743 |
-
'\u2022': '•', # Bullet
|
| 744 |
-
'\u2026': '...', # Horizontal ellipsis
|
| 745 |
-
'\u00A0': ' ', # Non-breaking space
|
| 746 |
-
'\u00B0': '°', # Degree sign
|
| 747 |
-
'\u00AE': '(R)', # Registered trademark
|
| 748 |
-
'\u2122': '(TM)', # Trademark
|
| 749 |
-
'\u00A9': '(C)', # Copyright
|
| 750 |
-
}
|
| 751 |
-
|
| 752 |
-
for unicode_char, replacement in unicode_replacements.items():
|
| 753 |
-
text = text.replace(unicode_char, replacement)
|
| 754 |
-
|
| 755 |
-
# Simple text cleaning - keep basic punctuation and common symbols
|
| 756 |
-
text = ''.join(char for char in text if ord(char) < 128 or char in '•°$€£¥₹₩₽₪₺₴₼₸₾֏₲₡₣₦₵₨₱₫₭៛৳؋﷼%')
|
| 757 |
-
|
| 758 |
-
return text
|
| 759 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/DocumentGeneration/mixed.py
DELETED
|
@@ -1,6 +0,0 @@
|
|
| 1 |
-
# This file has been removed as all its functions are not present in maintest.txt
|
| 2 |
-
# Functions removed:
|
| 3 |
-
# - make_sentences_ending_with_colon_bold()
|
| 4 |
-
# - _render_mixed_text_to_pdf()
|
| 5 |
-
# - clean_bold_text_for_pdf()
|
| 6 |
-
# - process_bold_text_for_docx()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/DocumentGeneration/pdf_generator.py
DELETED
|
@@ -1,682 +0,0 @@
|
|
| 1 |
-
# PDF Generator Module
|
| 2 |
-
# Contains create_pdf_document() and all PDF-related functions
|
| 3 |
-
|
| 4 |
-
import re
|
| 5 |
-
import os
|
| 6 |
-
import tempfile
|
| 7 |
-
import time
|
| 8 |
-
import sys
|
| 9 |
-
from typing import List, Dict, Any
|
| 10 |
-
|
| 11 |
-
# Add the parent directory to sys.path to import from models.py
|
| 12 |
-
sys.path.append(os.path.dirname(os.path.dirname(__file__)))
|
| 13 |
-
from models import ExportRequest
|
| 14 |
-
|
| 15 |
-
# Import from the same directory (Document Generation)
|
| 16 |
-
from document_helpers import (
|
| 17 |
-
filter_empty_sections,
|
| 18 |
-
extract_business_name_from_content,
|
| 19 |
-
remove_duplicate_headings
|
| 20 |
-
)
|
| 21 |
-
from table_of_content import generate_table_of_contents_after_content, add_table_of_contents_page
|
| 22 |
-
from chart_generator import parse_embedded_chart_data, create_chart_from_data
|
| 23 |
-
from markdown_renderer import (
|
| 24 |
-
clean_text_for_pdf,
|
| 25 |
-
render_markdown_to_pdf_grouped
|
| 26 |
-
)
|
| 27 |
-
|
| 28 |
-
# Add Currency Mapping path
|
| 29 |
-
currency_mapping_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'Currency Mapping')
|
| 30 |
-
if currency_mapping_path not in sys.path:
|
| 31 |
-
sys.path.append(currency_mapping_path)
|
| 32 |
-
from CurrencyMapping.currency_mapping import convert_currency_symbols_to_iso
|
| 33 |
-
|
| 34 |
-
# Import sort_sections_by_order from utils module
|
| 35 |
-
from utils import sort_sections_by_order
|
| 36 |
-
|
| 37 |
-
def calculate_real_page_numbers(request: ExportRequest, filtered_sections):
|
| 38 |
-
"""Calculate real page numbers by creating a temporary PDF with all content INCLUDING charts"""
|
| 39 |
-
try:
|
| 40 |
-
from fpdf import FPDF
|
| 41 |
-
except ImportError:
|
| 42 |
-
raise Exception("fpdf2 is not installed. Please install it with: pip install fpdf2")
|
| 43 |
-
|
| 44 |
-
# Create temporary PDF to calculate page numbers
|
| 45 |
-
temp_pdf = FPDF()
|
| 46 |
-
temp_pdf.set_auto_page_break(auto=True, margin=15)
|
| 47 |
-
|
| 48 |
-
# Add cover page
|
| 49 |
-
temp_pdf.add_page()
|
| 50 |
-
|
| 51 |
-
# Add TOC page (placeholder)
|
| 52 |
-
temp_pdf.add_page()
|
| 53 |
-
|
| 54 |
-
# Parse charts from embedded chart data in section content
|
| 55 |
-
embedded_charts = parse_embedded_chart_data(filtered_sections)
|
| 56 |
-
|
| 57 |
-
# Create charts and map them to sections
|
| 58 |
-
chart_info_map = {}
|
| 59 |
-
if embedded_charts:
|
| 60 |
-
for chart_info in embedded_charts:
|
| 61 |
-
chart_bytes = create_chart_from_data(chart_info, request.businessIdea or "Business")
|
| 62 |
-
if chart_bytes:
|
| 63 |
-
source_section = chart_info["source_section"]
|
| 64 |
-
if source_section not in chart_info_map:
|
| 65 |
-
chart_info_map[source_section] = []
|
| 66 |
-
chart_info_map[source_section].append({
|
| 67 |
-
"chart_bytes": chart_bytes,
|
| 68 |
-
"title": chart_info["title"],
|
| 69 |
-
"type": chart_info["type"]
|
| 70 |
-
})
|
| 71 |
-
|
| 72 |
-
# Add all sections to calculate real page numbers INCLUDING charts
|
| 73 |
-
section_page_map = {}
|
| 74 |
-
sorted_sections = sort_sections_by_order(filtered_sections)
|
| 75 |
-
|
| 76 |
-
for section in sorted_sections:
|
| 77 |
-
temp_pdf.add_page()
|
| 78 |
-
current_page = temp_pdf.page_no()
|
| 79 |
-
section_page_map[section.title] = current_page
|
| 80 |
-
|
| 81 |
-
# Don't add section title manually - let the markdown content handle headings
|
| 82 |
-
|
| 83 |
-
# Add section content
|
| 84 |
-
temp_pdf.set_font("Arial", "", 12)
|
| 85 |
-
temp_pdf.set_text_color(51, 51, 51)
|
| 86 |
-
temp_pdf.set_xy(25, temp_pdf.get_y())
|
| 87 |
-
|
| 88 |
-
# Process content
|
| 89 |
-
try:
|
| 90 |
-
content_text = clean_text_for_pdf(section.content)
|
| 91 |
-
except Exception as e:
|
| 92 |
-
content_text = section.content or ""
|
| 93 |
-
|
| 94 |
-
# Remove chart data markers
|
| 95 |
-
content_text = re.sub(r'<!--CHARTDATASTART-->.*?<!--CHARTDATAEND-->', '', content_text, flags=re.DOTALL)
|
| 96 |
-
content_text = re.sub(r'<!--CHARTDATASTART-->.*$', '', content_text, flags=re.DOTALL)
|
| 97 |
-
|
| 98 |
-
# No need to filter section titles since we're not adding them manually
|
| 99 |
-
|
| 100 |
-
# Add content with proper markdown rendering
|
| 101 |
-
if content_text and content_text.strip():
|
| 102 |
-
render_markdown_to_pdf_grouped(temp_pdf, content_text, (0,0,0), (75,0,130), (51,51,51))
|
| 103 |
-
|
| 104 |
-
# Add charts for this section (same logic as in actual PDF generation)
|
| 105 |
-
section_charts = chart_info_map.get(section.title, [])
|
| 106 |
-
|
| 107 |
-
# Try different matching strategies for charts
|
| 108 |
-
if not section_charts:
|
| 109 |
-
# Case-insensitive match
|
| 110 |
-
for chart_source, charts in chart_info_map.items():
|
| 111 |
-
if chart_source.lower() == section.title.lower():
|
| 112 |
-
section_charts = charts
|
| 113 |
-
break
|
| 114 |
-
|
| 115 |
-
# Partial match
|
| 116 |
-
if not section_charts:
|
| 117 |
-
for chart_source, charts in chart_info_map.items():
|
| 118 |
-
if (section.title.lower() in chart_source.lower() or
|
| 119 |
-
chart_source.lower() in section.title.lower()):
|
| 120 |
-
section_charts = charts
|
| 121 |
-
break
|
| 122 |
-
|
| 123 |
-
# Key-based match
|
| 124 |
-
if not section_charts and hasattr(section, 'key'):
|
| 125 |
-
for chart_source, charts in chart_info_map.items():
|
| 126 |
-
if section.key and section.key.replace('prompt_', '') in chart_source.lower():
|
| 127 |
-
section_charts = charts
|
| 128 |
-
break
|
| 129 |
-
|
| 130 |
-
# Add charts with same page break logic as actual PDF
|
| 131 |
-
if section_charts:
|
| 132 |
-
charts_per_page = 0
|
| 133 |
-
max_charts_per_page = 5
|
| 134 |
-
|
| 135 |
-
for chart_info in section_charts:
|
| 136 |
-
try:
|
| 137 |
-
chart_height_needed = 140 # Chart height (120) + title (20) + minimal spacing
|
| 138 |
-
current_y = temp_pdf.get_y()
|
| 139 |
-
charts_per_page += 1
|
| 140 |
-
|
| 141 |
-
# Only force page break if absolutely necessary
|
| 142 |
-
if (current_y + chart_height_needed > 290) or (charts_per_page > 5):
|
| 143 |
-
temp_pdf.add_page()
|
| 144 |
-
y_title = 25
|
| 145 |
-
charts_per_page = 1
|
| 146 |
-
else:
|
| 147 |
-
y_title = current_y
|
| 148 |
-
|
| 149 |
-
# Add minimal spacing before chart if not at top of page
|
| 150 |
-
if y_title > 25:
|
| 151 |
-
temp_pdf.ln(3)
|
| 152 |
-
|
| 153 |
-
# Chart title is already included in the chart itself, no need for separate title
|
| 154 |
-
|
| 155 |
-
# Add chart image
|
| 156 |
-
img_width = 160 # Much larger chart width
|
| 157 |
-
x = (210 - img_width) / 2
|
| 158 |
-
y_img = y_title
|
| 159 |
-
|
| 160 |
-
# Save image to temp file
|
| 161 |
-
with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as tmp_img:
|
| 162 |
-
tmp_img.write(chart_info["chart_bytes"])
|
| 163 |
-
temp_img_path = tmp_img.name
|
| 164 |
-
|
| 165 |
-
# Add chart
|
| 166 |
-
chart_height = 120 # Much larger chart height
|
| 167 |
-
temp_pdf.image(temp_img_path, x=x, y=y_img, w=img_width)
|
| 168 |
-
temp_pdf.ln(chart_height + 5)
|
| 169 |
-
|
| 170 |
-
# Clean up temp file
|
| 171 |
-
try:
|
| 172 |
-
os.remove(temp_img_path)
|
| 173 |
-
except Exception:
|
| 174 |
-
pass
|
| 175 |
-
|
| 176 |
-
except Exception:
|
| 177 |
-
# Skip faulty chart and continue
|
| 178 |
-
continue
|
| 179 |
-
|
| 180 |
-
return section_page_map
|
| 181 |
-
|
| 182 |
-
def create_pdf_document(request: ExportRequest):
|
| 183 |
-
"""Create a PROFESSIONAL PDF document from the business plan data using FPDF2
|
| 184 |
-
NOTE: Now uses matplotlib charts exclusively - old base64 chart logic commented out"""
|
| 185 |
-
try:
|
| 186 |
-
from fpdf import FPDF
|
| 187 |
-
except ImportError:
|
| 188 |
-
raise Exception("fpdf2 is not installed. Please install it with: pip install fpdf2")
|
| 189 |
-
|
| 190 |
-
# Filter out empty sections to prevent blank pages
|
| 191 |
-
try:
|
| 192 |
-
filtered_sections = filter_empty_sections(request.sections)
|
| 193 |
-
except Exception as e:
|
| 194 |
-
raise Exception(f"Failed to filter sections: {str(e)}")
|
| 195 |
-
if not filtered_sections:
|
| 196 |
-
raise Exception("No valid sections found after filtering. All sections appear to be empty.")
|
| 197 |
-
|
| 198 |
-
# First pass: Calculate real page numbers by creating a temporary PDF
|
| 199 |
-
section_page_map = calculate_real_page_numbers(request, filtered_sections)
|
| 200 |
-
|
| 201 |
-
# Generate TOC with real page numbers
|
| 202 |
-
toc_items = generate_table_of_contents_after_content(filtered_sections, section_page_map, request.businessIdea)
|
| 203 |
-
|
| 204 |
-
# Second pass: Create final PDF with TOC on page 2
|
| 205 |
-
pdf = FPDF()
|
| 206 |
-
pdf.set_auto_page_break(auto=True, margin=15) # Reduced margin to allow more charts per page
|
| 207 |
-
|
| 208 |
-
# Add confidentiality footer to every page
|
| 209 |
-
def add_confidentiality_footer(pdf):
|
| 210 |
-
pdf.set_font("Arial", "", 8)
|
| 211 |
-
pdf.set_text_color(100, 100, 100) # Gray text
|
| 212 |
-
pdf.set_y(-15) # 15mm from bottom
|
| 213 |
-
pdf.cell(0, 5, "CONFIDENTIAL - This document contains proprietary information and may not be shared without consent", 0, 0, 'C')
|
| 214 |
-
|
| 215 |
-
# Override the footer method
|
| 216 |
-
pdf.footer = lambda: add_confidentiality_footer(pdf)
|
| 217 |
-
pdf.add_page()
|
| 218 |
-
|
| 219 |
-
# Professional color scheme - Refined for bank/investor presentation
|
| 220 |
-
primary_color = (0, 0, 0) # Black - for headings
|
| 221 |
-
accent_color = (75, 0, 130) # Navy purple - for accent lines and highlights
|
| 222 |
-
text_color = (51, 51, 51) # Dark gray - for body text
|
| 223 |
-
light_gray = (221, 221, 221) # Lighter gray - for subtle lines (#DDDDDD)
|
| 224 |
-
navy_color = (25, 25, 112) # Navy blue - for TOC and emphasis
|
| 225 |
-
|
| 226 |
-
# Professional fonts - Arial for body, optional serif for main titles
|
| 227 |
-
title_font = 'Arial' # Keep Arial for consistency
|
| 228 |
-
body_font = 'Arial' # Modern business feel
|
| 229 |
-
|
| 230 |
-
# Helper functions for consistent styling
|
| 231 |
-
def add_header(pdf, text, size=24, y_offset=25, primary_color=(0,0,0), accent_color=(75,0,130)):
|
| 232 |
-
pdf.set_font("Arial", "B", size)
|
| 233 |
-
pdf.set_text_color(*primary_color)
|
| 234 |
-
pdf.set_xy(25, y_offset)
|
| 235 |
-
pdf.cell(0, 18, text, ln=True)
|
| 236 |
-
|
| 237 |
-
# Calculate text width and make line autofit
|
| 238 |
-
text_width = pdf.get_string_width(text)
|
| 239 |
-
line_width = min(text_width + 10, 160) # Add 10mm padding, max 160mm (page width - margins)
|
| 240 |
-
|
| 241 |
-
# Removed purple accent line
|
| 242 |
-
return y_offset + 25
|
| 243 |
-
|
| 244 |
-
def add_subheader(pdf, text, size=16, y_offset=None, primary_color=(0,0,0)):
|
| 245 |
-
if y_offset is None:
|
| 246 |
-
y_offset = pdf.get_y() + 10
|
| 247 |
-
pdf.set_font("Arial", "B", size)
|
| 248 |
-
pdf.set_text_color(*primary_color)
|
| 249 |
-
pdf.set_xy(25, y_offset)
|
| 250 |
-
pdf.cell(0, 14, text, ln=True)
|
| 251 |
-
|
| 252 |
-
# Calculate text width and make line autofit
|
| 253 |
-
text_width = pdf.get_string_width(text)
|
| 254 |
-
line_width = min(text_width + 8, 160) # Add 8mm padding, max 160mm (page width - margins)
|
| 255 |
-
|
| 256 |
-
pdf.set_draw_color(221, 221, 221) # Lighter gray (#DDDDDD) for subtle lines
|
| 257 |
-
pdf.set_line_width(0.3) # Thinner line
|
| 258 |
-
pdf.line(25, y_offset + 14, 25 + line_width, y_offset + 14)
|
| 259 |
-
return y_offset + 20
|
| 260 |
-
|
| 261 |
-
def add_body_text(pdf, text, y_offset=None, text_color=(51,51,51)):
|
| 262 |
-
if y_offset is None:
|
| 263 |
-
y_offset = pdf.get_y() + 5
|
| 264 |
-
pdf.set_font("Arial", "", 12)
|
| 265 |
-
pdf.set_text_color(*text_color)
|
| 266 |
-
pdf.set_xy(25, y_offset)
|
| 267 |
-
pdf.multi_cell(160, 8, text, align='L') # Left align to prevent text justification
|
| 268 |
-
return pdf.get_y()
|
| 269 |
-
|
| 270 |
-
def check_page_break(pdf, required_height=120, bottom_margin=300):
|
| 271 |
-
"""Check if we need a page break and return the Y position to use"""
|
| 272 |
-
y = pdf.get_y()
|
| 273 |
-
if y + required_height > bottom_margin:
|
| 274 |
-
pdf.add_page()
|
| 275 |
-
return 25
|
| 276 |
-
return y
|
| 277 |
-
|
| 278 |
-
# ===== COVER PAGE =====
|
| 279 |
-
def add_cover_page():
|
| 280 |
-
# Clean white background - no purple background
|
| 281 |
-
|
| 282 |
-
# Add company logo at the top
|
| 283 |
-
try:
|
| 284 |
-
# Use the MBD logo PNG file
|
| 285 |
-
project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
| 286 |
-
logo_path = os.path.join(project_root, 'assets', 'logos', 'MBD logo.png')
|
| 287 |
-
|
| 288 |
-
if os.path.exists(logo_path):
|
| 289 |
-
# Add logo to PDF (centered, top of page) - LOGO FIRST, THEN TITLE BELOW
|
| 290 |
-
# Center the logo: (210 - 70) / 2 = 70, so logo starts at x=70
|
| 291 |
-
pdf.image(logo_path, x=70, y=20, w=70) # Even larger size to show full logo
|
| 292 |
-
print(f"Logo added successfully from: {logo_path}")
|
| 293 |
-
else:
|
| 294 |
-
print(f"Logo file not found at: {logo_path}")
|
| 295 |
-
except Exception as e:
|
| 296 |
-
# Log any errors with logo
|
| 297 |
-
print(f"Logo error: {e}")
|
| 298 |
-
pass
|
| 299 |
-
|
| 300 |
-
# Extract business name from content, fallback to businessIdea
|
| 301 |
-
extracted_business_name = extract_business_name_from_content(filtered_sections)
|
| 302 |
-
business_name = extracted_business_name or request.businessIdea or "Business Plan"
|
| 303 |
-
|
| 304 |
-
# Company/Project name - DARK TEXT (moved down below logo)
|
| 305 |
-
pdf.set_font(title_font, "B", 32)
|
| 306 |
-
pdf.set_text_color(0, 0, 0) # Black text for visibility on white background
|
| 307 |
-
pdf.ln(60) # More space to move title below logo
|
| 308 |
-
pdf.cell(0, 25, business_name, ln=True, align='C')
|
| 309 |
-
|
| 310 |
-
# Subtitle - DARK GRAY, BOLD, NO ITALICS
|
| 311 |
-
pdf.set_font(title_font, "B", 16) # Reduced from 18pt, removed italics
|
| 312 |
-
pdf.set_text_color(51, 51, 51) # Dark gray for visibility on white background
|
| 313 |
-
pdf.cell(0, 12, "Business Plan", ln=True, align='C')
|
| 314 |
-
|
| 315 |
-
# Add some space
|
| 316 |
-
pdf.ln(40)
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
pdf.set_font(body_font, "", 12)
|
| 320 |
-
pdf.set_text_color(51, 51, 51) # Dark text for content
|
| 321 |
-
pdf.set_xy(35, 145)
|
| 322 |
-
pdf.cell(0, 8, f"Created: {time.strftime('%B %d, %Y')}", ln=True)
|
| 323 |
-
pdf.set_xy(35, 153)
|
| 324 |
-
pdf.cell(0, 8, f"Format: {request.format.upper()}", ln=True)
|
| 325 |
-
pdf.set_xy(35, 161)
|
| 326 |
-
pdf.cell(0, 8, f"Sections: {len(request.sections)}", ln=True)
|
| 327 |
-
|
| 328 |
-
# Footer - simple footer since confidentiality is now in page footer
|
| 329 |
-
pdf.set_font(body_font, "", 10)
|
| 330 |
-
pdf.set_text_color(51, 51, 51) # Dark gray text for visibility on white background
|
| 331 |
-
pdf.set_xy(0, 280)
|
| 332 |
-
pdf.cell(0, 8, "Business Plan Document", ln=True, align='C')
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
# ===== SECTION PAGES =====
|
| 337 |
-
def add_sections_with_charts():
|
| 338 |
-
# Parse charts from embedded chart data in section content
|
| 339 |
-
embedded_charts = parse_embedded_chart_data(filtered_sections)
|
| 340 |
-
|
| 341 |
-
if embedded_charts:
|
| 342 |
-
# Create actual charts from the parsed data
|
| 343 |
-
matplotlib_charts = []
|
| 344 |
-
chart_info_map = {} # Map section names to their charts
|
| 345 |
-
|
| 346 |
-
# logging.info(f"Chart info map keys: {list(chart_info_map.keys())}")
|
| 347 |
-
|
| 348 |
-
for chart_info in embedded_charts:
|
| 349 |
-
chart_bytes = create_chart_from_data(chart_info, request.businessIdea or "Business")
|
| 350 |
-
if chart_bytes:
|
| 351 |
-
matplotlib_charts.append(chart_bytes)
|
| 352 |
-
# Map this chart to its source section
|
| 353 |
-
source_section = chart_info["source_section"]
|
| 354 |
-
# logging.info(f"Chart '{chart_info['title']}' has source_section: '{source_section}'")
|
| 355 |
-
|
| 356 |
-
if source_section not in chart_info_map:
|
| 357 |
-
chart_info_map[source_section] = []
|
| 358 |
-
chart_info_map[source_section].append({
|
| 359 |
-
"chart_bytes": chart_bytes,
|
| 360 |
-
"title": chart_info["title"],
|
| 361 |
-
"type": chart_info["type"]
|
| 362 |
-
})
|
| 363 |
-
# logging.info(f"Mapping chart '{chart_info['title']}' to source section: '{source_section}'")
|
| 364 |
-
|
| 365 |
-
# CRITICAL DEBUG: Show the exact mapping being created
|
| 366 |
-
# logging.info(f" -> Chart '{chart_info['title']}' mapped to key '{source_section}' in chart_info_map")
|
| 367 |
-
else:
|
| 368 |
-
pass
|
| 369 |
-
|
| 370 |
-
# logging.info(f"Total charts created: {len(matplotlib_charts)}")
|
| 371 |
-
# logging.info(f"Final chart_info_map: {chart_info_map}")
|
| 372 |
-
|
| 373 |
-
# Debug: Show all chart mappings
|
| 374 |
-
for source_section, charts in chart_info_map.items():
|
| 375 |
-
# logging.info(f"Source section '{source_section}' has {len(charts)} charts:")
|
| 376 |
-
for chart in charts:
|
| 377 |
-
# logging.info(f" - {chart['title']} ({chart['type']})")
|
| 378 |
-
pass
|
| 379 |
-
|
| 380 |
-
else:
|
| 381 |
-
matplotlib_charts = []
|
| 382 |
-
chart_info_map = {}
|
| 383 |
-
|
| 384 |
-
total_charts_placed = 0
|
| 385 |
-
|
| 386 |
-
# Sort sections according to predefined order
|
| 387 |
-
sorted_sections = sort_sections_by_order(filtered_sections)
|
| 388 |
-
# logging.info(f"Section titles in sorted order: {[s.title for s in sorted_sections]}")
|
| 389 |
-
|
| 390 |
-
# CRITICAL DEBUG: Show exact section titles and their keys
|
| 391 |
-
for i, section in enumerate(sorted_sections):
|
| 392 |
-
# logging.info(f"Section {i+1}: '{section.title}' (key: {getattr(section, 'key', 'NO_KEY')})")
|
| 393 |
-
pass
|
| 394 |
-
|
| 395 |
-
for i, section in enumerate(sorted_sections):
|
| 396 |
-
|
| 397 |
-
# Always start each section on a new page
|
| 398 |
-
pdf.add_page()
|
| 399 |
-
|
| 400 |
-
# Don't add section title manually - let the markdown content handle headings
|
| 401 |
-
|
| 402 |
-
# Section content (properly positioned)
|
| 403 |
-
pdf.set_font(body_font, "", 12)
|
| 404 |
-
pdf.set_text_color(*text_color)
|
| 405 |
-
pdf.set_xy(25, pdf.get_y()) # Use current Y position for consistent spacing
|
| 406 |
-
|
| 407 |
-
# Process content
|
| 408 |
-
try:
|
| 409 |
-
content_text = clean_text_for_pdf(section.content)
|
| 410 |
-
except Exception as e:
|
| 411 |
-
content_text = section.content or ""
|
| 412 |
-
try:
|
| 413 |
-
content_text = convert_currency_symbols_to_iso(content_text)
|
| 414 |
-
except Exception:
|
| 415 |
-
# If currency conversion fails, keep original text
|
| 416 |
-
pass
|
| 417 |
-
|
| 418 |
-
# Remove chart data markers
|
| 419 |
-
content_text = re.sub(r'<!--CHARTDATASTART-->.*?<!--CHARTDATAEND-->', '', content_text, flags=re.DOTALL)
|
| 420 |
-
content_text = re.sub(r'<!--CHARTDATASTART-->.*$', '', content_text, flags=re.DOTALL)
|
| 421 |
-
|
| 422 |
-
# Remove chart titles from content to prevent duplicate headings
|
| 423 |
-
# Get chart titles for this section
|
| 424 |
-
section_charts = chart_info_map.get(section.title, [])
|
| 425 |
-
if not section_charts:
|
| 426 |
-
# Try case-insensitive match
|
| 427 |
-
for chart_source, charts in chart_info_map.items():
|
| 428 |
-
if chart_source.lower() == section.title.lower():
|
| 429 |
-
section_charts = charts
|
| 430 |
-
break
|
| 431 |
-
|
| 432 |
-
# Remove chart titles from content
|
| 433 |
-
for chart_info in section_charts:
|
| 434 |
-
chart_title = chart_info["title"]
|
| 435 |
-
# Remove various markdown heading formats for the chart title
|
| 436 |
-
patterns_to_remove = [
|
| 437 |
-
rf'^#+\s*{re.escape(chart_title)}\s*$', # # Title, ## Title, etc.
|
| 438 |
-
rf'^\*\*{re.escape(chart_title)}\*\*\s*$', # **Title**
|
| 439 |
-
rf'^{re.escape(chart_title)}\s*$', # Just the title
|
| 440 |
-
]
|
| 441 |
-
for pattern in patterns_to_remove:
|
| 442 |
-
content_text = re.sub(pattern, '', content_text, flags=re.MULTILINE | re.IGNORECASE)
|
| 443 |
-
|
| 444 |
-
# No need to filter section titles since we're not adding them manually
|
| 445 |
-
|
| 446 |
-
# Skip enhance_subheadings_with_bold for grouped rendering to preserve ** markers
|
| 447 |
-
# content_text = enhance_subheadings_with_bold(content_text)
|
| 448 |
-
content_text = remove_duplicate_headings(content_text)
|
| 449 |
-
|
| 450 |
-
# Add content with proper markdown rendering using grouped approach
|
| 451 |
-
if content_text and content_text.strip():
|
| 452 |
-
render_markdown_to_pdf_grouped(pdf, content_text, primary_color, accent_color, text_color)
|
| 453 |
-
else:
|
| 454 |
-
# Add placeholder text to prevent blank page
|
| 455 |
-
pdf.multi_cell(160, 8, f"Content for {section.title} section is being processed...", align='L')
|
| 456 |
-
|
| 457 |
-
# Add visual separation between content and charts
|
| 458 |
-
if content_text and content_text.strip():
|
| 459 |
-
pdf.ln(15) # More generous spacing for better visual separation
|
| 460 |
-
|
| 461 |
-
# ---- Charts for this section ----
|
| 462 |
-
charts_added = 0 # IMPORTANT: initialize to avoid NameError
|
| 463 |
-
|
| 464 |
-
# CRITICAL DEBUG: Show exactly what charts exist and their source_section values
|
| 465 |
-
if section.title == "Financial Plan & Funding":
|
| 466 |
-
# logging.info(f"=== LAST SECTION DEBUG ===")
|
| 467 |
-
# logging.info(f"Section title: '{section.title}'")
|
| 468 |
-
# logging.info(f"All chart_info_map keys: {list(chart_info_map.keys())}")
|
| 469 |
-
for key, charts in chart_info_map.items():
|
| 470 |
-
# logging.info(f" Key '{key}' has {len(charts)} charts")
|
| 471 |
-
for chart in charts:
|
| 472 |
-
# logging.info(f" Chart: {chart['title']} ({chart['type']})")
|
| 473 |
-
pass
|
| 474 |
-
# logging.info(f"=== END LAST SECTION DEBUG ===")
|
| 475 |
-
pass
|
| 476 |
-
|
| 477 |
-
section_charts = chart_info_map.get(section.title, [])
|
| 478 |
-
# logging.info(f"Section '{section.title}': Direct lookup found {len(section_charts)} charts")
|
| 479 |
-
|
| 480 |
-
# Strategy 1: Exact title match
|
| 481 |
-
if not section_charts:
|
| 482 |
-
# logging.info(f"No exact match found for '{section.title}'")
|
| 483 |
-
pass
|
| 484 |
-
|
| 485 |
-
# Strategy 2: Case-insensitive match
|
| 486 |
-
for chart_source, charts in chart_info_map.items():
|
| 487 |
-
if chart_source.lower() == section.title.lower():
|
| 488 |
-
section_charts = charts
|
| 489 |
-
# logging.info(f"Found charts via case-insensitive match: '{chart_source}' -> {len(charts)} charts")
|
| 490 |
-
break
|
| 491 |
-
|
| 492 |
-
# Strategy 3: Partial match
|
| 493 |
-
if not section_charts:
|
| 494 |
-
for chart_source, charts in chart_info_map.items():
|
| 495 |
-
if (section.title.lower() in chart_source.lower() or
|
| 496 |
-
chart_source.lower() in section.title.lower()):
|
| 497 |
-
section_charts = charts
|
| 498 |
-
# logging.info(f"Found charts via partial match: '{chart_source}' -> {len(charts)} charts")
|
| 499 |
-
break
|
| 500 |
-
|
| 501 |
-
# Strategy 4: Key-based match (for prompt_ prefixed sections)
|
| 502 |
-
if not section_charts and hasattr(section, 'key'):
|
| 503 |
-
for chart_source, charts in chart_info_map.items():
|
| 504 |
-
if section.key and section.key.replace('prompt_', '') in chart_source.lower():
|
| 505 |
-
section_charts = charts
|
| 506 |
-
# logging.info(f"Found charts via key match: '{section.key}' -> '{chart_source}' -> {len(charts)} charts")
|
| 507 |
-
break
|
| 508 |
-
|
| 509 |
-
# logging.info(f"Section '{section.title}': Final chart count: {len(section_charts)}")
|
| 510 |
-
|
| 511 |
-
if section_charts:
|
| 512 |
-
|
| 513 |
-
# Group charts that can fit on the same page
|
| 514 |
-
charts_per_page = 0
|
| 515 |
-
max_charts_per_page = 5 # Allow up to 5 charts per page
|
| 516 |
-
|
| 517 |
-
for chart_info in section_charts:
|
| 518 |
-
try:
|
| 519 |
-
# Check if we need a new page for this chart
|
| 520 |
-
chart_height_needed = 140 # Chart height (120) + title (20) + minimal spacing
|
| 521 |
-
|
| 522 |
-
# Smart page break logic - allow multiple charts per page
|
| 523 |
-
current_y = pdf.get_y()
|
| 524 |
-
charts_per_page += 1
|
| 525 |
-
|
| 526 |
-
# Only force page break if absolutely necessary - allow up to 5 charts per page
|
| 527 |
-
if (current_y + chart_height_needed > 290) or (charts_per_page > 5):
|
| 528 |
-
pdf.add_page()
|
| 529 |
-
# Header/footer removed for now
|
| 530 |
-
y_title = 25 # Start at normal position
|
| 531 |
-
charts_per_page = 1 # Reset counter for new page
|
| 532 |
-
else:
|
| 533 |
-
y_title = current_y
|
| 534 |
-
|
| 535 |
-
# Add minimal spacing before chart if not at top of page
|
| 536 |
-
if y_title > 25: # If not at top of page, add minimal space
|
| 537 |
-
pdf.ln(3) # Minimal spacing to fit more charts per page
|
| 538 |
-
|
| 539 |
-
# Chart title is already included in the chart itself, no need for separate subheader
|
| 540 |
-
|
| 541 |
-
# Centered image with larger dimensions for better visibility
|
| 542 |
-
img_width = 160 # Much larger chart width for better visibility
|
| 543 |
-
x = (210 - img_width) / 2
|
| 544 |
-
y_img = y_title
|
| 545 |
-
|
| 546 |
-
# Save image to temp file
|
| 547 |
-
with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as tmp_img:
|
| 548 |
-
tmp_img.write(chart_info["chart_bytes"])
|
| 549 |
-
temp_img_path = tmp_img.name
|
| 550 |
-
|
| 551 |
-
# Chart dimensions (no border needed)
|
| 552 |
-
chart_height = 120 # Much larger height for better visibility
|
| 553 |
-
chart_width = img_width
|
| 554 |
-
|
| 555 |
-
# Image
|
| 556 |
-
pdf.image(temp_img_path, x=x, y=y_img, w=chart_width)
|
| 557 |
-
|
| 558 |
-
# Move below image and clean up (spacing based on actual chart height)
|
| 559 |
-
pdf.ln(chart_height + 5) # Chart height (120) + minimal spacing between charts
|
| 560 |
-
try:
|
| 561 |
-
os.remove(temp_img_path)
|
| 562 |
-
except Exception:
|
| 563 |
-
pass
|
| 564 |
-
|
| 565 |
-
charts_added += 1
|
| 566 |
-
total_charts_placed += 1
|
| 567 |
-
except Exception:
|
| 568 |
-
# Skip faulty chart and continue rendering others
|
| 569 |
-
continue
|
| 570 |
-
else:
|
| 571 |
-
# Debug: Check if there are any charts that might match this section
|
| 572 |
-
potential_matches = []
|
| 573 |
-
for chart_source, charts in chart_info_map.items():
|
| 574 |
-
if (section.title.lower() in chart_source.lower() or
|
| 575 |
-
chart_source.lower() in section.title.lower()):
|
| 576 |
-
potential_matches.append(f"'{chart_source}' -> {len(charts)} charts")
|
| 577 |
-
|
| 578 |
-
if potential_matches:
|
| 579 |
-
# logging.info(f"Section '{section.title}': Potential chart matches: {potential_matches}")
|
| 580 |
-
pass
|
| 581 |
-
else:
|
| 582 |
-
# logging.info(f"Section '{section.title}': No charts found and no potential matches")
|
| 583 |
-
pass
|
| 584 |
-
|
| 585 |
-
# Each section is already on its own page, no need for complex page break logic
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
# ===== BUILD THE PDF =====
|
| 591 |
-
try:
|
| 592 |
-
# Add all sections
|
| 593 |
-
try:
|
| 594 |
-
add_cover_page()
|
| 595 |
-
except Exception as e:
|
| 596 |
-
raise Exception(f"Cover page failed: {str(e)}")
|
| 597 |
-
|
| 598 |
-
# Add TOC on page 2 with REAL page numbers (already calculated above)
|
| 599 |
-
try:
|
| 600 |
-
if toc_items:
|
| 601 |
-
add_table_of_contents_page(pdf, toc_items, request.businessIdea)
|
| 602 |
-
else:
|
| 603 |
-
# Add a basic TOC page if no items were generated
|
| 604 |
-
pdf.add_page()
|
| 605 |
-
pdf.set_font("Arial", "B", 20)
|
| 606 |
-
pdf.set_text_color(0, 0, 0)
|
| 607 |
-
pdf.set_xy(25, 25)
|
| 608 |
-
pdf.cell(0, 18, "Table of Contents", ln=True)
|
| 609 |
-
pdf.set_font("Arial", "", 12)
|
| 610 |
-
pdf.set_text_color(51, 51, 51)
|
| 611 |
-
pdf.set_xy(25, 50)
|
| 612 |
-
pdf.multi_cell(160, 8, "No sections available for table of contents.", align='L')
|
| 613 |
-
except Exception as e:
|
| 614 |
-
# logging.error(f"Table of contents failed: {str(e)}")
|
| 615 |
-
pass
|
| 616 |
-
# Add a basic TOC page as fallback
|
| 617 |
-
pdf.add_page()
|
| 618 |
-
pdf.set_font("Arial", "B", 20)
|
| 619 |
-
pdf.set_text_color(0, 0, 0)
|
| 620 |
-
pdf.set_xy(25, 25)
|
| 621 |
-
pdf.cell(0, 18, "Table of Contents", ln=True)
|
| 622 |
-
pdf.set_font("Arial", "", 12)
|
| 623 |
-
pdf.set_text_color(51, 51, 51)
|
| 624 |
-
pdf.set_xy(25, 50)
|
| 625 |
-
pdf.multi_cell(160, 8, "Table of contents could not be generated.", align='L')
|
| 626 |
-
|
| 627 |
-
# Executive summary is now handled as part of the sections
|
| 628 |
-
# No separate page needed - the first section contains the executive summary
|
| 629 |
-
|
| 630 |
-
try:
|
| 631 |
-
add_sections_with_charts()
|
| 632 |
-
except Exception as e:
|
| 633 |
-
raise Exception(f"Sections with charts failed: {str(e)}")
|
| 634 |
-
|
| 635 |
-
# CRITICAL CHECK: Ensure we have content in the PDF
|
| 636 |
-
total_pages = pdf.page_no()
|
| 637 |
-
|
| 638 |
-
if total_pages < 2: # Should have at least cover + 1 content page
|
| 639 |
-
# Add a content page to prevent blank PDF
|
| 640 |
-
pdf.add_page()
|
| 641 |
-
pdf.set_font("Arial", "B", 16)
|
| 642 |
-
pdf.set_text_color(255, 0, 0)
|
| 643 |
-
pdf.cell(0, 20, "Content Processing Issue", ln=True, align='C')
|
| 644 |
-
pdf.set_font("Arial", "", 12)
|
| 645 |
-
pdf.set_text_color(0, 0, 0)
|
| 646 |
-
pdf.cell(0, 10, "The PDF generation encountered an issue with content processing.", ln=True, align='C')
|
| 647 |
-
pdf.cell(0, 10, "Please check the server logs for detailed information.", ln=True, align='C')
|
| 648 |
-
|
| 649 |
-
# Get PDF output and convert to bytes
|
| 650 |
-
try:
|
| 651 |
-
pdf_output = pdf.output(dest='S')
|
| 652 |
-
if isinstance(pdf_output, bytearray):
|
| 653 |
-
pdf_bytes = bytes(pdf_output)
|
| 654 |
-
else:
|
| 655 |
-
pdf_bytes = pdf_output.encode('latin-1') if isinstance(pdf_output, str) else pdf_output
|
| 656 |
-
|
| 657 |
-
# CRITICAL SAFETY CHECK: Ensure PDF is not empty
|
| 658 |
-
if len(pdf_bytes) < 1000: # PDF should be at least 1KB
|
| 659 |
-
# Try to add some content to prevent blank PDF
|
| 660 |
-
pdf.add_page()
|
| 661 |
-
pdf.set_font("Arial", "B", 16)
|
| 662 |
-
pdf.set_text_color(255, 0, 0) # Red text
|
| 663 |
-
pdf.cell(0, 20, "PDF Generation Issue Detected", ln=True, align='C')
|
| 664 |
-
pdf.set_font("Arial", "", 12)
|
| 665 |
-
pdf.set_text_color(0, 0, 0)
|
| 666 |
-
pdf.cell(0, 10, "If you see this message, there was an issue with content processing.", ln=True, align='C')
|
| 667 |
-
pdf.cell(0, 10, "Please check the server logs for details.", ln=True, align='C')
|
| 668 |
-
|
| 669 |
-
# Regenerate PDF with error message
|
| 670 |
-
pdf_output = pdf.output(dest='S')
|
| 671 |
-
if isinstance(pdf_output, bytearray):
|
| 672 |
-
pdf_bytes = bytes(pdf_output)
|
| 673 |
-
else:
|
| 674 |
-
pdf_bytes = pdf_output.encode('latin-1') if isinstance(pdf_output, str) else pdf_output
|
| 675 |
-
|
| 676 |
-
return pdf_bytes, f"business_plan_{request.businessIdea or 'export'}.pdf"
|
| 677 |
-
except Exception as e:
|
| 678 |
-
raise Exception(f"PDF conversion failed: {str(e)}")
|
| 679 |
-
|
| 680 |
-
except Exception as e:
|
| 681 |
-
# logging.error(f"PDF generation failed: {e}")
|
| 682 |
-
raise Exception(f"Failed to generate professional PDF: {str(e)}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/DocumentGeneration/table_of_content.py
DELETED
|
@@ -1,205 +0,0 @@
|
|
| 1 |
-
# Table of Contents Module
|
| 2 |
-
# Contains functions to generate TOC AFTER content is placed with real page numbers
|
| 3 |
-
|
| 4 |
-
import re
|
| 5 |
-
import os
|
| 6 |
-
import sys
|
| 7 |
-
from typing import List, Dict, Any
|
| 8 |
-
|
| 9 |
-
# Add the parent directory to sys.path to import from models.py and constants.py
|
| 10 |
-
sys.path.append(os.path.dirname(os.path.dirname(__file__)))
|
| 11 |
-
from models import ExportSection
|
| 12 |
-
from constants import SECTION_ORDER
|
| 13 |
-
|
| 14 |
-
def extract_first_heading_from_content(content: str) -> str:
|
| 15 |
-
"""Extract the first heading from markdown content"""
|
| 16 |
-
if not content:
|
| 17 |
-
return ""
|
| 18 |
-
|
| 19 |
-
lines = content.split('\n')
|
| 20 |
-
for line in lines:
|
| 21 |
-
line = line.strip()
|
| 22 |
-
if not line:
|
| 23 |
-
continue
|
| 24 |
-
|
| 25 |
-
# Check for markdown headings (# ## ### etc.)
|
| 26 |
-
if line.startswith('#'):
|
| 27 |
-
# Extract heading text without the # symbols
|
| 28 |
-
heading_text = re.sub(r'^#+\s*', '', line).strip()
|
| 29 |
-
if heading_text:
|
| 30 |
-
return heading_text
|
| 31 |
-
|
| 32 |
-
# Check for bold headings (**text**)
|
| 33 |
-
elif line.startswith('**') and line.endswith('**') and len(line) > 4:
|
| 34 |
-
# Extract text from bold markers
|
| 35 |
-
heading_text = line[2:-2].strip()
|
| 36 |
-
if heading_text:
|
| 37 |
-
return heading_text
|
| 38 |
-
|
| 39 |
-
return ""
|
| 40 |
-
|
| 41 |
-
def generate_table_of_contents_after_content(sections: List[ExportSection], section_page_map: Dict[str, int], business_idea: str) -> List[Dict[str, Any]]:
|
| 42 |
-
"""Generate TOC AFTER all content is placed, using REAL page numbers from section_page_map
|
| 43 |
-
This ensures accurate page numbers based on actual content placement"""
|
| 44 |
-
toc_items = []
|
| 45 |
-
|
| 46 |
-
# Create a mapping from section titles to section objects for easy lookup
|
| 47 |
-
section_map = {section.title: section for section in sections}
|
| 48 |
-
|
| 49 |
-
# Add business plan sections in the predefined SECTION_ORDER
|
| 50 |
-
for i, expected_title in enumerate(SECTION_ORDER):
|
| 51 |
-
# Find the corresponding section - try multiple matching strategies
|
| 52 |
-
section = None
|
| 53 |
-
|
| 54 |
-
# Strategy 1: Exact title match
|
| 55 |
-
section = section_map.get(expected_title)
|
| 56 |
-
|
| 57 |
-
# Strategy 2: Case-insensitive match
|
| 58 |
-
if not section:
|
| 59 |
-
for section_title, section_obj in section_map.items():
|
| 60 |
-
if section_title.lower() == expected_title.lower():
|
| 61 |
-
section = section_obj
|
| 62 |
-
break
|
| 63 |
-
|
| 64 |
-
# Strategy 3: Partial match (e.g., "Company Profile" matches "Company Profile Section")
|
| 65 |
-
if not section:
|
| 66 |
-
for section_title, section_obj in section_map.items():
|
| 67 |
-
if (expected_title.lower() in section_title.lower() or
|
| 68 |
-
section_title.lower() in expected_title.lower()):
|
| 69 |
-
section = section_obj
|
| 70 |
-
break
|
| 71 |
-
|
| 72 |
-
# Strategy 4: Key-based match (remove 'prompt_' prefix and match)
|
| 73 |
-
if not section:
|
| 74 |
-
for section_obj in sections:
|
| 75 |
-
if section_obj.key:
|
| 76 |
-
key_clean = section_obj.key.replace('prompt_', '') if section_obj.key.startswith('prompt_') else section_obj.key
|
| 77 |
-
if key_clean.lower() == expected_title.lower().replace(' ', '').replace('&', ''):
|
| 78 |
-
section = section_obj
|
| 79 |
-
break
|
| 80 |
-
|
| 81 |
-
# Only add sections that have actual content AND real page numbers - NO DUMMY DATA
|
| 82 |
-
has_content = bool(section and section.content and section.content.strip())
|
| 83 |
-
if has_content:
|
| 84 |
-
# Get the REAL page number from section_page_map
|
| 85 |
-
real_page = section_page_map.get(section.title, 0)
|
| 86 |
-
if real_page > 0: # Only add if we have a real page number
|
| 87 |
-
# Extract the actual heading from markdown content
|
| 88 |
-
actual_heading = extract_first_heading_from_content(section.content)
|
| 89 |
-
|
| 90 |
-
# Use the actual heading from markdown content, fallback to expected title
|
| 91 |
-
toc_title = actual_heading if actual_heading else expected_title
|
| 92 |
-
|
| 93 |
-
toc_items.append({
|
| 94 |
-
"title": toc_title, # Use actual heading from markdown content
|
| 95 |
-
"level": 1,
|
| 96 |
-
"page": real_page, # REAL page number from actual content placement
|
| 97 |
-
"section_type": "section",
|
| 98 |
-
"has_content": True,
|
| 99 |
-
"section_obj": section # Store reference to actual section
|
| 100 |
-
})
|
| 101 |
-
|
| 102 |
-
return toc_items
|
| 103 |
-
|
| 104 |
-
def add_table_of_contents_page(pdf, toc_items: List[Dict[str, Any]], business_idea: str):
|
| 105 |
-
"""Add a professional table of contents page with REAL page numbers"""
|
| 106 |
-
|
| 107 |
-
# TOC items are already in the correct order and have real page numbers
|
| 108 |
-
sorted_toc_items = toc_items
|
| 109 |
-
|
| 110 |
-
# Professional color scheme - Refined for bank/investor presentation
|
| 111 |
-
primary_color = (0, 0, 0) # Black - for headings
|
| 112 |
-
accent_color = (75, 0, 130) # Navy purple - for accent lines and highlights
|
| 113 |
-
text_color = (51, 51, 51) # Dark gray - for body text
|
| 114 |
-
light_gray = (221, 221, 221) # Lighter gray - for subtle lines (#DDDDDD)
|
| 115 |
-
navy_color = (25, 25, 112) # Navy blue - for TOC and emphasis
|
| 116 |
-
|
| 117 |
-
# Page title - more compact
|
| 118 |
-
pdf.set_font("Arial", "B", 20) # Reduced from 24 to 20
|
| 119 |
-
pdf.set_text_color(*primary_color)
|
| 120 |
-
pdf.set_xy(25, 20) # Reduced from 25 to 20
|
| 121 |
-
pdf.cell(0, 15, "Table of Contents", ln=True) # Reduced from 18 to 15
|
| 122 |
-
|
| 123 |
-
# Calculate text width and make line autofit
|
| 124 |
-
text_width = pdf.get_string_width("Table of Contents")
|
| 125 |
-
line_width = min(text_width + 12, 160) # Add 12mm padding, max 160mm
|
| 126 |
-
|
| 127 |
-
# Removed purple accent line under TOC title
|
| 128 |
-
|
| 129 |
-
# TOC content - optimized for single page layout
|
| 130 |
-
y_position = 50 # Reduced from 60 to 50
|
| 131 |
-
pages_used = 1
|
| 132 |
-
|
| 133 |
-
for item in sorted_toc_items:
|
| 134 |
-
# Only add new page if absolutely necessary (very close to bottom)
|
| 135 |
-
if y_position > 290: # Very close to bottom margin
|
| 136 |
-
pdf.add_page()
|
| 137 |
-
y_position = 25
|
| 138 |
-
pages_used += 1
|
| 139 |
-
|
| 140 |
-
# Indent based on level - better indentation
|
| 141 |
-
indent = 0 if item["level"] == 1 else 20 # Increased indent for subsections
|
| 142 |
-
|
| 143 |
-
# Font and color based on level - ALL sections same color
|
| 144 |
-
if item["level"] == 1:
|
| 145 |
-
pdf.set_font("Arial", "B", 12)
|
| 146 |
-
pdf.set_text_color(*navy_color) # Navy blue for all main entries
|
| 147 |
-
else:
|
| 148 |
-
pdf.set_font("Arial", "", 10)
|
| 149 |
-
pdf.set_text_color(*text_color)
|
| 150 |
-
|
| 151 |
-
# Title with hyperlink
|
| 152 |
-
title_width = pdf.get_string_width(item["title"])
|
| 153 |
-
pdf.set_xy(25 + indent, y_position)
|
| 154 |
-
try:
|
| 155 |
-
# FPDF2 hyperlinks: test both 0-based and 1-based indexing
|
| 156 |
-
# First try 1-based (no subtraction)
|
| 157 |
-
target_page = item["page"]
|
| 158 |
-
# Try different FPDF2 link methods
|
| 159 |
-
if hasattr(pdf, 'add_link'):
|
| 160 |
-
link_id = pdf.add_link()
|
| 161 |
-
pdf.set_link(link_id, page=target_page)
|
| 162 |
-
pdf.link(25 + indent, y_position, title_width, 8, link_id)
|
| 163 |
-
else:
|
| 164 |
-
pdf.link(25 + indent, y_position, title_width, 8, dest=target_page)
|
| 165 |
-
except:
|
| 166 |
-
pass # If hyperlink fails, continue without it
|
| 167 |
-
pdf.cell(title_width, 8, item["title"], ln=False)
|
| 168 |
-
|
| 169 |
-
# Dots leading to page number
|
| 170 |
-
pdf.set_font("Arial", "", 8)
|
| 171 |
-
pdf.set_text_color(128, 128, 128) # Medium gray for dots
|
| 172 |
-
|
| 173 |
-
# Calculate dots position
|
| 174 |
-
title_width = pdf.get_string_width(item["title"])
|
| 175 |
-
dots_start = 25 + indent + title_width + 5
|
| 176 |
-
dots_end = 160
|
| 177 |
-
|
| 178 |
-
# Draw dots
|
| 179 |
-
for x in range(int(dots_start), int(dots_end), 3):
|
| 180 |
-
pdf.set_xy(x, y_position + 3)
|
| 181 |
-
pdf.cell(1, 1, ".", ln=False)
|
| 182 |
-
|
| 183 |
-
# Page number with hyperlink - NOW USING REAL PAGE NUMBERS
|
| 184 |
-
page_number_width = pdf.get_string_width(str(item["page"]))
|
| 185 |
-
pdf.set_xy(160, y_position)
|
| 186 |
-
try:
|
| 187 |
-
# FPDF2 hyperlinks: test both 0-based and 1-based indexing
|
| 188 |
-
# First try 1-based (no subtraction)
|
| 189 |
-
target_page = item["page"]
|
| 190 |
-
# Try different FPDF2 link methods
|
| 191 |
-
if hasattr(pdf, 'add_link'):
|
| 192 |
-
link_id = pdf.add_link()
|
| 193 |
-
pdf.set_link(link_id, page=target_page)
|
| 194 |
-
pdf.link(160, y_position, page_number_width, 8, link_id)
|
| 195 |
-
else:
|
| 196 |
-
pdf.link(160, y_position, page_number_width, 8, dest=target_page)
|
| 197 |
-
except:
|
| 198 |
-
pass # If hyperlink fails, continue without it
|
| 199 |
-
pdf.cell(0, 8, str(item["page"]), ln=False)
|
| 200 |
-
|
| 201 |
-
# Reduced spacing between items for better page utilization
|
| 202 |
-
y_position += 8 # Reduced from 10 to 8 for even tighter spacing
|
| 203 |
-
|
| 204 |
-
# Add some spacing at the end
|
| 205 |
-
pdf.ln(20)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/DocumentGeneration/word_generator.py
DELETED
|
@@ -1,279 +0,0 @@
|
|
| 1 |
-
# Word Generator Module
|
| 2 |
-
# Contains create_word_document() and all Word-related functions
|
| 3 |
-
|
| 4 |
-
import re
|
| 5 |
-
import os
|
| 6 |
-
import tempfile
|
| 7 |
-
import time
|
| 8 |
-
import io
|
| 9 |
-
import sys
|
| 10 |
-
from typing import List, Dict, Any
|
| 11 |
-
|
| 12 |
-
# Add the parent directory to sys.path to import from models.py
|
| 13 |
-
sys.path.append(os.path.dirname(os.path.dirname(__file__)))
|
| 14 |
-
from models import ExportRequest
|
| 15 |
-
|
| 16 |
-
# Import from the same directory (Document Generation)
|
| 17 |
-
from document_helpers import (
|
| 18 |
-
filter_empty_sections,
|
| 19 |
-
extract_business_name_from_content
|
| 20 |
-
)
|
| 21 |
-
# from table_of_content import generate_table_of_contents # Not used in word generator
|
| 22 |
-
from chart_generator import parse_embedded_chart_data, create_chart_from_data
|
| 23 |
-
from markdown_renderer import (
|
| 24 |
-
render_markdown_to_docx_grouped,
|
| 25 |
-
process_content_with_inline_charts
|
| 26 |
-
)
|
| 27 |
-
|
| 28 |
-
# Add Currency Mapping path
|
| 29 |
-
sys.path.append(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'Currency Mapping'))
|
| 30 |
-
from CurrencyMapping.currency_mapping import convert_currency_symbols_to_iso
|
| 31 |
-
|
| 32 |
-
def create_word_document(request: ExportRequest):
|
| 33 |
-
"""Create a Word document from the business plan data with front page, TOC, and proper page breaks"""
|
| 34 |
-
try:
|
| 35 |
-
from docx import Document
|
| 36 |
-
from docx.shared import Inches, Pt, RGBColor
|
| 37 |
-
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_TAB_ALIGNMENT
|
| 38 |
-
from docx.oxml import OxmlElement
|
| 39 |
-
from docx.oxml.ns import qn
|
| 40 |
-
except ImportError:
|
| 41 |
-
raise Exception("python-docx is not installed. Please install it with: pip install python-docx")
|
| 42 |
-
|
| 43 |
-
# Filter out empty sections to prevent blank pages
|
| 44 |
-
try:
|
| 45 |
-
filtered_sections = filter_empty_sections(request.sections)
|
| 46 |
-
except Exception as e:
|
| 47 |
-
raise Exception(f"Failed to filter sections: {str(e)}")
|
| 48 |
-
if not filtered_sections:
|
| 49 |
-
raise Exception("No valid sections found after filtering. All sections appear to be empty.")
|
| 50 |
-
|
| 51 |
-
doc = Document()
|
| 52 |
-
|
| 53 |
-
# Add confidentiality footer to every page
|
| 54 |
-
section = doc.sections[0]
|
| 55 |
-
footer = section.footer
|
| 56 |
-
footer_para = footer.paragraphs[0]
|
| 57 |
-
footer_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 58 |
-
footer_run = footer_para.add_run("CONFIDENTIAL - This document contains proprietary information and may not be shared without consent")
|
| 59 |
-
footer_run.font.size = Pt(8)
|
| 60 |
-
footer_run.font.color.rgb = RGBColor(100, 100, 100) # Gray text
|
| 61 |
-
|
| 62 |
-
# ===== FRONT PAGE =====
|
| 63 |
-
# Add company logo at the top
|
| 64 |
-
try:
|
| 65 |
-
# Use the MBD logo PNG file
|
| 66 |
-
project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
| 67 |
-
logo_path = os.path.join(project_root, 'assets', 'logos', 'MBD logo.png')
|
| 68 |
-
|
| 69 |
-
if os.path.exists(logo_path):
|
| 70 |
-
# Add logo to Word document (centered, top of page)
|
| 71 |
-
logo_para = doc.add_paragraph()
|
| 72 |
-
logo_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 73 |
-
logo_run = logo_para.add_run()
|
| 74 |
-
logo_run.add_picture(logo_path, width=Inches(3.0)) # Even larger size to show full logo
|
| 75 |
-
print(f"Logo added successfully from: {logo_path}")
|
| 76 |
-
else:
|
| 77 |
-
print(f"Logo file not found at: {logo_path}")
|
| 78 |
-
except Exception as e:
|
| 79 |
-
# Log any errors with logo
|
| 80 |
-
print(f"Logo error: {e}")
|
| 81 |
-
pass
|
| 82 |
-
|
| 83 |
-
# Add top spacing
|
| 84 |
-
doc.add_paragraph()
|
| 85 |
-
doc.add_paragraph()
|
| 86 |
-
|
| 87 |
-
# Extract business name from content, fallback to businessIdea
|
| 88 |
-
extracted_business_name = extract_business_name_from_content(filtered_sections)
|
| 89 |
-
business_name = extracted_business_name or request.businessIdea or "Business Plan"
|
| 90 |
-
|
| 91 |
-
# Business name as main title with enhanced styling - PURPLE THEME
|
| 92 |
-
title = doc.add_heading(business_name, 0)
|
| 93 |
-
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 94 |
-
|
| 95 |
-
# Style the title with larger font and PURPLE color
|
| 96 |
-
for run in title.runs:
|
| 97 |
-
run.font.size = Pt(36)
|
| 98 |
-
run.font.color.rgb = RGBColor(139, 92, 246) # PURPLE
|
| 99 |
-
|
| 100 |
-
# Removed decorative line under title
|
| 101 |
-
|
| 102 |
-
# Add subtitle with enhanced styling - PURPLE
|
| 103 |
-
doc.add_paragraph()
|
| 104 |
-
subtitle = doc.add_paragraph("Professional Business Plan Document")
|
| 105 |
-
subtitle.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 106 |
-
subtitle_run = subtitle.runs[0]
|
| 107 |
-
subtitle_run.font.size = Pt(18)
|
| 108 |
-
subtitle_run.font.color.rgb = RGBColor(124, 58, 237) # DARKER PURPLE
|
| 109 |
-
subtitle_run.italic = True
|
| 110 |
-
|
| 111 |
-
# Add more spacing
|
| 112 |
-
doc.add_paragraph()
|
| 113 |
-
doc.add_paragraph()
|
| 114 |
-
doc.add_paragraph()
|
| 115 |
-
|
| 116 |
-
# Add document info in a styled box - PURPLE BORDER
|
| 117 |
-
info_para = doc.add_paragraph()
|
| 118 |
-
info_para.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 119 |
-
|
| 120 |
-
# Add background color and border styling
|
| 121 |
-
info_run = info_para.add_run("Prepared by: Business Plan Generator")
|
| 122 |
-
info_run.font.size = Pt(14)
|
| 123 |
-
info_run.font.color.rgb = RGBColor(139, 92, 246) # PURPLE
|
| 124 |
-
|
| 125 |
-
doc.add_paragraph()
|
| 126 |
-
date_para = doc.add_paragraph()
|
| 127 |
-
date_para.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 128 |
-
date_run = date_para.add_run(f"Date: {time.strftime('%B %Y')}")
|
| 129 |
-
date_run.font.size = Pt(14)
|
| 130 |
-
date_run.font.color.rgb = RGBColor(139, 92, 246) # PURPLE
|
| 131 |
-
|
| 132 |
-
# Confidentiality statement removed - now in footer of every page
|
| 133 |
-
|
| 134 |
-
# Add more spacing before footer
|
| 135 |
-
doc.add_paragraph()
|
| 136 |
-
doc.add_paragraph()
|
| 137 |
-
doc.add_paragraph()
|
| 138 |
-
doc.add_paragraph()
|
| 139 |
-
|
| 140 |
-
# Add footer with enhanced styling - PURPLE
|
| 141 |
-
footer = doc.add_paragraph("Confidential Business Information")
|
| 142 |
-
footer.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 143 |
-
footer_run = footer.runs[0]
|
| 144 |
-
footer_run.font.size = Pt(12)
|
| 145 |
-
footer_run.font.color.rgb = RGBColor(139, 92, 246) # PURPLE
|
| 146 |
-
footer_run.bold = True
|
| 147 |
-
|
| 148 |
-
# Removed decorative line above footer
|
| 149 |
-
|
| 150 |
-
# ===== TABLE OF CONTENTS =====
|
| 151 |
-
# Add page break before TOC
|
| 152 |
-
doc.add_page_break()
|
| 153 |
-
|
| 154 |
-
# Add TOC title
|
| 155 |
-
toc_title = doc.add_heading("Table of Contents", level=1)
|
| 156 |
-
toc_title.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 157 |
-
|
| 158 |
-
# Removed decorative line under TOC title
|
| 159 |
-
|
| 160 |
-
# Add spacing before TOC entries
|
| 161 |
-
doc.add_paragraph()
|
| 162 |
-
|
| 163 |
-
# Sort sections according to predefined order (do this before TOC generation)
|
| 164 |
-
try:
|
| 165 |
-
sorted_sections = sort_sections_by_order(filtered_sections)
|
| 166 |
-
except Exception as e:
|
| 167 |
-
# Fallback: use filtered order if sorting fails
|
| 168 |
-
sorted_sections = filtered_sections
|
| 169 |
-
|
| 170 |
-
# Generate TOC entries with actual page numbers
|
| 171 |
-
# Since each section starts on a new page, we can calculate page numbers
|
| 172 |
-
toc_items = []
|
| 173 |
-
current_page = 3 # Start after cover (1) and TOC (2)
|
| 174 |
-
|
| 175 |
-
for section in sorted_sections:
|
| 176 |
-
if section.content and section.content.strip():
|
| 177 |
-
# Add main section
|
| 178 |
-
toc_items.append({
|
| 179 |
-
"title": section.title,
|
| 180 |
-
"level": 1,
|
| 181 |
-
"page": current_page
|
| 182 |
-
})
|
| 183 |
-
|
| 184 |
-
# Skip subheadings and chart titles - only include main section headings
|
| 185 |
-
|
| 186 |
-
current_page += 1 # Each section starts on a new page
|
| 187 |
-
|
| 188 |
-
# Add the actual TOC entries
|
| 189 |
-
for item in toc_items:
|
| 190 |
-
# Create TOC entry paragraph
|
| 191 |
-
toc_para = doc.add_paragraph()
|
| 192 |
-
toc_para.paragraph_format.space_after = Pt(6)
|
| 193 |
-
|
| 194 |
-
# Add section title
|
| 195 |
-
title_run = toc_para.add_run(item["title"])
|
| 196 |
-
title_run.font.size = Pt(11)
|
| 197 |
-
|
| 198 |
-
# Add dots and page number
|
| 199 |
-
if item["level"] == 1:
|
| 200 |
-
# Main section - bold
|
| 201 |
-
title_run.bold = True
|
| 202 |
-
# Add dots
|
| 203 |
-
dots_run = toc_para.add_run(" " + "." * (60 - len(item["title"])) + " ")
|
| 204 |
-
dots_run.font.size = Pt(11)
|
| 205 |
-
dots_run.font.color.rgb = RGBColor(128, 128, 128) # Gray
|
| 206 |
-
# Add page number
|
| 207 |
-
page_run = toc_para.add_run(str(item["page"]))
|
| 208 |
-
page_run.font.size = Pt(11)
|
| 209 |
-
page_run.bold = True
|
| 210 |
-
else:
|
| 211 |
-
# Subsection - indented
|
| 212 |
-
toc_para.paragraph_format.left_indent = Inches(0.3)
|
| 213 |
-
# Add dots
|
| 214 |
-
dots_run = toc_para.add_run(" " + "." * (50 - len(item["title"])) + " ")
|
| 215 |
-
dots_run.font.size = Pt(11)
|
| 216 |
-
dots_run.font.color.rgb = RGBColor(128, 128, 128) # Gray
|
| 217 |
-
# Add page number
|
| 218 |
-
page_run = toc_para.add_run(str(item["page"]))
|
| 219 |
-
page_run.font.size = Pt(11)
|
| 220 |
-
|
| 221 |
-
# logging.info(f"Generated {len(toc_items)} TOC items with actual page numbers")
|
| 222 |
-
|
| 223 |
-
# Executive Summary is now handled as a regular section above
|
| 224 |
-
|
| 225 |
-
# ===== MAIN SECTIONS =====
|
| 226 |
-
for i, section in enumerate(sorted_sections):
|
| 227 |
-
if section.content.strip():
|
| 228 |
-
# Add page break before each section (including Executive Summary)
|
| 229 |
-
doc.add_page_break()
|
| 230 |
-
|
| 231 |
-
# Add section heading
|
| 232 |
-
doc.add_heading(section.title, level=1)
|
| 233 |
-
|
| 234 |
-
# Clean and process content WITH inline charts
|
| 235 |
-
try:
|
| 236 |
-
clean_content = clean_text_for_pdf(section.content)
|
| 237 |
-
except Exception:
|
| 238 |
-
clean_content = section.content or ""
|
| 239 |
-
try:
|
| 240 |
-
clean_content = convert_currency_symbols_to_iso(clean_content)
|
| 241 |
-
except Exception:
|
| 242 |
-
pass
|
| 243 |
-
|
| 244 |
-
# Remove the section title from content to prevent duplication
|
| 245 |
-
section_title_clean = section.title.strip()
|
| 246 |
-
content_lines = clean_content.split('\n')
|
| 247 |
-
filtered_lines = []
|
| 248 |
-
for line in content_lines:
|
| 249 |
-
line_clean = line.strip()
|
| 250 |
-
# Skip lines that match the section title (with or without markdown)
|
| 251 |
-
# Check for various markdown formats and variations
|
| 252 |
-
if (line_clean == section_title_clean or
|
| 253 |
-
line_clean == f"**{section_title_clean}**" or
|
| 254 |
-
line_clean == f"# {section_title_clean}" or
|
| 255 |
-
line_clean == f"## {section_title_clean}" or
|
| 256 |
-
line_clean == f"### {section_title_clean}" or
|
| 257 |
-
line_clean == f"#### {section_title_clean}" or
|
| 258 |
-
# Also check if the line starts with the section title (for numbered headings)
|
| 259 |
-
line_clean.startswith(f"{section_title_clean}") and len(line_clean) <= len(section_title_clean) + 10):
|
| 260 |
-
continue
|
| 261 |
-
filtered_lines.append(line)
|
| 262 |
-
clean_content = '\n'.join(filtered_lines)
|
| 263 |
-
|
| 264 |
-
# Process content with inline charts - THIS IS THE KEY CHANGE
|
| 265 |
-
process_content_with_inline_charts(doc, clean_content, request.businessIdea or "Business")
|
| 266 |
-
|
| 267 |
-
doc.add_paragraph()
|
| 268 |
-
|
| 269 |
-
# Charts are now handled inline with the sections above
|
| 270 |
-
|
| 271 |
-
# Save to bytes
|
| 272 |
-
buffer = io.BytesIO()
|
| 273 |
-
doc.save(buffer)
|
| 274 |
-
buffer.seek(0)
|
| 275 |
-
doc_bytes = buffer.getvalue()
|
| 276 |
-
buffer.close()
|
| 277 |
-
|
| 278 |
-
filename = f"business_plan_{request.businessIdea or 'export'}.docx"
|
| 279 |
-
return doc_bytes, filename
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/__init__.py
DELETED
|
@@ -1,29 +0,0 @@
|
|
| 1 |
-
# App Package
|
| 2 |
-
# This file makes the app directory a proper Python package
|
| 3 |
-
|
| 4 |
-
import os
|
| 5 |
-
import sys
|
| 6 |
-
|
| 7 |
-
# Add the current directory to sys.path
|
| 8 |
-
current_dir = os.path.dirname(__file__)
|
| 9 |
-
if current_dir not in sys.path:
|
| 10 |
-
sys.path.insert(0, current_dir)
|
| 11 |
-
|
| 12 |
-
# Import main modules for easy access
|
| 13 |
-
from models import ExportRequest, ExportSection, ExportResponse, SectionJob, BatchGenerateRequest, GenerateRequest, CreatePlanRequest
|
| 14 |
-
from constants import SECTION_ORDER, SECTION_MAP, SECTION_COLUMNS
|
| 15 |
-
from utils import sort_sections_by_order
|
| 16 |
-
|
| 17 |
-
__all__ = [
|
| 18 |
-
'ExportRequest',
|
| 19 |
-
'ExportSection',
|
| 20 |
-
'ExportResponse',
|
| 21 |
-
'SectionJob',
|
| 22 |
-
'BatchGenerateRequest',
|
| 23 |
-
'GenerateRequest',
|
| 24 |
-
'CreatePlanRequest',
|
| 25 |
-
'SECTION_ORDER',
|
| 26 |
-
'SECTION_MAP',
|
| 27 |
-
'SECTION_COLUMNS',
|
| 28 |
-
'sort_sections_by_order'
|
| 29 |
-
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/constants.py
DELETED
|
@@ -1,41 +0,0 @@
|
|
| 1 |
-
# Constants and Configuration
|
| 2 |
-
# Contains constants and configuration values to avoid circular imports
|
| 3 |
-
|
| 4 |
-
# Human-readable section titles for TOC and display
|
| 5 |
-
SECTION_ORDER = [
|
| 6 |
-
"Executive Summary",
|
| 7 |
-
"Company Profile",
|
| 8 |
-
"Market Analysis",
|
| 9 |
-
"Product or Service",
|
| 10 |
-
"Business Model",
|
| 11 |
-
"Marketing & Growth",
|
| 12 |
-
"Operations Plan",
|
| 13 |
-
"Management Team",
|
| 14 |
-
"Financial Plan & Funding"
|
| 15 |
-
]
|
| 16 |
-
|
| 17 |
-
# Database helper constants
|
| 18 |
-
SECTION_MAP = {
|
| 19 |
-
"prompt_ExecutiveSummary": ("executiveSummary", "executiveSummaryComplete", "executiveSummaryGenerating"),
|
| 20 |
-
"prompt_CompanyProfile": ("companyProfile", "companyProfileComplete", "companyProfileGenerating"),
|
| 21 |
-
"prompt_MarketAnalysis": ("marketAnalysis", "marketAnalysisComplete", "marketAnalysisGenerating"),
|
| 22 |
-
"prompt_ProductOrService": ("productOrService", "productOrServiceComplete", "productOrServiceGenerating"),
|
| 23 |
-
"prompt_BusinessModel": ("businessModel", "businessModelComplete", "businessModelGenerating"),
|
| 24 |
-
"prompt_MarketingGrowth": ("marketingGrowth", "marketingGrowthComplete", "marketingGrowthGenerating"),
|
| 25 |
-
"prompt_OperationsPlan": ("operationsPlan", "operationsPlanComplete", "operationsPlanGenerating"),
|
| 26 |
-
"prompt_ManagementTeam": ("managementTeam", "managementTeamComplete", "managementTeamGenerating"),
|
| 27 |
-
"prompt_FinancialPlanFunding": ("financialPlanFunding", "financialPlanFundingComplete", "financialPlanFundingGenerating"),
|
| 28 |
-
}
|
| 29 |
-
|
| 30 |
-
# Database column names for sections
|
| 31 |
-
SECTION_COLUMNS = [
|
| 32 |
-
"executiveSummary",
|
| 33 |
-
"companyProfile",
|
| 34 |
-
"marketAnalysis",
|
| 35 |
-
"productOrService",
|
| 36 |
-
"businessModel",
|
| 37 |
-
"marketingGrowth",
|
| 38 |
-
"operationsPlan",
|
| 39 |
-
"managementTeam",
|
| 40 |
-
"financialPlanFunding",
|
| 41 |
-
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/main.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
app/models.py
DELETED
|
@@ -1,61 +0,0 @@
|
|
| 1 |
-
# Models and Data Structures
|
| 2 |
-
# Contains Pydantic models and data structures to avoid circular imports
|
| 3 |
-
|
| 4 |
-
from pydantic import BaseModel
|
| 5 |
-
from typing import List, Dict, Any, Optional, Literal
|
| 6 |
-
|
| 7 |
-
class ExportSection(BaseModel):
|
| 8 |
-
"""Represents a section of content for export"""
|
| 9 |
-
title: str
|
| 10 |
-
content: str
|
| 11 |
-
key: Optional[str] = None
|
| 12 |
-
order: Optional[int] = None
|
| 13 |
-
|
| 14 |
-
class ExportRequest(BaseModel):
|
| 15 |
-
"""Request model for document export"""
|
| 16 |
-
sections: List[ExportSection]
|
| 17 |
-
businessIdea: str
|
| 18 |
-
summary: str
|
| 19 |
-
format: Literal["pdf", "word"] = "pdf"
|
| 20 |
-
chartImages: Optional[List[str]] = None
|
| 21 |
-
includeCharts: bool = True
|
| 22 |
-
includeTOC: bool = True
|
| 23 |
-
|
| 24 |
-
class ExportResponse(BaseModel):
|
| 25 |
-
"""Response model for document export"""
|
| 26 |
-
success: bool
|
| 27 |
-
message: str
|
| 28 |
-
filename: Optional[str] = None
|
| 29 |
-
size: Optional[int] = None
|
| 30 |
-
downloadUrl: Optional[str] = None
|
| 31 |
-
documentData: Optional[str] = None
|
| 32 |
-
|
| 33 |
-
class SectionJob(BaseModel):
|
| 34 |
-
"""Job model for section generation"""
|
| 35 |
-
businessPlanId: str
|
| 36 |
-
sectionKey: str
|
| 37 |
-
businessIdea: str
|
| 38 |
-
countryCode: Optional[str] = None
|
| 39 |
-
defaultCountry: Optional[str] = None
|
| 40 |
-
|
| 41 |
-
class BatchGenerateRequest(BaseModel):
|
| 42 |
-
"""Request model for batch generation"""
|
| 43 |
-
businessPlanId: str
|
| 44 |
-
businessIdea: str
|
| 45 |
-
sections: List[str]
|
| 46 |
-
countryCode: Optional[str] = None
|
| 47 |
-
defaultCountry: Optional[str] = None
|
| 48 |
-
|
| 49 |
-
class GenerateRequest(BaseModel):
|
| 50 |
-
"""Request model for single section generation"""
|
| 51 |
-
businessPlanId: str
|
| 52 |
-
sectionKey: str
|
| 53 |
-
businessIdea: str
|
| 54 |
-
countryCode: Optional[str] = None
|
| 55 |
-
defaultCountry: Optional[str] = None
|
| 56 |
-
|
| 57 |
-
class CreatePlanRequest(BaseModel):
|
| 58 |
-
"""Request model for creating a new business plan"""
|
| 59 |
-
businessIdea: str
|
| 60 |
-
countryCode: Optional[str] = None
|
| 61 |
-
defaultCountry: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/utils.py
DELETED
|
@@ -1,42 +0,0 @@
|
|
| 1 |
-
# Utility functions shared across modules
|
| 2 |
-
# This file helps break circular imports between main.py and pdf_generator.py
|
| 3 |
-
|
| 4 |
-
from typing import List
|
| 5 |
-
from models import ExportSection
|
| 6 |
-
from constants import SECTION_ORDER
|
| 7 |
-
|
| 8 |
-
def sort_sections_by_order(sections: List[ExportSection]) -> List[ExportSection]:
|
| 9 |
-
"""Sort sections according to the predefined SECTION_ORDER to ensure correct numerical ordering"""
|
| 10 |
-
# Create a mapping from section title to order index
|
| 11 |
-
order_map = {title: index for index, title in enumerate(SECTION_ORDER)}
|
| 12 |
-
|
| 13 |
-
def get_section_order(section: ExportSection) -> int:
|
| 14 |
-
# Try to match by exact title first
|
| 15 |
-
if section.title in order_map:
|
| 16 |
-
order_index = order_map[section.title]
|
| 17 |
-
return order_index
|
| 18 |
-
|
| 19 |
-
# Try case-insensitive matching
|
| 20 |
-
section_title_lower = section.title.lower()
|
| 21 |
-
for order_title, order_index in order_map.items():
|
| 22 |
-
if order_title.lower() == section_title_lower:
|
| 23 |
-
return order_index
|
| 24 |
-
|
| 25 |
-
# Try to match by key (remove 'prompt_' prefix if present)
|
| 26 |
-
key_clean = section.key.replace('prompt_', '') if section.key.startswith('prompt_') else section.key
|
| 27 |
-
if key_clean in order_map:
|
| 28 |
-
order_index = order_map[key_clean]
|
| 29 |
-
return order_index
|
| 30 |
-
|
| 31 |
-
# Try partial matching for keys
|
| 32 |
-
for order_title, order_index in order_map.items():
|
| 33 |
-
if key_clean.lower() in order_title.lower() or order_title.lower() in key_clean.lower():
|
| 34 |
-
return order_index
|
| 35 |
-
|
| 36 |
-
# If no match found, put at the end
|
| 37 |
-
return len(SECTION_ORDER)
|
| 38 |
-
|
| 39 |
-
# Sort sections by their order
|
| 40 |
-
sorted_sections = sorted(sections, key=get_section_order)
|
| 41 |
-
|
| 42 |
-
return sorted_sections
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/logos/MBD logo.png
DELETED
|
Binary file (23.2 kB)
|
|
|
package-lock.json
DELETED
|
@@ -1,6 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"name": "planify",
|
| 3 |
-
"lockfileVersion": 3,
|
| 4 |
-
"requires": true,
|
| 5 |
-
"packages": {}
|
| 6 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|