TiH0 commited on
Commit
9694a99
Β·
verified Β·
1 Parent(s): 514921e

Create meta.py

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