TiH0 commited on
Commit
7f2c329
·
verified ·
1 Parent(s): 16ce9a3

Create prof.py

Browse files
Files changed (1) hide show
  1. prof.py +1490 -0
prof.py ADDED
@@ -0,0 +1,1490 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import html
2
+ import os
3
+ import re
4
+ import pandas as pd
5
+ from docx import Document
6
+ from docx.shared import Pt, Cm, Inches, RGBColor
7
+ from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_TAB_ALIGNMENT, WD_TAB_LEADER
8
+ from docx.enum.table import WD_TABLE_ALIGNMENT
9
+ from docx.enum.style import WD_STYLE_TYPE
10
+ from docx.enum.section import WD_SECTION
11
+ from docx.oxml import parse_xml
12
+ from docx.oxml.ns import nsdecls
13
+ from docx.oxml.shared import OxmlElement, qn
14
+
15
+
16
+ THEME_COLOR_HEX = "CC00CC" # Hex version for XML elements
17
+ THEME_COLOR = RGBColor.from_string(THEME_COLOR_HEX)
18
+
19
+
20
+ def set_page_size(section, width_inches, height_inches):
21
+ """Set custom page size for a section"""
22
+ sectPr = section._sectPr
23
+
24
+ # Create or get pgSz element
25
+ pgSz = sectPr.find(qn('w:pgSz'))
26
+ if pgSz is None:
27
+ pgSz = OxmlElement('w:pgSz')
28
+ sectPr.insert(0, pgSz)
29
+
30
+ # Convert inches to twentieths of a point (1 inch = 1440 twips)
31
+ width_twips = int(width_inches * 1440)
32
+ height_twips = int(height_inches * 1440)
33
+
34
+ pgSz.set(qn('w:w'), str(width_twips))
35
+ pgSz.set(qn('w:h'), str(height_twips))
36
+
37
+
38
+ # Common paper sizes (width x height in inches)
39
+ PAPER_SIZES = {
40
+ 'LETTER': (8.5, 11), # US Letter
41
+ 'A4': (8.27, 11.69), # A4
42
+ 'A4_WIDE': (8.77, 11.69),
43
+ 'A3': (11.69, 16.54), # A3
44
+ 'A5': (5.83, 8.27), # A5
45
+ 'LEGAL': (8.5, 14), # US Legal
46
+ 'TABLOID': (11, 17), # Tabloid
47
+ 'LEDGER': (17, 11), # Ledger
48
+ }
49
+
50
+
51
+ def add_colored_separator(doc, color="FF0000"):
52
+ # Add a vertical line shape between columns (fake separator)
53
+ section = doc.sections[0]
54
+ page_width = section.page_width
55
+ left_margin = section.left_margin
56
+ right_margin = section.right_margin
57
+
58
+ # Calculate midpoint between columns
59
+ usable_width = page_width - left_margin - right_margin
60
+ mid_x = left_margin + usable_width // 2
61
+
62
+ # Add a vertical rectangle (shape)
63
+ shape_xml = f"""
64
+ <w:pict xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
65
+ xmlns:v="urn:schemas-microsoft-com:vml">
66
+ <v:rect style="position:absolute;left:{mid_x//20}pt;width:1pt;height:800pt"
67
+ fillcolor="#{color}" stroked="f"/>
68
+ </w:pict>
69
+ """
70
+ paragraph = doc.add_paragraph()
71
+ paragraph._element.append(parse_xml(shape_xml))
72
+
73
+ return doc
74
+
75
+
76
+ def set_two_column_layout(doc, add_separator_line=True, balance_columns=True):
77
+ """Set the document to use a two-column layout with optional separator line and column balancing"""
78
+ # Get the current section
79
+ section = doc.sections[0]
80
+
81
+ # Create sectPr element if it doesn't exist
82
+ sectPr = section._sectPr
83
+
84
+ # Create cols element for columns
85
+ cols = sectPr.find(qn('w:cols'))
86
+ if cols is None:
87
+ cols = OxmlElement('w:cols')
88
+ sectPr.append(cols)
89
+
90
+ # Set number of columns to 2
91
+ cols.set(qn('w:num'), '2')
92
+
93
+ # Set space between columns (reduced for better space utilization)
94
+ cols.set(qn('w:space'), '432') # 0.3 inch in twentieths of a point (was 708)
95
+
96
+ # Add separator line between columns if requested
97
+ if add_separator_line:
98
+ cols.set(qn('w:sep'), '1') # This adds the vertical separator line
99
+
100
+ # Enable column balancing if requested
101
+ if balance_columns:
102
+ cols.set(qn('w:equalWidth'), '1') # Equal width columns
103
+
104
+ return doc
105
+
106
+
107
+ def set_cell_borders(cell, top=False, bottom=False, left=False, right=False):
108
+ """Set specific borders for a table cell"""
109
+ from docx.oxml import parse_xml
110
+ from docx.oxml.ns import nsdecls
111
+
112
+ # Get the cell's table cell properties
113
+ tcPr = cell._tc.get_or_add_tcPr()
114
+
115
+ # Create borders element
116
+ tcBorders = tcPr.find(qn('w:tcBorders'))
117
+ if tcBorders is None:
118
+ tcBorders = parse_xml(f'<w:tcBorders {nsdecls("w")}></w:tcBorders>')
119
+ tcPr.append(tcBorders)
120
+
121
+ # Define border settings
122
+ border_settings = {
123
+ 'top': top,
124
+ 'bottom': bottom,
125
+ 'left': left,
126
+ 'right': right
127
+ }
128
+
129
+ for border_name, should_show in border_settings.items():
130
+ border_element = tcBorders.find(qn(f'w:{border_name}'))
131
+ if border_element is not None:
132
+ tcBorders.remove(border_element)
133
+
134
+ if should_show:
135
+ # Create visible border
136
+ border_xml = f'<w:{border_name} {nsdecls("w")} w:val="single" w:sz="4" w:space="0" w:color="000000"/>'
137
+ border_element = parse_xml(border_xml)
138
+ tcBorders.append(border_element)
139
+ # If should_show is False, don't add any border element (let table-level borders show through)
140
+
141
+
142
+ def continue_two_column_layout(doc):
143
+ """Continue with the existing two-column layout for answer tables"""
144
+ # Add a column break to start fresh in the columns
145
+ add_column_break(doc)
146
+ return doc
147
+
148
+
149
+ def add_column_break(doc):
150
+ """Add a column break to move to the next column"""
151
+ para = doc.add_paragraph()
152
+ run = para.runs[0] if para.runs else para.add_run()
153
+
154
+ # Create column break element
155
+ br = OxmlElement('w:br')
156
+ br.set(qn('w:type'), 'column')
157
+ run._element.append(br)
158
+
159
+
160
+ def add_page_break(doc):
161
+ """Add a page break to the document"""
162
+ doc.add_page_break()
163
+
164
+
165
+ def create_course_title(doc, course_number, course_title, theme_color=None):
166
+ """Create a course title section in the document and return the paragraph object"""
167
+ if theme_color is None:
168
+ theme_color = THEME_COLOR
169
+
170
+ # Add minimal space before course title
171
+ course_para = doc.add_paragraph()
172
+ course_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
173
+
174
+ # Reduce spacing before and after
175
+ course_para.paragraph_format.space_before = Pt(6)
176
+ course_para.paragraph_format.space_after = Pt(3)
177
+ course_para.paragraph_format.keep_with_next = True # Keep course title with first question
178
+ course_para.paragraph_format.keep_together = True # Prevent splitting within paragraph
179
+
180
+ course_run = course_para.add_run(f"{course_number}. {course_title}")
181
+ course_run.font.name = 'Montserrat'
182
+ course_run.font.size = Pt(13)
183
+ course_run.font.bold = True
184
+ course_run.font.color.rgb = None
185
+ course_run.font.color.rgb = theme_color
186
+
187
+ return course_para
188
+
189
+
190
+ def format_question_block(doc, question_num, question_text, choices, correct_answers, source, comment=None, theme_color=None):
191
+ """Format a single question block with reduced spacing and keep together formatting"""
192
+ if theme_color is None:
193
+ theme_color = THEME_COLOR
194
+
195
+ if 'TinySpace' not in doc.styles:
196
+ tiny_style = doc.styles.add_style('TinySpace', WD_STYLE_TYPE.PARAGRAPH)
197
+ tiny_style.font.name = 'SF Pro'
198
+ tiny_style.font.size = Pt(5)
199
+ tiny_style.paragraph_format.line_spacing = Pt(5)
200
+ tiny_style.paragraph_format.space_before = Pt(0)
201
+ tiny_style.paragraph_format.space_after = Pt(0)
202
+
203
+ # Question title with reduced spacing and keep-together formatting
204
+ question_para = doc.add_paragraph()
205
+ question_para.paragraph_format.space_before = Pt(1)
206
+ question_para.paragraph_format.space_after = Pt(0)
207
+ question_para.paragraph_format.keep_with_next = True # Keep question with choices
208
+ question_para.paragraph_format.keep_together = True # Prevent splitting within paragraph
209
+ question_para.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
210
+
211
+ # Question number in Axiforma Black
212
+ num_run = question_para.add_run(f"{question_num}. ")
213
+ num_run.font.name = 'Inter ExtraBold'
214
+ num_run.font.size = Pt(10)
215
+ num_run.font.bold = True
216
+ num_run.font.color.rgb = theme_color
217
+
218
+ # Question text in SF UI Display Med
219
+ text_run = question_para.add_run(question_text)
220
+ text_run.font.name = 'Inter ExtraBold'
221
+ text_run.font.size = Pt(10)
222
+
223
+ # Display ALL choices for this question with minimal spacing
224
+ choice_paragraphs = []
225
+ for i, (choice_letter, choice_text) in enumerate(choices):
226
+ choice_para = doc.add_paragraph()
227
+ choice_para.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
228
+ choice_para.paragraph_format.space_before = Pt(1)
229
+ choice_para.paragraph_format.space_after = Pt(1)
230
+ choice_para.paragraph_format.keep_together = True # Prevent splitting within paragraph
231
+
232
+ # Keep all choices together, and keep the last choice with the source
233
+ if i < len(choices) - 1:
234
+ choice_para.paragraph_format.keep_with_next = True
235
+ else:
236
+ # Last choice should stay with source line
237
+ choice_para.paragraph_format.keep_with_next = True
238
+
239
+ # Ensure each choice ends with a dot
240
+ if not str(choice_text).strip().endswith('.'):
241
+ choice_text = str(choice_text).strip() + '.'
242
+
243
+ choice_run = choice_para.add_run(f"{choice_letter}- {choice_text}")
244
+ choice_run.font.name = 'Inter Display Medium'
245
+ choice_run.font.size = Pt(10)
246
+
247
+ choice_paragraphs.append(choice_para)
248
+
249
+ # Source and Answer line (using table for side-by-side layout)
250
+ # Create a single-row, 2-column table
251
+ info_table = doc.add_table(rows=1, cols=2)
252
+ info_table.alignment = WD_TABLE_ALIGNMENT.LEFT
253
+ info_table.allow_autofit = False # Disable autofit for manual control
254
+
255
+ # Remove all borders
256
+ tblPr = info_table._tbl.tblPr
257
+ if tblPr is None:
258
+ tblPr = OxmlElement('w:tblPr')
259
+ info_table._tbl.insert(0, tblPr)
260
+
261
+ # Set borders to none
262
+ tblBorders = parse_xml(f'''
263
+ <w:tblBorders {nsdecls("w")}>
264
+ <w:top w:val="none"/>
265
+ <w:left w:val="none"/>
266
+ <w:bottom w:val="none"/>
267
+ <w:right w:val="none"/>
268
+ <w:insideH w:val="none"/>
269
+ <w:insideV w:val="none"/>
270
+ </w:tblBorders>
271
+ ''')
272
+ tblPr.append(tblBorders)
273
+
274
+ # ADD THIS: Set column widths - left cell small, right cell takes remaining space
275
+ left_cell = info_table.rows[0].cells[0]
276
+ right_cell = info_table.rows[0].cells[1]
277
+
278
+ # Set left cell to fit content (adjust width as needed, e.g., 1.5 inches)
279
+ left_cell.width = Inches(1.65) # Adjust this value to fit "Réponse: ABC" or "Réponse: /"
280
+
281
+ # Set right cell to take remaining space (wider for source)
282
+ # The exact value doesn't matter much as it will fill remaining space
283
+ right_cell.width = Inches(4.5) # Remaining space for source
284
+
285
+ # Set spacing
286
+ left_cell._element.get_or_add_tcPr()
287
+ right_cell._element.get_or_add_tcPr()
288
+
289
+ # LEFT cell - Answer
290
+ left_para = left_cell.paragraphs[0]
291
+ left_para.alignment = WD_ALIGN_PARAGRAPH.LEFT
292
+ left_para.paragraph_format.space_before = Pt(2)
293
+ left_para.paragraph_format.space_after = Pt(2)
294
+
295
+ # Prepare answer text
296
+ if len(correct_answers) == 0:
297
+ correct_answers = "/"
298
+
299
+ answer_run = left_para.add_run("Réponse:")
300
+ answer_run.font.name = 'Inter SemiBold'
301
+ answer_run.font.size = Pt(8)
302
+ answer_run.font.bold = True
303
+ answer_run.font.underline = True
304
+
305
+ answer_run = left_para.add_run(f' {correct_answers}')
306
+ answer_run.font.name = 'Inter Display Medium'
307
+ answer_run.font.size = Pt(8)
308
+ answer_run.font.color.rgb = theme_color
309
+
310
+ # RIGHT cell - Source
311
+ right_para = right_cell.paragraphs[0]
312
+ right_para.alignment = WD_ALIGN_PARAGRAPH.RIGHT
313
+ right_para.paragraph_format.space_before = Pt(2)
314
+ right_para.paragraph_format.space_after = Pt(2)
315
+
316
+ source_run = right_para.add_run("Source:")
317
+ source_run.font.name = 'Inter SemiBold'
318
+ source_run.font.size = Pt(8)
319
+ source_run.font.bold = True
320
+ source_run.font.underline = True
321
+
322
+ source_value_run = right_para.add_run(f" {source}")
323
+ source_value_run.font.name = 'Inter Display Medium'
324
+ source_value_run.font.size = Pt(8)
325
+ source_value_run.font.color.rgb = theme_color
326
+
327
+ # Keep with comment if exists
328
+ if comment and str(comment).strip() and str(comment).lower() != 'nan':
329
+ right_para.paragraph_format.keep_with_next = True
330
+
331
+ empty_para = doc.add_paragraph(' ', style='TinySpace')
332
+ empty_para.paragraph_format.space_before = Pt(0)
333
+ empty_para.paragraph_format.space_after = Pt(0)
334
+ empty_para.paragraph_format.line_spacing = Pt(5)
335
+ empty_run = empty_para.add_run(' ')
336
+ empty_run.font.size = Pt(5)
337
+
338
+ # Add comment if exists
339
+ if comment and str(comment).strip() and str(comment).lower() != 'nan':
340
+ comment_para = doc.add_paragraph()
341
+ comment_para.paragraph_format.left_indent = Inches(0.2)
342
+ comment_para.paragraph_format.space_before = Pt(1)
343
+ comment_para.paragraph_format.space_after = Pt(2)
344
+ comment_para.paragraph_format.keep_together = True # Prevent splitting within paragraph
345
+ # Comment is the last element, so no keep_with_next needed
346
+
347
+ comment_run = comment_para.add_run(f"Commentaire : {comment}")
348
+ comment_run.font.name = 'Inter Display'
349
+ comment_run.font.size = Pt(6)
350
+ comment_run.font.italic = True
351
+
352
+
353
+ def add_page_numbers(doc, theme_hex=None):
354
+ """Add page numbers to the footer of all pages (keeps existing module headers), starting from page 1 after TOC."""
355
+ if theme_hex is None:
356
+ theme_hex = THEME_COLOR_HEX
357
+
358
+ for section_idx, section in enumerate(doc.sections):
359
+ # ===== HEADER (keep existing text like module name) =====
360
+ header = section.header
361
+ header.is_linked_to_previous = False
362
+ section.header_distance = Cm(0.6)
363
+
364
+ # If header is empty, add a blank paragraph
365
+ if not header.paragraphs:
366
+ header.add_paragraph()
367
+
368
+ # ===== FOOTER (page numbers + TOC link) =====
369
+ footer = section.footer
370
+ footer.is_linked_to_previous = False
371
+ section.footer_distance = Cm(0.5) # Distance from bottom of page to footer
372
+
373
+ # Clear existing text in footer
374
+ if footer.paragraphs:
375
+ footer.paragraphs[0].clear()
376
+ else:
377
+ footer.add_paragraph()
378
+
379
+ # Skip page numbers for the first section (TOC)
380
+ if section_idx == 0:
381
+ continue
382
+
383
+ # For the second section (first content page), restart numbering at 1
384
+ if section_idx == 1:
385
+ sectPr = section._sectPr
386
+ pgNumType = sectPr.find(qn('w:pgNumType'))
387
+ if pgNumType is None:
388
+ pgNumType = OxmlElement('w:pgNumType')
389
+ sectPr.append(pgNumType)
390
+ pgNumType.set(qn('w:start'), '1') # Start at page 1
391
+
392
+ # Add an empty line above the page number
393
+ empty_para = footer.paragraphs[0]
394
+ empty_para.paragraph_format.space_before = Pt(0)
395
+ empty_para.paragraph_format.space_after = Pt(0)
396
+ empty_para.paragraph_format.line_spacing = 1.0
397
+
398
+ # Add the page number paragraph
399
+ paragraph = footer.add_paragraph()
400
+ paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
401
+
402
+ # Set vertical alignment to center
403
+ paragraph.paragraph_format.space_before = Pt(0)
404
+ paragraph.paragraph_format.space_after = Pt(0)
405
+
406
+ # Add page number in center
407
+ run = paragraph.add_run()
408
+
409
+ # Create the PAGE field
410
+ fldChar1 = OxmlElement('w:fldChar')
411
+ fldChar1.set(qn('w:fldCharType'), 'begin')
412
+
413
+ instrText = OxmlElement('w:instrText')
414
+ instrText.set(qn('xml:space'), 'preserve')
415
+ instrText.text = "PAGE"
416
+
417
+ fldChar2 = OxmlElement('w:fldChar')
418
+ fldChar2.set(qn('w:fldCharType'), 'end')
419
+
420
+ run._r.append(fldChar1)
421
+ run._r.append(instrText)
422
+ run._r.append(fldChar2)
423
+
424
+ run.font.name = 'Montserrat'
425
+ run.font.size = Pt(12)
426
+ run.font.bold = True
427
+ run.font.color.rgb = RGBColor(0, 0, 0)
428
+
429
+ # ===== ADD TOC LINK IN TEXT BOX (BOTTOM RIGHT) =====
430
+ # Create TOC link text box similar to header style
431
+ toc_textbox_xml = f'''
432
+ <w:r xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
433
+ xmlns:v="urn:schemas-microsoft-com:vml"
434
+ xmlns:w10="urn:schemas-microsoft-com:office:word">
435
+ <w:pict>
436
+ <v:shape style="position:absolute;margin-left:0in;margin-top:0;width:60pt;height:20pt;z-index:1;mso-position-horizontal:right;mso-position-horizontal-relative:margin;mso-position-vertical-relative:line" fillcolor="#FFFFFF" filled="f" stroked="f">
437
+ <v:textbox inset="5pt,0pt,5pt,0pt" style="mso-fit-shape-to-text:t">
438
+ <w:txbxContent>
439
+ <w:p>
440
+ <w:pPr>
441
+ <w:jc w:val="right"/>
442
+ <w:spacing w:before="0" w:after="0"/>
443
+ </w:pPr>
444
+ <w:hyperlink w:anchor="TOC_BOOKMARK">
445
+ <w:r>
446
+ <w:rPr>
447
+ <w:rFonts w:ascii="Aptos" w:hAnsi="Aptos"/>
448
+ <w:sz w:val="28"/>
449
+ <w:color w:val="{theme_hex}"/>
450
+ <w:u w:val="single"/>
451
+ </w:rPr>
452
+ <w:t>↗️</w:t>
453
+ </w:r>
454
+ <w:r>
455
+ <w:rPr>
456
+ <w:rFonts w:ascii="Montserrat" w:hAnsi="Montserrat"/>
457
+ <w:b/>
458
+ <w:sz w:val="18"/>
459
+ <w:color w:val="{theme_hex}"/>
460
+ <w:u w:val="single"/>
461
+ </w:rPr>
462
+ <w:t> SOM</w:t>
463
+ </w:r>
464
+ </w:hyperlink>
465
+ </w:p>
466
+ </w:txbxContent>
467
+ </v:textbox>
468
+ </v:shape>
469
+ </w:pict>
470
+ </w:r>
471
+ '''
472
+
473
+ toc_textbox_element = parse_xml(toc_textbox_xml)
474
+ paragraph._p.append(toc_textbox_element)
475
+
476
+
477
+ def add_toc_bookmark(doc, toc_title_para):
478
+ """Add a bookmark to the TOC title paragraph"""
479
+ bookmark_start = OxmlElement('w:bookmarkStart')
480
+ bookmark_start.set(qn('w:id'), '0')
481
+ bookmark_start.set(qn('w:name'), 'TOC_BOOKMARK')
482
+ toc_title_para._p.insert(0, bookmark_start)
483
+
484
+ bookmark_end = OxmlElement('w:bookmarkEnd')
485
+ bookmark_end.set(qn('w:id'), '0')
486
+ toc_title_para._p.append(bookmark_end)
487
+
488
+
489
+ def set_module_header(doc, module_name):
490
+ """Update the top-left header text with the current module name."""
491
+ for section in doc.sections:
492
+ header = section.header
493
+ header.is_linked_to_previous = False
494
+
495
+ if not header.paragraphs:
496
+ header.add_paragraph()
497
+ header.paragraphs[0].clear()
498
+
499
+ para = header.paragraphs[0]
500
+ para.alignment = WD_ALIGN_PARAGRAPH.LEFT
501
+
502
+ run = para.add_run(f"{module_name.upper()}")
503
+ run.font.name = 'Montserrat'
504
+ run.font.size = Pt(10)
505
+ run.font.bold = True
506
+ run.font.color.rgb = RGBColor(0, 0, 0)
507
+
508
+
509
+ def set_zero_spacing(paragraph):
510
+ """Force paragraph spacing to 0 before and after."""
511
+ paragraph.paragraph_format.space_before = Pt(0)
512
+ paragraph.paragraph_format.space_after = Pt(0)
513
+
514
+
515
+ def is_valid_cours_number(cours_value):
516
+ """Check if cours value is valid (numeric and not 'S2')"""
517
+ if pd.isna(cours_value):
518
+ return False
519
+
520
+ cours_str = str(cours_value).strip().upper()
521
+
522
+ # Skip S2 courses and other specific invalid values
523
+ if cours_str in ['S2', 'NAN', '']:
524
+ return False
525
+
526
+ # Try to convert to numeric - if it works and is positive, it's valid
527
+ try:
528
+ numeric_value = float(cours_str)
529
+ # Check if it's a positive number (courses should be positive integers)
530
+ return numeric_value > 0 and numeric_value == int(numeric_value)
531
+ except (ValueError, TypeError, OverflowError):
532
+ return False
533
+
534
+
535
+ def check_if_course_has_e_choices(course_questions):
536
+ """Check if any question in the course has an E choice"""
537
+ for q_data in course_questions:
538
+ for choice in q_data['choices']:
539
+ if choice['letter'].upper() == 'E':
540
+ return True
541
+ return False
542
+
543
+
544
+ def sanitize_bookmark_name(text):
545
+ """Create a safe bookmark name (letters, numbers, underscores)."""
546
+ name = re.sub(r'[^A-Za-z0-9_]', '_', str(text))
547
+ # Word has bookmark name length limits — keep it short
548
+ return name[:40]
549
+
550
+
551
+ def add_bookmark_to_paragraph(paragraph, bookmark_name, bm_id):
552
+ """Wrap the paragraph with a Word bookmark (start & end)."""
553
+ # bookmarkStart: should be before the paragraph text
554
+ bookmark_start = OxmlElement('w:bookmarkStart')
555
+ bookmark_start.set(qn('w:id'), str(bm_id))
556
+ bookmark_start.set(qn('w:name'), bookmark_name)
557
+ paragraph._p.insert(0, bookmark_start)
558
+
559
+ # bookmarkEnd: appended after paragraph content
560
+ bookmark_end = OxmlElement('w:bookmarkEnd')
561
+ bookmark_end.set(qn('w:id'), str(bm_id))
562
+ paragraph._p.append(bookmark_end)
563
+
564
+
565
+ def add_pagenumber_field_in_paragraph(paragraph, bookmark_name, right_inch=Inches(6.5)):
566
+ """
567
+ Insert a PAGEREF field pointing to bookmark_name.
568
+ This function also adds a right tab stop with dotted leader and a tab character
569
+ so the page number appears at the right edge with dot leaders.
570
+ """
571
+ # add a right aligned tab stop with dots
572
+ try:
573
+ paragraph.paragraph_format.tab_stops.add_tab_stop(right_inch, WD_TAB_ALIGNMENT.RIGHT, WD_TAB_LEADER.DOTS)
574
+ except Exception:
575
+ # If the tab_stop API differs, ignore and still try to insert the field
576
+ pass
577
+
578
+ # Add a tab character so the PAGEREF sits at the right tab stop
579
+ tab_run = paragraph.add_run('\t')
580
+
581
+ # Create field: begin -> instrText -> end
582
+ fldChar1 = OxmlElement('w:fldChar'); fldChar1.set(qn('w:fldCharType'), 'begin')
583
+ instrText = OxmlElement('w:instrText'); instrText.set(qn('xml:space'), 'preserve')
584
+ instrText.text = f"PAGEREF {bookmark_name} \\h"
585
+ fldChar2 = OxmlElement('w:fldChar'); fldChar2.set(qn('w:fldCharType'), 'end')
586
+
587
+ tab_run._r.append(fldChar1)
588
+ tab_run._r.append(instrText)
589
+ tab_run._r.append(fldChar2)
590
+
591
+
592
+
593
+
594
+ def estimate_content_length(questions_by_course, cours_titles):
595
+ """Estimate relative content length for each question to better balance columns"""
596
+ question_lengths = []
597
+ total_estimated_lines = 0
598
+
599
+ for cours_num in sorted(questions_by_course.keys()):
600
+ course_questions = questions_by_course[cours_num]
601
+ course_title = cours_titles.get(cours_num, f"COURSE {cours_num}")
602
+
603
+ # Add course title weight (approximately 2-3 lines)
604
+ course_weight = 3
605
+ total_estimated_lines += course_weight
606
+
607
+ for q_data in course_questions:
608
+ # Estimate lines for this question
609
+ question_lines = 2 # Question line + spacing
610
+ question_lines += len(q_data['choices']) # Choice lines
611
+ question_lines += 2 # Source/answer line + spacing
612
+
613
+ if q_data.get('comment') and str(q_data['comment']).strip() and str(q_data['comment']).lower() != 'nan':
614
+ question_lines += 2 # Comment lines
615
+
616
+ question_lengths.append({
617
+ 'cours': cours_num,
618
+ 'question': q_data,
619
+ 'estimated_lines': question_lines
620
+ })
621
+ total_estimated_lines += question_lines
622
+
623
+ return question_lengths, total_estimated_lines
624
+
625
+
626
+ def read_course_titles_from_module_sheet(excel_file_path, module_name):
627
+ """Read course titles from a module-specific sheet (case-insensitive)"""
628
+ cours_titles = {}
629
+
630
+ print(f" DEBUG: Looking for sheet matching module '{module_name}'")
631
+
632
+ # Get all sheet names from the Excel file
633
+ xls = pd.ExcelFile(excel_file_path)
634
+ sheet_names = xls.sheet_names
635
+
636
+ # Find matching sheet (case-insensitive)
637
+ target_sheet = None
638
+ module_name_lower = str(module_name).strip().lower()
639
+
640
+ print(f" DEBUG: Module name (lowercase): '{module_name_lower}'")
641
+ print(f" DEBUG: Available sheets: {sheet_names}")
642
+
643
+ for sheet in sheet_names:
644
+ sheet_lower = sheet.strip().lower()
645
+ print(f" DEBUG: Comparing '{module_name_lower}' with '{sheet_lower}'")
646
+ if sheet_lower == module_name_lower:
647
+ target_sheet = sheet
648
+ print(f" DEBUG: MATCH FOUND! Using sheet '{target_sheet}'")
649
+ break
650
+
651
+ if target_sheet is None:
652
+ print(f" DEBUG: No sheet found matching module '{module_name}'")
653
+ return cours_titles
654
+
655
+ # Read the matching sheet
656
+ cours_df = pd.read_excel(excel_file_path, sheet_name=target_sheet)
657
+ print(f" DEBUG: Sheet '{target_sheet}' has {len(cours_df)} rows")
658
+ print(f" DEBUG: Sheet columns: {list(cours_df.columns)}")
659
+
660
+ if not cours_df.empty and 'cours' in cours_df.columns and 'titre' in cours_df.columns:
661
+ for idx, row in cours_df.iterrows():
662
+ print(f" DEBUG: Row {idx}: cours={row['cours']}, titre={row.get('titre', 'N/A')}")
663
+ if pd.notna(row['cours']) and pd.notna(row['titre']):
664
+ # Only store valid numeric courses
665
+ if is_valid_cours_number(row['cours']):
666
+ cours_num = int(float(str(row['cours']).strip()))
667
+ cours_titles[cours_num] = row['titre']
668
+ print(f" DEBUG: Added cours {cours_num}: {row['titre']}")
669
+ else:
670
+ print(f" DEBUG: Skipped invalid cours: {row['cours']}")
671
+ print(f" DEBUG: Final count: {len(cours_titles)} course titles from sheet '{target_sheet}'")
672
+ else:
673
+ print(f" DEBUG: Sheet '{target_sheet}' doesn't have expected structure")
674
+ print(f" DEBUG: Has 'cours' column: {'cours' in cours_df.columns}")
675
+ print(f" DEBUG: Has 'titre' column: {'titre' in cours_df.columns}")
676
+
677
+ return cours_titles
678
+
679
+
680
+ def extract_display_name_from_excel(excel_file_path):
681
+ """Extract display name from Excel file - checks multiple locations"""
682
+ try:
683
+ xls = pd.ExcelFile(excel_file_path)
684
+ first_sheet_name = xls.sheet_names[0]
685
+ df = pd.read_excel(excel_file_path, sheet_name=first_sheet_name, nrows=5)
686
+
687
+ # Strategy 1: Look for a cell with "Name:", "Display Name:", etc.
688
+ for col in df.columns:
689
+ for idx, val in df[col].items():
690
+ if pd.notna(val):
691
+ val_str = str(val).strip().lower()
692
+ if any(keyword in val_str for keyword in ['name:', 'nom:', 'display name:', 'titre:']):
693
+ # Get the value from next cell or same row
694
+ try:
695
+ if ':' in str(val):
696
+ return str(val).split(':', 1)[1].strip()
697
+ elif idx + 1 < len(df):
698
+ next_val = df[col].iloc[idx + 1]
699
+ if pd.notna(next_val):
700
+ return str(next_val).strip()
701
+ except:
702
+ pass
703
+
704
+ # Strategy 2: Check for a dedicated "Info" or "Metadata" sheet
705
+ for sheet_name in xls.sheet_names:
706
+ if any(keyword in sheet_name.lower() for keyword in ['info', 'metadata', 'details', 'nom']):
707
+ info_df = pd.read_excel(excel_file_path, sheet_name=sheet_name, nrows=10)
708
+ for col in info_df.columns:
709
+ for idx, val in info_df[col].items():
710
+ if pd.notna(val) and 'name' in str(val).lower():
711
+ if idx + 1 < len(info_df):
712
+ next_val = info_df[col].iloc[idx + 1]
713
+ if pd.notna(next_val):
714
+ return str(next_val).strip()
715
+
716
+ # Strategy 3: Check first cell of first sheet
717
+ if not df.empty and pd.notna(df.iloc[0, 0]):
718
+ first_cell = str(df.iloc[0, 0]).strip()
719
+ if len(first_cell) < 50 and not any(char.isdigit() for char in first_cell[:10]):
720
+ return first_cell
721
+
722
+ # Fallback: Use filename without extension
723
+ return os.path.splitext(os.path.basename(excel_file_path))[0]
724
+
725
+ except Exception as e:
726
+ print(f"Error extracting display name: {e}")
727
+ # Ultimate fallback
728
+ return os.path.splitext(os.path.basename(excel_file_path))[0]
729
+
730
+
731
+ def create_flexible_header(section, module_name, sheet_name, display_name=None, left_margin_inches=0, right_margin_inches=0, theme_hex=None):
732
+ """Create flexible header text boxes that adapt to content size"""
733
+ if theme_hex is None:
734
+ theme_hex = THEME_COLOR_HEX
735
+
736
+ header = section.header
737
+ header.is_linked_to_previous = False
738
+ section.header_distance = Cm(0.6)
739
+
740
+ if not header.paragraphs:
741
+ header.add_paragraph()
742
+
743
+ # Clear the first paragraph
744
+ header_para = header.paragraphs[0]
745
+ header_para.clear()
746
+
747
+ module_name_str = str(module_name).upper()
748
+
749
+ # Use display_name if provided, otherwise use sheet_name
750
+ if display_name:
751
+ sheet_name_str = str(display_name).upper()
752
+ else:
753
+ sheet_name_str = str(sheet_name).upper()
754
+
755
+ module_name_str = html.escape(module_name_str)
756
+ sheet_name_str = html.escape(sheet_name_str)
757
+
758
+ # Calculate approximate widths based on text length
759
+ # Rough estimate: ~7pt per character for Montserrat Bold size 10
760
+ module_width = max(len(module_name_str) * 9, 100) # Minimum 60pt
761
+ sheet_width = max(len(sheet_name_str) * 9, 100) # Minimum 60pt
762
+
763
+ # LEFT text box (module name) - flexible width
764
+ left_textbox_xml = f'''
765
+ <w:r xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
766
+ xmlns:v="urn:schemas-microsoft-com:vml"
767
+ xmlns:w10="urn:schemas-microsoft-com:office:word">
768
+ <w:pict>
769
+ <v:shape style="position:absolute;margin-left:{left_margin_inches}in;margin-top:0;width:{module_width}pt;height:25pt;z-index:1;mso-position-horizontal:left;mso-position-horizontal-relative:margin;mso-position-vertical-relative:line" fillcolor="#FFFFFF" filled="f" stroked="f">
770
+ <v:textbox inset="5pt,0pt,5pt,0pt" style="mso-fit-shape-to-text:t">
771
+ <w:txbxContent>
772
+ <w:p>
773
+ <w:pPr>
774
+ <w:jc w:val="left"/>
775
+ <w:spacing w:before="0" w:after="0"/>
776
+ </w:pPr>
777
+ <w:r>
778
+ <w:rPr>
779
+ <w:rFonts w:ascii="Montserrat" w:hAnsi="Montserrat"/>
780
+ <w:b/>
781
+ <w:sz w:val="20"/>
782
+ <w:color w:val="{theme_hex}"/>
783
+ </w:rPr>
784
+ <w:t>{module_name_str}</w:t>
785
+ </w:r>
786
+ </w:p>
787
+ </w:txbxContent>
788
+ </v:textbox>
789
+ </v:shape>
790
+ </w:pict>
791
+ </w:r>
792
+ '''
793
+
794
+ # RIGHT text box (sheet name) - flexible width
795
+ right_textbox_xml = f'''
796
+ <w:r xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
797
+ xmlns:v="urn:schemas-microsoft-com:vml"
798
+ xmlns:w10="urn:schemas-microsoft-com:office:word">
799
+ <w:pict>
800
+ <v:shape style="position:absolute;margin-left:{right_margin_inches}in;margin-top:0;width:{sheet_width}pt;height:25pt;z-index:1;mso-position-horizontal:right;mso-position-horizontal-relative:margin;mso-position-vertical-relative:line" fillcolor="#FFFFFF" filled="f" stroked="f">
801
+ <v:textbox inset="5pt,0pt,5pt,0pt" style="mso-fit-shape-to-text:t">
802
+ <w:txbxContent>
803
+ <w:p>
804
+ <w:pPr>
805
+ <w:jc w:val="right"/>
806
+ <w:spacing w:before="0" w:after="0"/>
807
+ </w:pPr>
808
+ <w:r>
809
+ <w:rPr>
810
+ <w:rFonts w:ascii="Montserrat" w:hAnsi="Montserrat"/>
811
+ <w:b/>
812
+ <w:sz w:val="20"/>
813
+ <w:color w:val="{theme_hex}"/>
814
+ </w:rPr>
815
+ <w:t>{sheet_name_str}</w:t>
816
+ </w:r>
817
+ </w:p>
818
+ </w:txbxContent>
819
+ </v:textbox>
820
+ </v:shape>
821
+ </w:pict>
822
+ </w:r>
823
+ '''
824
+
825
+ # Parse both XML elements
826
+ left_textbox_element = parse_xml(left_textbox_xml)
827
+ right_textbox_element = parse_xml(right_textbox_xml)
828
+
829
+ # Append BOTH text boxes to the SAME paragraph
830
+ header_para._p.append(left_textbox_element)
831
+ header_para._p.append(right_textbox_element)
832
+
833
+
834
+ def process_excel_to_word(excel_file_path, output_word_path, display_name=None, use_two_columns=True, add_separator_line=True, balance_method="dynamic", theme_hex=None):
835
+ """Main function to process Excel and create a Word document with TOC on the first page"""
836
+
837
+ if theme_hex is None:
838
+ theme_hex = THEME_COLOR_HEX
839
+ theme_color = RGBColor.from_string(theme_hex)
840
+
841
+ # Read the Excel file
842
+ xls = pd.ExcelFile(excel_file_path)
843
+ first_sheet_name = xls.sheet_names[0] # Get the first sheet name
844
+ questions_df = pd.read_excel(excel_file_path, sheet_name=first_sheet_name)
845
+
846
+ # Extract display name if not provided
847
+ if display_name is None:
848
+ display_name = extract_display_name_from_excel(excel_file_path)
849
+ print(f"Extracted display name: {display_name}")
850
+
851
+ # Get unique modules from Questions sheet (case-insensitive)
852
+ module_col = None
853
+ for col in questions_df.columns:
854
+ if col.lower().strip() == 'module':
855
+ module_col = col
856
+ break
857
+
858
+ if module_col:
859
+ xls_temp = pd.ExcelFile(excel_file_path)
860
+ all_sheets = xls_temp.sheet_names
861
+
862
+ modules_in_questions = questions_df[module_col].dropna().unique()
863
+
864
+ # Create a mapping from lowercase module name to actual sheet name
865
+ module_to_sheet = {}
866
+ for module in modules_in_questions:
867
+ module_lower = str(module).strip().lower()
868
+ for sheet in all_sheets:
869
+ if sheet.strip().lower() == module_lower:
870
+ module_to_sheet[module] = sheet
871
+ break
872
+
873
+ # Normalize all module names in the dataframe
874
+ questions_df[module_col] = questions_df[module_col].apply(
875
+ lambda x: module_to_sheet.get(x, x) if pd.notna(x) else x
876
+ )
877
+
878
+ # Get unique modules in sheet order
879
+ modules = []
880
+ seen = set()
881
+ for sheet in all_sheets:
882
+ sheet_lower = sheet.strip().lower()
883
+ for module in modules_in_questions:
884
+ if str(module).strip().lower() == sheet_lower and sheet not in seen:
885
+ modules.append(sheet)
886
+ seen.add(sheet)
887
+ break
888
+ else:
889
+ modules = []
890
+
891
+ # Read course titles from module-specific sheets
892
+ modules_data = {}
893
+ xls = pd.ExcelFile(excel_file_path)
894
+
895
+ for module in modules:
896
+ try:
897
+ cours_titles_for_module = read_course_titles_from_module_sheet(excel_file_path, module)
898
+ modules_data[module] = cours_titles_for_module
899
+ except Exception as e:
900
+ print(f"DEBUG: Error reading module '{module}': {e}")
901
+
902
+ # Clean column names
903
+ questions_df.columns = questions_df.columns.str.strip()
904
+
905
+ # Create Word document
906
+ doc = Document()
907
+
908
+ core_props = doc.core_properties
909
+ core_props.author = "Natural Killer"
910
+ core_props.title = "Manhattan Project"
911
+ core_props.subject = "QCM"
912
+ core_props.comments = "Created By NK"
913
+ core_props.last_modified_by = "NK"
914
+ core_props.generator = "Microsoft Word"
915
+
916
+ set_page_size(doc.sections[0], PAPER_SIZES['A4_WIDE'][0], PAPER_SIZES['A4'][1])
917
+
918
+ # ========================================
919
+ # ADD THREE EMPTY PAGES AT THE BEGINNING
920
+ # ========================================
921
+ for i in range(4):
922
+ doc.add_paragraph() # Add empty paragraph
923
+ if i < 3: # Add page breaks for first 2 pages (3rd page leads to TOC)
924
+ doc.add_page_break()
925
+
926
+ # TOC helpers
927
+ toc_entries = []
928
+ bookmark_id = 1
929
+
930
+ # Set page margins
931
+ for section in doc.sections:
932
+ section.top_margin = Inches(0.5)
933
+ section.bottom_margin = Inches(0.5)
934
+ section.left_margin = Cm(1.27)
935
+ section.right_margin = Cm(1.27)
936
+
937
+ # ========================================
938
+ # CREATE TOC SECTION FIRST (SINGLE COLUMN)
939
+ # ========================================
940
+ toc_section = doc.sections[0]
941
+ sectPr = toc_section._sectPr
942
+ cols = sectPr.find(qn('w:cols'))
943
+ if cols is None:
944
+ cols = OxmlElement('w:cols')
945
+ sectPr.append(cols)
946
+ cols.set(qn('w:num'), '1')
947
+
948
+ # Add TOC title
949
+ toc_title = doc.add_paragraph()
950
+ toc_title.alignment = WD_ALIGN_PARAGRAPH.CENTER
951
+ toc_title.paragraph_format.space_after = Pt(12)
952
+ toc_title_run = toc_title.add_run("Sommaire")
953
+ toc_title_run.font.name = 'Montserrat'
954
+ toc_title_run.font.size = Pt(16)
955
+ toc_title_run.font.bold = True
956
+ toc_title_run.font.color.rgb = theme_color
957
+
958
+ # Add bookmark to TOC title
959
+ add_toc_bookmark(doc, toc_title)
960
+
961
+ # Remember position to insert TOC entries later
962
+ toc_insert_index = len(doc.paragraphs)
963
+
964
+ # ========================================
965
+ # START NEW SECTION FOR CONTENT (TWO COLUMNS)
966
+ # ========================================
967
+ doc.add_section(WD_SECTION.NEW_PAGE)
968
+
969
+ # Process questions
970
+ processed_questions = []
971
+ current_question = None
972
+ current_choices = []
973
+ skipped_s2_questions = 0
974
+
975
+ for idx, row in questions_df.iterrows():
976
+ numero = row['Numero']
977
+
978
+ if pd.notna(numero):
979
+ if current_question is not None and current_choices and is_valid_cours_number(current_cours):
980
+ processed_questions.append({
981
+ 'numero': current_question,
982
+ 'question_text': current_question_text,
983
+ 'source': current_source,
984
+ 'comment': current_comment,
985
+ 'cours': int(float(str(current_cours).strip())),
986
+ 'module': current_module,
987
+ 'choices': current_choices.copy()
988
+ })
989
+ elif current_question is not None and not is_valid_cours_number(current_cours):
990
+ skipped_s2_questions += 1
991
+
992
+ current_question = numero
993
+ current_question_text = str(row['Question']).strip()
994
+ current_source = str(row['Source']).strip() if pd.notna(row['Source']) else ""
995
+ current_comment = str(row['Comment']).strip() if pd.notna(row['Comment']) and str(
996
+ row['Comment']).lower() != 'nan' else None
997
+ current_cours = row['Cours'] if pd.notna(row['Cours']) else 1
998
+ current_module = row[module_col] if module_col and pd.notna(row[module_col]) else None
999
+ current_choices = []
1000
+
1001
+ if is_valid_cours_number(current_cours):
1002
+ choice_letter = str(row['Order']).strip().upper()
1003
+ choice_text = str(row['ChoiceText']).strip()
1004
+ ct_value = str(row['CT']).strip().upper() if pd.notna(row['CT']) else ""
1005
+ is_correct = ct_value == 'X'
1006
+
1007
+ if choice_text and choice_text.lower() != 'nan' and choice_text != '':
1008
+ current_choices.append({
1009
+ 'letter': choice_letter,
1010
+ 'text': choice_text,
1011
+ 'is_correct': is_correct
1012
+ })
1013
+
1014
+ if current_question is not None and current_choices and is_valid_cours_number(current_cours):
1015
+ processed_questions.append({
1016
+ 'numero': current_question,
1017
+ 'question_text': current_question_text,
1018
+ 'source': current_source,
1019
+ 'comment': current_comment,
1020
+ 'cours': int(float(str(current_cours).strip())),
1021
+ 'module': current_module,
1022
+ 'choices': current_choices.copy()
1023
+ })
1024
+ elif current_question is not None and not is_valid_cours_number(current_cours):
1025
+ skipped_s2_questions += 1
1026
+
1027
+ # Group questions by module and course
1028
+ questions_by_module = {}
1029
+ for q_data in processed_questions:
1030
+ module_name = q_data['module']
1031
+ cours_num = q_data['cours']
1032
+
1033
+ if module_name not in questions_by_module:
1034
+ questions_by_module[module_name] = {}
1035
+
1036
+ if cours_num not in questions_by_module[module_name]:
1037
+ questions_by_module[module_name][cours_num] = []
1038
+
1039
+ questions_by_module[module_name][cours_num].append(q_data)
1040
+
1041
+ # Check for E choices
1042
+ total_e_choices = 0
1043
+ for module_name, questions_by_course in questions_by_module.items():
1044
+ for cours_num, course_questions in questions_by_course.items():
1045
+ course_e_count = sum(1 for q_data in course_questions
1046
+ for choice in q_data['choices']
1047
+ if choice['letter'].upper() == 'E')
1048
+ if course_e_count > 0:
1049
+ total_e_choices += course_e_count
1050
+
1051
+ # Column balancing
1052
+ column_break_after_question = 0
1053
+ if use_two_columns and balance_method == "dynamic":
1054
+ total_estimated_lines = 0
1055
+ all_question_lengths = []
1056
+
1057
+ for module_name in modules:
1058
+ if module_name not in questions_by_module:
1059
+ continue
1060
+
1061
+ questions_by_course = questions_by_module[module_name]
1062
+ cours_titles = modules_data.get(module_name, {})
1063
+
1064
+ total_estimated_lines += 5
1065
+ question_lengths, module_lines = estimate_content_length(questions_by_course, cours_titles)
1066
+ total_estimated_lines += module_lines
1067
+ all_question_lengths.extend(question_lengths)
1068
+
1069
+ target_lines_first_column = total_estimated_lines * 0.52
1070
+ cumulative_lines = 0
1071
+ global_question_counter = 0
1072
+
1073
+ for module_name in modules:
1074
+ if module_name not in questions_by_module:
1075
+ continue
1076
+
1077
+ cumulative_lines += 5
1078
+ questions_by_course = questions_by_module[module_name]
1079
+
1080
+ for cours_num in sorted(questions_by_course.keys()):
1081
+ cumulative_lines += 3
1082
+ course_questions = questions_by_course[cours_num]
1083
+
1084
+ for q_data in course_questions:
1085
+ global_question_counter += 1
1086
+
1087
+ for q_length in all_question_lengths:
1088
+ if q_length['question'] == q_data:
1089
+ cumulative_lines += q_length['estimated_lines']
1090
+ break
1091
+
1092
+ if cumulative_lines >= target_lines_first_column and column_break_after_question == 0:
1093
+ column_break_after_question = global_question_counter
1094
+ break
1095
+
1096
+ if column_break_after_question > 0:
1097
+ break
1098
+
1099
+ if column_break_after_question > 0:
1100
+ break
1101
+
1102
+ # Format questions grouped by module
1103
+ overall_question_count = 1
1104
+ global_question_counter = 0
1105
+ column_break_added = False
1106
+
1107
+ for module_index, module_name in enumerate(modules):
1108
+ if module_name not in questions_by_module:
1109
+ continue
1110
+
1111
+ if module_index == 0:
1112
+ section = doc.sections[-1]
1113
+ else:
1114
+ section = doc.add_section(WD_SECTION.NEW_PAGE)
1115
+
1116
+ if use_two_columns:
1117
+ sectPr = section._sectPr
1118
+ cols = sectPr.find(qn('w:cols'))
1119
+ if cols is None:
1120
+ cols = OxmlElement('w:cols')
1121
+ sectPr.append(cols)
1122
+ cols.set(qn('w:num'), '2')
1123
+ cols.set(qn('w:space'), '432')
1124
+ if add_separator_line:
1125
+ cols.set(qn('w:sep'), '1')
1126
+ cols.set(qn('w:equalWidth'), '1')
1127
+
1128
+ if use_two_columns:
1129
+ sectPr = section._sectPr
1130
+ cols = sectPr.find(qn('w:cols'))
1131
+ if cols is None:
1132
+ cols = OxmlElement('w:cols')
1133
+ sectPr.append(cols)
1134
+ cols.set(qn('w:num'), '2')
1135
+ cols.set(qn('w:space'), '432')
1136
+ if add_separator_line:
1137
+ cols.set(qn('w:sep'), '1')
1138
+ cols.set(qn('w:equalWidth'), '1')
1139
+
1140
+ # Use the new flexible header function
1141
+ create_flexible_header(section, module_name, first_sheet_name, display_name, theme_hex=theme_hex)
1142
+
1143
+ # ========== CUSTOMIZE MODULE TITLE APPEARANCE HERE ==========
1144
+ MODULE_HEIGHT = 31 # Frame height in points
1145
+ MODULE_ROUNDNESS = 50 # Corner roundness % (0=square, 50=pill)
1146
+ MODULE_FONT_SIZE = 35 # Font size in half-points (28=14pt, 24=12pt, 32=16pt)
1147
+ MODULE_BG_COLOR = theme_hex
1148
+ MODULE_TEXT_COLOR = "FFFFFF" # White text color
1149
+ MODULE_PADDING = 60 # Extra width padding
1150
+ # ============================================================
1151
+
1152
+ # Add module title as rounded shape
1153
+ shape_para = doc.add_paragraph()
1154
+ shape_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
1155
+ shape_para.paragraph_format.space_before = Pt(12)
1156
+ shape_para.paragraph_format.space_after = Pt(8)
1157
+
1158
+ # Calculate width based on text length
1159
+ text_length = len(module_name.upper())
1160
+ estimated_width = (text_length * 12) + MODULE_PADDING
1161
+ module_name_escaped = html.escape(module_name.upper())
1162
+
1163
+ # Create rounded rectangle shape
1164
+ shape_xml = f'''
1165
+ <w:r xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
1166
+ xmlns:v="urn:schemas-microsoft-com:vml">
1167
+ <w:pict>
1168
+ <v:roundrect style="width:{estimated_width}pt;height:{MODULE_HEIGHT}pt"
1169
+ arcsize="{MODULE_ROUNDNESS}%" fillcolor="#{MODULE_BG_COLOR}" stroked="f">
1170
+ <v:textbox inset="10pt,0pt,10pt,0pt" style="v-text-anchor:middle">
1171
+ <w:txbxContent>
1172
+ <w:p>
1173
+ <w:pPr>
1174
+ <w:jc w:val="center"/>
1175
+ <w:spacing w:before="0" w:after="0"/>
1176
+ </w:pPr>
1177
+ <w:r>
1178
+ <w:rPr>
1179
+ <w:rFonts w:ascii="Montserrat" w:hAnsi="Montserrat"/>
1180
+ <w:b/>
1181
+ <w:sz w:val="{MODULE_FONT_SIZE}"/>
1182
+ <w:color w:val="{MODULE_TEXT_COLOR}"/>
1183
+ </w:rPr>
1184
+ <w:t>{module_name_escaped}</w:t>
1185
+ </w:r>
1186
+ </w:p>
1187
+ </w:txbxContent>
1188
+ </v:textbox>
1189
+ </v:roundrect>
1190
+ </w:pict>
1191
+ </w:r>
1192
+ '''
1193
+
1194
+ shape_element = parse_xml(shape_xml)
1195
+ shape_para._p.append(shape_element)
1196
+
1197
+ # Add bookmark
1198
+ bm_name = sanitize_bookmark_name(f"MOD_{module_name}")
1199
+ add_bookmark_to_paragraph(shape_para, bm_name, bookmark_id)
1200
+ toc_entries.append({'level': 'module', 'text': f"MODULE: {module_name}", 'bm': bm_name})
1201
+ bookmark_id += 1
1202
+
1203
+ questions_by_course = questions_by_module[module_name]
1204
+ cours_titles = modules_data.get(module_name, {})
1205
+
1206
+ for natural_num, cours_num in enumerate(sorted(questions_by_course.keys()), start=1):
1207
+ course_questions = questions_by_course[cours_num]
1208
+ course_question_count = 1
1209
+
1210
+ course_title = cours_titles.get(cours_num, f"COURSE {cours_num}")
1211
+ course_para = create_course_title(doc, natural_num, course_title, theme_color)
1212
+
1213
+ bm_course_name = sanitize_bookmark_name(f"COURSE_{module_name}_{cours_num}")
1214
+ add_bookmark_to_paragraph(course_para, bm_course_name, bookmark_id)
1215
+ toc_entries.append({'level': 'course', 'text': f"{natural_num}. {course_title}", 'bm': bm_course_name})
1216
+ bookmark_id += 1
1217
+
1218
+ for q_data in course_questions:
1219
+ global_question_counter += 1
1220
+
1221
+ if (use_two_columns and balance_method == "dynamic" and
1222
+ not column_break_added and global_question_counter == column_break_after_question):
1223
+ add_column_break(doc)
1224
+ column_break_added = True
1225
+
1226
+ choices = [(choice['letter'], choice['text']) for choice in q_data['choices']]
1227
+ choices.sort(key=lambda x: x[0])
1228
+
1229
+ correct_answers = [choice['letter'] for choice in q_data['choices'] if choice['is_correct']]
1230
+ correct_answers_str = ''.join(sorted(correct_answers))
1231
+
1232
+ if choices:
1233
+ format_question_block(
1234
+ doc,
1235
+ course_question_count,
1236
+ q_data['question_text'],
1237
+ choices,
1238
+ correct_answers_str,
1239
+ q_data['source'],
1240
+ q_data['comment'],
1241
+ theme_color
1242
+ )
1243
+
1244
+ course_question_count += 1
1245
+ overall_question_count += 1
1246
+
1247
+ # ========================================
1248
+ # INSERT TOC ENTRIES IN THE FIRST SECTION
1249
+ # ========================================
1250
+ # We need to insert TOC entries in the FIRST section, before the section break
1251
+ # Get the body element
1252
+ body = doc._element.body
1253
+
1254
+ # Find where to insert - right after toc_title, before the section break
1255
+ toc_title_element = toc_title._element
1256
+ insert_index = list(body).index(toc_title_element) + 1
1257
+
1258
+ # In the TOC generation section, update the formatting code:
1259
+
1260
+ # Generate the TOC entries and insert them at the correct position
1261
+ for entry in toc_entries:
1262
+ # Create a new paragraph element
1263
+ new_p = body.makeelement(qn('w:p'), nsmap=body.nsmap)
1264
+
1265
+ # Set paragraph properties
1266
+ pPr = new_p.makeelement(qn('w:pPr'), nsmap=new_p.nsmap)
1267
+
1268
+ # Alignment - CENTER
1269
+ jc = pPr.makeelement(qn('w:jc'), nsmap=pPr.nsmap)
1270
+ jc.set(qn('w:val'), 'center')
1271
+ pPr.append(jc)
1272
+
1273
+ # Set spacing
1274
+ spacing = pPr.makeelement(qn('w:spacing'), nsmap=pPr.nsmap)
1275
+ spacing.set(qn('w:before'), '0')
1276
+ spacing.set(qn('w:after'), '0')
1277
+ pPr.append(spacing)
1278
+
1279
+ # Add tab stops with dotted leader
1280
+ tabs = pPr.makeelement(qn('w:tabs'), nsmap=pPr.nsmap)
1281
+ tab = tabs.makeelement(qn('w:tab'), nsmap=tabs.nsmap)
1282
+ tab.set(qn('w:val'), 'right')
1283
+ tab.set(qn('w:leader'), 'dot') # This adds the dots!
1284
+ tab.set(qn('w:pos'), '9360') # 6.5 inches in twentieths of a point
1285
+ tabs.append(tab)
1286
+ pPr.append(tabs)
1287
+
1288
+ # Indent course entries and responses entries
1289
+ if entry['level'] == 'course':
1290
+ ind = pPr.makeelement(qn('w:ind'), nsmap=pPr.nsmap)
1291
+ ind.set(qn('w:left'), '360') # 0.25 inches
1292
+ pPr.append(ind)
1293
+ elif entry['level'] == 'responses':
1294
+ ind = pPr.makeelement(qn('w:ind'), nsmap=pPr.nsmap)
1295
+ ind.set(qn('w:left'), '360') # 0.25 inches - same as course
1296
+ pPr.append(ind)
1297
+
1298
+ new_p.append(pPr)
1299
+
1300
+ # Add text run with font formatting
1301
+ r = new_p.makeelement(qn('w:r'), nsmap=new_p.nsmap)
1302
+
1303
+ # Add run properties (font)
1304
+ rPr = r.makeelement(qn('w:rPr'), nsmap=r.nsmap)
1305
+
1306
+ # Font family
1307
+ rFonts = rPr.makeelement(qn('w:rFonts'), nsmap=rPr.nsmap)
1308
+ rFonts.set(qn('w:ascii'), 'Montserrat')
1309
+ rFonts.set(qn('w:hAnsi'), 'Montserrat')
1310
+ rPr.append(rFonts)
1311
+
1312
+ # Font size and styling based on level
1313
+ sz = rPr.makeelement(qn('w:sz'), nsmap=rPr.nsmap)
1314
+ if entry['level'] == 'module':
1315
+ sz.set(qn('w:val'), '22') # 11pt
1316
+ # Bold for module
1317
+ b = rPr.makeelement(qn('w:b'), nsmap=rPr.nsmap)
1318
+ rPr.append(b)
1319
+ # Color for module
1320
+ color = rPr.makeelement(qn('w:color'), nsmap=rPr.nsmap)
1321
+ color.set(qn('w:val'), theme_hex)
1322
+ rPr.append(color)
1323
+ elif entry['level'] == 'responses':
1324
+ sz.set(qn('w:val'), '20') # 10pt
1325
+ # Bold and italic for responses
1326
+ b = rPr.makeelement(qn('w:b'), nsmap=rPr.nsmap)
1327
+ rPr.append(b)
1328
+ i = rPr.makeelement(qn('w:i'), nsmap=rPr.nsmap)
1329
+ rPr.append(i)
1330
+ # Purple color for responses to match the box
1331
+ color = rPr.makeelement(qn('w:color'), nsmap=rPr.nsmap)
1332
+ color.set(qn('w:val'), theme_hex)
1333
+ rPr.append(color)
1334
+ else: # course level
1335
+ sz.set(qn('w:val'), '20') # 10pt
1336
+ rPr.append(sz)
1337
+
1338
+ r.append(rPr)
1339
+
1340
+ # Add text
1341
+ t = r.makeelement(qn('w:t'), nsmap=r.nsmap)
1342
+ t.set(qn('xml:space'), 'preserve')
1343
+ t.text = entry['text']
1344
+ r.append(t)
1345
+ new_p.append(r)
1346
+
1347
+ # Add tab run (this triggers the dotted leader)
1348
+ r_tab = new_p.makeelement(qn('w:r'), nsmap=new_p.nsmap)
1349
+ tab_char = r_tab.makeelement(qn('w:tab'), nsmap=r_tab.nsmap)
1350
+ r_tab.append(tab_char)
1351
+ new_p.append(r_tab)
1352
+
1353
+ # Add PAGEREF field runs
1354
+ r_field_begin = new_p.makeelement(qn('w:r'), nsmap=new_p.nsmap)
1355
+ fldChar1 = r_field_begin.makeelement(qn('w:fldChar'), nsmap=r_field_begin.nsmap)
1356
+ fldChar1.set(qn('w:fldCharType'), 'begin')
1357
+ r_field_begin.append(fldChar1)
1358
+ new_p.append(r_field_begin)
1359
+
1360
+ r_instr = new_p.makeelement(qn('w:r'), nsmap=new_p.nsmap)
1361
+ instrText = r_instr.makeelement(qn('w:instrText'), nsmap=r_instr.nsmap)
1362
+ instrText.set(qn('xml:space'), 'preserve')
1363
+ instrText.text = f"PAGEREF {entry['bm']} \\h"
1364
+ r_instr.append(instrText)
1365
+ new_p.append(r_instr)
1366
+
1367
+ r_field_end = new_p.makeelement(qn('w:r'), nsmap=new_p.nsmap)
1368
+ fldChar2 = r_field_end.makeelement(qn('w:fldChar'), nsmap=r_field_end.nsmap)
1369
+ fldChar2.set(qn('w:fldCharType'), 'end')
1370
+ r_field_end.append(fldChar2)
1371
+ new_p.append(r_field_end)
1372
+
1373
+ # Insert the paragraph at the correct position
1374
+ body.insert(insert_index, new_p)
1375
+ insert_index += 1 # Increment for next insertion
1376
+
1377
+ # Add page numbers
1378
+ add_page_numbers(doc, theme_hex)
1379
+
1380
+ # Save document
1381
+ doc.save(output_word_path)
1382
+ print(f"\n🎉 SUCCESS: Document saved as: {output_word_path}")
1383
+ print(f"📊 Total questions processed: {overall_question_count - 1}")
1384
+ print(f"🚫 Total S2/invalid questions skipped: {skipped_s2_questions}")
1385
+ if total_e_choices > 0:
1386
+ print(f"✨ Dynamic E columns added for courses with 5-choice questions")
1387
+
1388
+
1389
+ def debug_excel_structure(excel_file_path):
1390
+ """Debug function to analyze Excel structure"""
1391
+ print("=== DEBUGGING EXCEL STRUCTURE ===")
1392
+
1393
+ xls = pd.ExcelFile(excel_file_path)
1394
+ first_sheet_name = xls.sheet_names[0] # Get the first sheet name
1395
+ questions_df = pd.read_excel(excel_file_path, sheet_name=first_sheet_name)
1396
+
1397
+ print(f"Total rows: {len(questions_df)}")
1398
+ print(f"Columns: {list(questions_df.columns)}")
1399
+
1400
+ # Check unique values in key columns
1401
+ if 'Numero' in questions_df.columns:
1402
+ try:
1403
+ print(f"Unique Numero values: {sorted(questions_df['Numero'].dropna().unique())}")
1404
+ except Exception as e:
1405
+ print(f"Unique Numero values: {list(questions_df['Numero'].dropna().unique())} (couldn't sort: {e})")
1406
+
1407
+ if 'Order' in questions_df.columns:
1408
+ try:
1409
+ unique_orders = sorted(questions_df['Order'].dropna().unique())
1410
+ print(f"Unique Order values: {unique_orders}")
1411
+ # Check specifically for E choices
1412
+ e_count = sum(1 for order in questions_df['Order'].dropna() if str(order).strip().upper() == 'E')
1413
+ print(f"Total E choices found: {e_count}")
1414
+ except Exception as e:
1415
+ print(f"Unique Order values: {list(questions_df['Order'].dropna().unique())} (couldn't sort: {e})")
1416
+
1417
+ if 'Cours' in questions_df.columns:
1418
+ unique_cours = questions_df['Cours'].dropna().unique()
1419
+
1420
+ # Convert all to strings first for display, then separate by validity
1421
+ unique_cours_str = [str(c) for c in unique_cours]
1422
+ print(f"Unique Cours values: {unique_cours_str}")
1423
+
1424
+ # Check which cours values are valid vs invalid
1425
+ valid_cours = []
1426
+ invalid_cours = []
1427
+
1428
+ for c in unique_cours:
1429
+ if is_valid_cours_number(c):
1430
+ valid_cours.append(c)
1431
+ else:
1432
+ invalid_cours.append(str(c))
1433
+
1434
+ # Sort valid ones (numeric) and invalid ones (as strings) separately
1435
+ try:
1436
+ valid_cours_sorted = sorted([float(c) for c in valid_cours])
1437
+ print(f"Valid cours values: {valid_cours_sorted}")
1438
+ except Exception:
1439
+ print(f"Valid cours values: {valid_cours}")
1440
+
1441
+ try:
1442
+ invalid_cours_sorted = sorted(invalid_cours)
1443
+ print(f"Invalid/S2 cours values: {invalid_cours_sorted}")
1444
+ except Exception:
1445
+ print(f"Invalid/S2 cours values: {invalid_cours}")
1446
+
1447
+ # Check module column and corresponding sheets
1448
+ if 'module' in questions_df.columns:
1449
+ unique_modules = questions_df['module'].dropna().unique()
1450
+ print(f"\nUnique Module values: {list(unique_modules)}")
1451
+
1452
+ # Check if sheets exist for each module
1453
+ xls = pd.ExcelFile(excel_file_path)
1454
+ sheet_names = xls.sheet_names
1455
+ sheet_names_lower = [s.lower() for s in sheet_names]
1456
+
1457
+ print("\nModule sheet availability:")
1458
+ for module in unique_modules:
1459
+ module_lower = str(module).strip().lower()
1460
+ if module_lower in sheet_names_lower:
1461
+ actual_sheet = sheet_names[sheet_names_lower.index(module_lower)]
1462
+ print(f" ✓ Module '{module}' -> Sheet '{actual_sheet}' found")
1463
+
1464
+ # Try to read and show course info from this sheet
1465
+ try:
1466
+ module_df = pd.read_excel(excel_file_path, sheet_name=actual_sheet)
1467
+ if 'cours' in module_df.columns and 'titre' in module_df.columns:
1468
+ print(f" Courses in this module:")
1469
+ for _, row in module_df.iterrows():
1470
+ if pd.notna(row['cours']):
1471
+ print(f" - {row['cours']}: {row.get('titre', 'N/A')}")
1472
+ except Exception as e:
1473
+ print(f" Error reading sheet: {e}")
1474
+ else:
1475
+ print(f" ✗ Module '{module}' -> No matching sheet found")
1476
+
1477
+ # Check Cours sheet
1478
+ try:
1479
+ cours_df = pd.read_excel(excel_file_path, sheet_name='Cours')
1480
+ print(f"\nCours sheet - Total rows: {len(cours_df)}")
1481
+ print(f"Cours sheet columns: {list(cours_df.columns)}")
1482
+ if not cours_df.empty:
1483
+ print("Course titles:")
1484
+ for _, row in cours_df.iterrows():
1485
+ cours_val = row.get('cours', 'N/A')
1486
+ is_valid = is_valid_cours_number(cours_val)
1487
+ status = "✓" if is_valid else "✗ (SKIPPED)"
1488
+ print(f" Course {cours_val}: {row.get('titre', 'N/A')} {status}")
1489
+ except Exception as e:
1490
+ print(f"Error reading Cours sheet: {e}")