devZenaight commited on
Commit
4e3de36
·
1 Parent(s): c0fc75b

Revert "Merge branch 'main' of https://huggingface.co/spaces/Zenaight/planify"

Browse files

This reverts commit c0fc75bb4bdd67a034bf624d7991da5a639775f3, reversing
changes made to d4b6186c2f8321449b6bf3c3cb8ba723212e68df.

app/CurrencyMapping/__init__.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,538 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,384 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,304 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,759 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,682 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
package-lock.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "name": "planify",
3
+ "lockfileVersion": 3,
4
+ "requires": true,
5
+ "packages": {}
6
+ }