Manhattan-Meta / meta.py
TiH0's picture
Update meta.py
5fa10ad verified
"""
META.PY - Quiz Document Generator with Answer Tables Only
==========================================================
This is the META version based on v4u.py with the following key difference:
- NO EMPTY TABLES after each course
- ONLY ANSWER TABLES at the end of each module
- All other features from v4u.py are preserved (images, highlighting, circled numbers, etc.)
"""
import re
import os
import html
import pandas as pd
from docx import Document
from docx.shared import Pt, Cm, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_TAB_ALIGNMENT, WD_TAB_LEADER
from docx.enum.table import WD_ALIGN_VERTICAL, WD_TABLE_ALIGNMENT
from docx.enum.style import WD_STYLE_TYPE
from docx.enum.section import WD_SECTION
from docx.oxml import parse_xml
from docx.oxml.ns import nsdecls
from docx.oxml.shared import OxmlElement, qn
import zipfile
from collections import defaultdict
import tempfile
THEME_COLOR_HEX = "5FFFDF" # Hex version for XML elements
THEME_COLOR = RGBColor.from_string(THEME_COLOR_HEX)
# ── Embedded logo (Cure logo, black on transparent PNG) ─────────────────────
_CURE_LOGO_B64 = "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"
def get_themed_logo_b64(theme_hex):
"""Recolor the black logo pixels to the theme color, return base64 PNG."""
import base64, io
try:
from PIL import Image
raw = base64.b64decode(_CURE_LOGO_B64)
img = Image.open(io.BytesIO(raw)).convert('RGBA')
r_t = int(theme_hex[0:2], 16)
g_t = int(theme_hex[2:4], 16)
b_t = int(theme_hex[4:6], 16)
w, h = img.size
pixels = img.load()
out = Image.new('RGBA', (w, h), (0, 0, 0, 0))
out_px = out.load()
for y in range(h):
for x in range(w):
r, g, b, a = pixels[x, y]
if a > 10:
out_px[x, y] = (r_t, g_t, b_t, a)
buf = io.BytesIO()
out.save(buf, format='PNG', optimize=True)
return base64.b64encode(buf.getvalue()).decode()
except Exception as e:
print(f"Logo recolor failed: {e}, using original")
return _CURE_LOGO_B64
# Common paper sizes (width x height in inches)
PAPER_SIZES = {
'LETTER': (8.5, 11), # US Letter
'A4': (8.27, 11.69), # A4
'A4_WIDE': (8.77, 11.69),
'A3': (11.69, 16.54), # A3
'A5': (5.83, 8.27), # A5
'LEGAL': (8.5, 14), # US Legal
'TABLOID': (11, 17), # Tabloid
'LEDGER': (17, 11), # Ledger
}
def get_circled_number(num):
"""Convert a number to its circled Unicode equivalent"""
# Unicode circled numbers 1-50
circled_numbers = {
1: '①', 2: '②', 3: '③', 4: '④', 5: '⑤',
6: '⑥', 7: '⑦', 8: '⑧', 9: '⑨', 10: '⑩',
11: '⑪', 12: '⑫', 13: '⑬', 14: '⑭', 15: '⑮',
16: '⑯', 17: '⑰', 18: '⑱', 19: '⑲', 20: '⑳',
21: '㉑', 22: '㉒', 23: '㉓', 24: '㉔', 25: '㉕',
26: '㉖', 27: '㉗', 28: '㉘', 29: '㉙', 30: '㉚',
31: '㉛', 32: '㉜', 33: '㉝', 34: '㉞', 35: '㉟',
36: '㊱', 37: '㊲', 38: '㊳', 39: '㊴', 40: '㊵',
41: '㊶', 42: '㊷', 43: '㊸', 44: '㊹', 45: '㊺',
46: '㊻', 47: '㊼', 48: '㊽', 49: '㊾', 50: '㊿'
}
if num in circled_numbers:
return circled_numbers[num]
else:
# For numbers > 50, use parentheses as fallback
return f"({num})"
def prepare_image_folder(path):
"""
Prepare the image folder. If it's a zip file, extract it to a temporary folder.
Returns None gracefully if path is None or invalid.
"""
# Handle None or empty path
if path is None or str(path).strip() == '':
print("ℹ️ No image folder provided - images will be skipped")
return None, False, None
path = str(path).strip()
# Check if it's a zip file
if path.lower().endswith('.zip') and os.path.isfile(path):
print(f"📦 Detected ZIP file: {os.path.basename(path)}")
print(f" Extracting to temporary folder...")
try:
# Create temporary directory
temp_dir = tempfile.TemporaryDirectory()
# Extract zip file
with zipfile.ZipFile(path, 'r') as zip_ref:
zip_ref.extractall(temp_dir.name)
# Count extracted files
all_files = []
for root, dirs, files in os.walk(temp_dir.name):
all_files.extend([os.path.join(root, f) for f in files])
image_files = [f for f in all_files if
f.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.webp'))]
print(f" ✓ Extracted {len(all_files)} files ({len(image_files)} images)")
print(f" Using folder: {temp_dir.name}")
return temp_dir.name, True, temp_dir
except Exception as e:
print(f" ✗ Error extracting ZIP: {e}")
return None, False, None
# Check if it's a regular folder
elif os.path.isdir(path):
print(f"📁 Using folder: {path}")
return path, False, None
else:
print(f"⚠️ WARNING: Path is neither a folder nor a ZIP file: {path}")
print(f"ℹ️ Images will be skipped")
return None, False, None
def map_images_from_excel(excel_path, image_folder):
"""
Map images to questions based on Photo Q and Photo C columns in Excel.
Returns empty dict if image_folder is None.
"""
# If no image folder, return empty dict immediately
if image_folder is None:
print("ℹ️ No image folder available - skipping image mapping")
return {}
xls = pd.ExcelFile(excel_path)
first_sheet = xls.sheet_names[0]
df = pd.read_excel(excel_path, sheet_name=first_sheet)
# Dictionary to store question -> image mappings
question_images = defaultdict(lambda: {'photo_q': None, 'photo_c': None})
# Check if Photo Q and Photo C columns exist
has_photo_q = 'Photo Q' in df.columns
has_photo_c = 'Photo C' in df.columns
if not has_photo_q and not has_photo_c:
print("ℹ️ No 'Photo Q' or 'Photo C' columns found in Excel")
return {}
print(f"\n=== MAPPING IMAGES FROM FOLDER ===")
print(f"Image folder: {image_folder}")
print(f"Folder exists: {os.path.exists(image_folder)}")
if os.path.exists(image_folder):
try:
images_in_folder = [f for f in os.listdir(image_folder)
if f.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.webp'))]
print(f"Images found in folder: {len(images_in_folder)}")
except Exception as e:
print(f"Error reading folder: {e}")
return {}
else:
print(f"ERROR: Folder does not exist!")
return {}
current_question = None
# Scan through all rows
for idx, row in df.iterrows():
# Detect new question
if pd.notna(row.get('Numero')):
current_question = row['Numero']
if current_question is None:
continue
# Check Photo Q on this row
if has_photo_q and pd.notna(row['Photo Q']):
photo_q_value = str(row['Photo Q']).strip()
if photo_q_value and photo_q_value.lower() not in ['nan', 'none', ''] and not photo_q_value.startswith('='):
# Only set if not already set (first occurrence wins)
if not question_images[current_question]['photo_q']:
image_path = find_image_in_folder(photo_q_value, image_folder)
if image_path:
question_images[current_question]['photo_q'] = image_path
print(f"Q{current_question}: Photo Q -> {os.path.basename(image_path)}")
# Check Photo C on this row
if has_photo_c and pd.notna(row['Photo C']):
photo_c_value = str(row['Photo C']).strip()
if photo_c_value and photo_c_value.lower() not in ['nan', 'none', ''] and not photo_c_value.startswith('='):
# Only set if not already set (first occurrence wins)
if not question_images[current_question]['photo_c']:
image_path = find_image_in_folder(photo_c_value, image_folder)
if image_path:
question_images[current_question]['photo_c'] = image_path
print(f"Q{current_question}: Photo C -> {os.path.basename(image_path)}")
print(f"\n✓ Mapped images to {len(question_images)} questions")
return dict(question_images)
def find_image_in_folder(filename, image_folder):
"""
Find an image file in the specified folder.
Returns None if image_folder is None or if image not found.
"""
if image_folder is None:
return None
if not filename or str(filename).strip().lower() in ['nan', 'none', '']:
return None
filename = str(filename).strip()
# If the filename already has the full path and exists, return it
if os.path.isabs(filename) and os.path.exists(filename):
return filename
# Common image extensions to try
image_extensions = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.webp']
# Get the filename without extension (if it has one)
name_without_ext = os.path.splitext(filename)[0]
original_ext = os.path.splitext(filename)[1].lower()
# Function to search in a directory (including subdirectories)
def search_in_dir(search_dir):
# Try exact match first in this directory
exact_path = os.path.join(search_dir, filename)
if os.path.exists(exact_path):
return exact_path
# Try case-insensitive match in this directory
try:
files_in_dir = os.listdir(search_dir)
for file in files_in_dir:
if file.lower() == filename.lower():
found_path = os.path.join(search_dir, file)
return found_path
# If no extension provided, try all common extensions
if not original_ext:
for ext in image_extensions:
test_path = os.path.join(search_dir, name_without_ext + ext)
if os.path.exists(test_path):
return test_path
# Also try case-insensitive
for file in files_in_dir:
if file.lower() == (name_without_ext + ext).lower():
found_path = os.path.join(search_dir, file)
return found_path
except Exception:
pass
return None
# Search in main folder first
result = search_in_dir(image_folder)
if result:
print(f" ✓ Found: {os.path.relpath(result, image_folder)}")
return result
# Search in all subdirectories
try:
for root, dirs, files in os.walk(image_folder):
result = search_in_dir(root)
if result:
print(f" ✓ Found in subfolder: {os.path.relpath(result, image_folder)}")
return result
except Exception as e:
print(f" ✗ Error searching subfolders: {e}")
print(f" ✗ Not found: {filename}")
return None
def create_stats_table_in_toc(doc, body, insert_index, modules, questions_by_module,
modules_data, modules_course_order, theme_hex, theme_color):
"""
Inserts a column-break then the Frequence & Traqueur stats table
directly into the body XML at insert_index.
Layout mirrors the reference PDF: module headers as full-width merged rows,
course rows with # | C1 | C2 | C3 columns (C1-C3 left blank for student fill-in).
"""
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
def xml_para(text, bold=False, center=False, color_hex=None, font_size_half=None,
space_before=0, space_after=0, keep_with_next=False):
"""Build a <w:p> element with a single run."""
p = body.makeelement(qn('w:p'), nsmap=body.nsmap)
pPr = OxmlElement('w:pPr')
sp = OxmlElement('w:spacing')
sp.set(qn('w:before'), str(space_before))
sp.set(qn('w:after'), str(space_after))
pPr.append(sp)
if center:
jc = OxmlElement('w:jc')
jc.set(qn('w:val'), 'center')
pPr.append(jc)
if keep_with_next:
kwn = OxmlElement('w:keepNext')
pPr.append(kwn)
p.append(pPr)
r = OxmlElement('w:r')
rPr = OxmlElement('w:rPr')
fnt = OxmlElement('w:rFonts')
fnt.set(qn('w:ascii'), 'Montserrat')
fnt.set(qn('w:hAnsi'), 'Montserrat')
rPr.append(fnt)
if bold:
rPr.append(OxmlElement('w:b'))
if color_hex:
col = OxmlElement('w:color')
col.set(qn('w:val'), color_hex)
rPr.append(col)
if font_size_half:
sz = OxmlElement('w:sz')
sz.set(qn('w:val'), str(font_size_half))
rPr.append(sz)
r.append(rPr)
t = OxmlElement('w:t')
t.set(qn('xml:space'), 'preserve')
t.text = text
r.append(t)
p.append(r)
return p
# ── 1. Column break to push into right column ───────────────────────────
col_break_p = body.makeelement(qn('w:p'), nsmap=body.nsmap)
col_break_pPr = OxmlElement('w:pPr')
sp0 = OxmlElement('w:spacing')
sp0.set(qn('w:before'), '0')
sp0.set(qn('w:after'), '0')
col_break_pPr.append(sp0)
col_break_p.append(col_break_pPr)
col_break_r = OxmlElement('w:r')
brk = OxmlElement('w:br')
brk.set(qn('w:type'), 'column')
col_break_r.append(brk)
col_break_p.append(col_break_r)
body.insert(insert_index, col_break_p)
insert_index += 1
# ── 2. Title "Fréquence & Traqueur" ────────────────────────────────────
title_p = xml_para('Fréquence & Traqueur', bold=True, center=True,
color_hex=theme_hex, font_size_half=28,
space_before=0, space_after=80)
body.insert(insert_index, title_p)
insert_index += 1
# ── 3. Build the stats table ────────────────────────────────────────────
tbl = OxmlElement('w:tbl')
# Table properties
tblPr = OxmlElement('w:tblPr')
tblW = OxmlElement('w:tblW')
tblW.set(qn('w:w'), '0')
tblW.set(qn('w:type'), 'auto')
tblPr.append(tblW)
# Table border (thin outer + inner)
tblBorders = OxmlElement('w:tblBorders')
for side in ['top', 'left', 'bottom', 'right', 'insideH', 'insideV']:
b = OxmlElement(f'w:{side}')
b.set(qn('w:val'), 'single')
b.set(qn('w:sz'), '4')
b.set(qn('w:space'), '0')
b.set(qn('w:color'), theme_hex)
tblBorders.append(b)
tblPr.append(tblBorders)
tbl.append(tblPr)
# Column widths: course name wide, # narrow, C1/C2/C3 narrow
tblGrid = OxmlElement('w:tblGrid')
for w in [2800, 500, 500, 500, 500]:
gc = OxmlElement('w:gridCol')
gc.set(qn('w:w'), str(w))
tblGrid.append(gc)
tbl.append(tblGrid)
def make_cell(text, bold=False, center=True, color_hex=None, font_size_half=18,
bg_color=None, merge_start=False, merge_cont=False, colspan=None):
tc = OxmlElement('w:tc')
tcPr = OxmlElement('w:tcPr')
if colspan:
span = OxmlElement('w:gridSpan')
span.set(qn('w:val'), str(colspan))
tcPr.append(span)
if merge_start:
vm = OxmlElement('w:vMerge')
vm.set(qn('w:val'), 'restart')
tcPr.append(vm)
if merge_cont:
vm = OxmlElement('w:vMerge')
tcPr.append(vm)
if bg_color:
shd = OxmlElement('w:shd')
shd.set(qn('w:val'), 'clear')
shd.set(qn('w:color'), 'auto')
shd.set(qn('w:fill'), bg_color)
tcPr.append(shd)
# Cell margins (tight)
tcMar = OxmlElement('w:tcMar')
for side, val in [('top', '40'), ('bottom', '40'), ('left', '80'), ('right', '80')]:
m = OxmlElement(f'w:{side}')
m.set(qn('w:w'), val)
m.set(qn('w:type'), 'dxa')
tcMar.append(m)
tcPr.append(tcMar)
tc.append(tcPr)
p = OxmlElement('w:p')
pPr = OxmlElement('w:pPr')
sp = OxmlElement('w:spacing')
sp.set(qn('w:before'), '0')
sp.set(qn('w:after'), '0')
pPr.append(sp)
if center:
jc = OxmlElement('w:jc')
jc.set(qn('w:val'), 'center')
pPr.append(jc)
p.append(pPr)
r = OxmlElement('w:r')
rPr = OxmlElement('w:rPr')
fnt = OxmlElement('w:rFonts')
fnt.set(qn('w:ascii'), 'Montserrat')
fnt.set(qn('w:hAnsi'), 'Montserrat')
rPr.append(fnt)
if bold:
rPr.append(OxmlElement('w:b'))
if color_hex:
col = OxmlElement('w:color')
col.set(qn('w:val'), color_hex)
rPr.append(col)
sz = OxmlElement('w:sz')
sz.set(qn('w:val'), str(font_size_half))
rPr.append(sz)
r.append(rPr)
t_el = OxmlElement('w:t')
t_el.set(qn('xml:space'), 'preserve')
t_el.text = str(text)
r.append(t_el)
p.append(r)
tc.append(p)
return tc
def make_row(cells, keep_with_next=False):
tr = OxmlElement('w:tr')
trPr = OxmlElement('w:trPr')
cant = OxmlElement('w:cantSplit')
trPr.append(cant)
if keep_with_next:
kwn = OxmlElement('w:trPr')
tr.append(trPr)
for c in cells:
tr.append(c)
return tr
# Header row: empty | # | C1 | C2 | C3
hdr_row = make_row([
make_cell('', bold=False, center=True, font_size_half=16),
make_cell('#', bold=True, center=True, color_hex=theme_hex, font_size_half=18),
make_cell('C1', bold=True, center=True, color_hex=theme_hex, font_size_half=18),
make_cell('C2', bold=True, center=True, color_hex=theme_hex, font_size_half=18),
make_cell('C3', bold=True, center=True, color_hex=theme_hex, font_size_half=18),
])
tbl.append(hdr_row)
# Data rows per module then per course
for module_name in modules:
if module_name not in questions_by_module:
continue
course_order = modules_course_order.get(module_name,
sorted(questions_by_module[module_name].keys()))
cours_titles = modules_data.get(module_name, {})
# Module header row: merged across all 5 columns
mod_display = str(module_name).upper()
mod_tc = make_cell(mod_display, bold=True, center=True,
color_hex=theme_hex, font_size_half=20,
colspan=5)
mod_row = make_row([mod_tc])
tbl.append(mod_row)
# Course rows
for cours_num in course_order:
if cours_num not in questions_by_module[module_name]:
continue
course_title = cours_titles.get(cours_num, f"Course {cours_num}")
count = len(questions_by_module[module_name][cours_num])
row = make_row([
make_cell(str(course_title), bold=False, center=False,
font_size_half=16),
make_cell(str(count), bold=True, center=True,
color_hex=theme_hex, font_size_half=18),
make_cell('', center=True, font_size_half=16),
make_cell('', center=True, font_size_half=16),
make_cell('', center=True, font_size_half=16),
])
tbl.append(row)
# Wrap table in a paragraph-like block and insert
tbl_wrap = body.makeelement(qn('w:tbl'), nsmap=body.nsmap)
body.insert(insert_index, tbl)
insert_index += 1
return insert_index
def process_excel_to_word(excel_file_path, output_word_path, image_folder=None, display_name=None, use_two_columns=True,
add_separator_line=True, balance_method="dynamic", theme_hex=None):
"""Main function to process Excel and create a Word document with TOC on the first page"""
if theme_hex is None:
theme_hex = THEME_COLOR_HEX
theme_color = RGBColor.from_string(theme_hex)
# Prepare image folder (extract if ZIP) - gracefully handle None
actual_image_folder, is_temp, temp_dir_obj = prepare_image_folder(image_folder)
# Map images from the prepared folder (returns empty dict if None)
question_photos = map_images_from_excel(excel_file_path, actual_image_folder)
# ... rest of the function remains the same ...
# The code will now handle missing images gracefully since question_photos will be empty
# At the end, clean up temporary folder if it was created
if is_temp and temp_dir_obj is not None:
print(f"\n🧹 Cleaning up temporary folder...")
try:
temp_dir_obj.cleanup()
print(f" ✓ Temporary files removed")
except Exception as e:
print(f" ⚠️ Could not clean up: {e}")
def preview_image_mapping(question_images):
"""Preview the image mapping for verification"""
print("\n" + "=" * 60)
print("IMAGE MAPPING PREVIEW")
print("=" * 60)
for q_num in sorted(question_images.keys()):
photos = question_images[q_num]
print(f"\nQuestion {q_num}:")
if photos['photo_q']:
exists = "✓" if os.path.exists(photos['photo_q']) else "✗"
print(f" Photo Q: {exists} {os.path.basename(photos['photo_q'])}")
else:
print(f" Photo Q: (none)")
if photos['photo_c']:
exists = "✓" if os.path.exists(photos['photo_c']) else "✗"
print(f" Photo C: {exists} {os.path.basename(photos['photo_c'])}")
else:
print(f" Photo C: (none)")
print("=" * 60 + "\n")
def is_only_x_string(text):
"""Check if a string contains only X's (case insensitive)"""
if not text or pd.isna(text):
return False
cleaned_text = str(text).strip()
if not cleaned_text:
return False
return all(c in ('x', 'X') for c in cleaned_text)
def set_page_size(section, width_inches, height_inches):
"""Set custom page size for a section"""
sectPr = section._sectPr
# Create or get pgSz element
pgSz = sectPr.find(qn('w:pgSz'))
if pgSz is None:
pgSz = OxmlElement('w:pgSz')
sectPr.insert(0, pgSz)
# Convert inches to twentieths of a point (1 inch = 1440 twips)
width_twips = int(width_inches * 1440)
height_twips = int(height_inches * 1440)
pgSz.set(qn('w:w'), str(width_twips))
pgSz.set(qn('w:h'), str(height_twips))
def set_two_column_layout(doc, add_separator_line=True, balance_columns=True):
"""Set the document to use a two-column layout with optional separator line and column balancing"""
# Get the current section
section = doc.sections[0]
# Create sectPr element if it doesn't exist
sectPr = section._sectPr
# Create cols element for columns
cols = sectPr.find(qn('w:cols'))
if cols is None:
cols = OxmlElement('w:cols')
sectPr.append(cols)
# Set number of columns to 2
cols.set(qn('w:num'), '2')
# Set space between columns (reduced for better space utilization)
cols.set(qn('w:space'), '432') # 0.3 inch in twentieths of a point (was 708)
# Enable column balancing if requested
if balance_columns:
cols.set(qn('w:equalWidth'), '1') # Equal width columns
return doc
def set_cell_borders(cell, top=False, bottom=False, left=False, right=False):
"""Set specific borders for a table cell"""
from docx.oxml import parse_xml
from docx.oxml.ns import nsdecls
# Get the cell's table cell properties
tcPr = cell._tc.get_or_add_tcPr()
# Create borders element
tcBorders = tcPr.find(qn('w:tcBorders'))
if tcBorders is None:
tcBorders = parse_xml(f'<w:tcBorders {nsdecls("w")}></w:tcBorders>')
tcPr.append(tcBorders)
# Define border settings
border_settings = {
'top': top,
'bottom': bottom,
'left': left,
'right': right
}
for border_name, should_show in border_settings.items():
border_element = tcBorders.find(qn(f'w:{border_name}'))
if border_element is not None:
tcBorders.remove(border_element)
if should_show:
# Create visible border
border_xml = f'<w:{border_name} {nsdecls("w")} w:val="single" w:sz="4" w:space="0" w:color="000000"/>'
border_element = parse_xml(border_xml)
tcBorders.append(border_element)
# If should_show is False, don't add any border element (let table-level borders show through)
def continue_two_column_layout(doc):
"""Continue with the existing two-column layout for answer tables"""
# Add a column break to start fresh in the columns
add_column_break(doc)
return doc
def add_column_break(doc):
"""Add a column break to move to the next column"""
para = doc.add_paragraph()
run = para.runs[0] if para.runs else para.add_run()
# Create column break element
br = OxmlElement('w:br')
br.set(qn('w:type'), 'column')
run._element.append(br)
def add_page_break(doc):
"""Add a page break to the document"""
doc.add_page_break()
def create_course_title(doc, course_number, course_title, theme_color=None, theme_hex=None, question_count=None):
"""Create a course title section with rounded frame (unfilled) matching module style
Automatically wraps to two lines and doubles height if text is too long"""
if theme_hex is None:
theme_hex = THEME_COLOR_HEX
if theme_color is None:
theme_color = RGBColor.from_string(theme_hex)
# Add minimal space before course title
course_para = doc.add_paragraph()
course_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
# Remove all spacing before and after
course_para.paragraph_format.space_before = Pt(0)
course_para.paragraph_format.space_after = Pt(0)
course_para.paragraph_format.keep_with_next = True
course_para.paragraph_format.keep_together = True
# Format the text
circled_num = get_circled_number(question_count)
full_text = f"{course_number}. {course_title} {circled_num}"
text_length = len(full_text)
# ========== CUSTOMIZE COURSE TITLE APPEARANCE HERE ==========
MAX_CHARS_SINGLE_LINE = 40 # Threshold for wrapping to two lines
SINGLE_LINE_HEIGHT = 31 # Frame height for single line
DOUBLE_LINE_HEIGHT = 55 # Frame height for two lines (almost double)
COURSE_ROUNDNESS = 50 # Corner roundness %
COURSE_FONT_SIZE = 26 # Font size in half-points (26=13pt)
COURSE_TEXT_COLOR = theme_hex
COURSE_STROKE_COLOR = theme_hex
COURSE_STROKE_WEIGHT = "2pt"
MAX_WIDTH_PT = 280 # Maximum width in points for the frame
# ============================================================
# Determine if we need two lines
needs_two_lines = text_length > MAX_CHARS_SINGLE_LINE
# Common XML properties to reduce repetition
xml_size_color = f'<w:sz w:val="{COURSE_FONT_SIZE}"/><w:color w:val="{COURSE_TEXT_COLOR}"/>'
if needs_two_lines:
# Split text intelligently
words = course_title.split()
mid_point = len(words) // 2
# Try to split at middle, but prefer breaking after shorter first line
# (We calculate lengths including the number to match your width logic)
prefix_len = len(f"{course_number}. ")
first_part_title = " ".join(words[:mid_point])
while (prefix_len + len(first_part_title)) > MAX_CHARS_SINGLE_LINE and mid_point > 1:
mid_point -= 1
first_part_title = " ".join(words[:mid_point])
# Define the two parts of the TITLE only
title_part_1 = " ".join(words[:mid_point])
title_part_2 = " ".join(words[mid_point:])
# Escape texts for XML
esc_num = html.escape(f"{course_number}. ")
esc_title_1 = html.escape(title_part_1)
# Add a trailing space to title part 2 to separate it from the circle
esc_title_2 = html.escape(title_part_2 + " ")
esc_circle = html.escape(f"{circled_num}")
# Calculate width based on the longest visual line
# Line 1: Number + Title Part 1
# Line 2: Title Part 2 + Circle
len_line_1 = len(f"{course_number}. {title_part_1}")
len_line_2 = len(f"{title_part_2} {circled_num}")
max_line_length = max(len_line_1, len_line_2)
estimated_width = min((max_line_length * 8) + 20, MAX_WIDTH_PT)
frame_height = DOUBLE_LINE_HEIGHT
# Two-line XML with 5 separate runs to handle fonts and line break
text_content = f'''
<w:r>
<w:rPr>
<w:rFonts w:ascii="Inter ExtraBold" w:hAnsi="Inter ExtraBold"/>
<w:b/>
{xml_size_color}
</w:rPr>
<w:t xml:space="preserve">{esc_num}</w:t>
</w:r>
<w:r>
<w:rPr>
<w:rFonts w:ascii="Montserrat" w:hAnsi="Montserrat"/>
<w:b/>
{xml_size_color}
</w:rPr>
<w:t xml:space="preserve">{esc_title_1}</w:t>
</w:r>
<w:r>
<w:br/>
</w:r>
<w:r>
<w:rPr>
<w:rFonts w:ascii="Montserrat" w:hAnsi="Montserrat"/>
<w:b/>
{xml_size_color}
</w:rPr>
<w:t xml:space="preserve">{esc_title_2}</w:t>
</w:r>
<w:r>
<w:rPr>
<w:rFonts w:ascii="MS Gothic" w:hAnsi="MS Gothic"/>
<w:b/>
{xml_size_color}
</w:rPr>
<w:t>{esc_circle}</w:t>
</w:r>'''
else:
# Single line
estimated_width = min((text_length * 9) + 20, MAX_WIDTH_PT)
frame_height = SINGLE_LINE_HEIGHT
# Escape texts
esc_num = html.escape(f"{course_number}. ")
esc_title = html.escape(f"{course_title} ")
esc_circle = html.escape(f"{circled_num}")
# Single-line XML with 3 separate runs for the fonts
text_content = f'''
<w:r>
<w:rPr>
<w:rFonts w:ascii="Inter ExtraBold" w:hAnsi="Inter ExtraBold"/>
<w:b/>
{xml_size_color}
</w:rPr>
<w:t xml:space="preserve">{esc_num}</w:t>
</w:r>
<w:r>
<w:rPr>
<w:rFonts w:ascii="Montserrat" w:hAnsi="Montserrat"/>
<w:b/>
{xml_size_color}
</w:rPr>
<w:t xml:space="preserve">{esc_title}</w:t>
</w:r>
<w:r>
<w:rPr>
<w:rFonts w:ascii="MS Gothic" w:hAnsi="MS Gothic"/>
<w:b/>
{xml_size_color}
</w:rPr>
<w:t>{esc_circle}</w:t>
</w:r>'''
# Create rounded rectangle shape (UNFILLED with stroke)
shape_xml = f'''
<w:r xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
xmlns:v="urn:schemas-microsoft-com:vml">
<w:pict>
<v:roundrect style="width:{estimated_width}pt;height:{frame_height}pt"
arcsize="{COURSE_ROUNDNESS}%"
filled="f"
strokecolor="#{COURSE_STROKE_COLOR}"
strokeweight="{COURSE_STROKE_WEIGHT}">
<v:textbox inset="0pt,3pt,0pt,3pt" style="v-text-anchor:middle">
<w:txbxContent>
<w:p>
<w:pPr>
<w:jc w:val="center"/>
<w:spacing w:before="0" w:after="0"/>
</w:pPr>{text_content}
</w:p>
</w:txbxContent>
</v:textbox>
</v:roundrect>
</w:pict>
</w:r>
'''
shape_element = parse_xml(shape_xml)
course_para._p.append(shape_element)
return course_para
def highlight_words_in_text(paragraph, text, highlight_words, theme_color, font_name='Inter Display Medium',
font_size=10.5, bold=False, word_boundary_mode=False):
"""
Add text to paragraph with specific words/substrings highlighted in theme color.
Highlights literal text matches (including special characters like parentheses, backslashes).
Args:
paragraph: The paragraph to add text to
text: The full text to add
highlight_words: List of literal strings to highlight
theme_color: RGBColor object for highlighting
font_name: Font to use
font_size: Font size in points
bold: Whether text should be bold
"""
if not highlight_words or not text:
# No highlighting needed, just add normal text
run = paragraph.add_run(text)
run.font.name = font_name
run.font.size = Pt(font_size)
if bold:
run.font.bold = True
return
escaped_words = [re.escape(word) for word in highlight_words]
pattern = '(' + '|'.join(escaped_words) + ')'
# Split text by highlighted words/substrings
parts = re.split(pattern, text, flags=re.IGNORECASE)
for i, part in enumerate(parts):
if not part:
continue
run = paragraph.add_run(part)
run.font.name = font_name
run.font.size = Pt(font_size)
if bold:
run.font.bold = True
# Check if this part should be highlighted (odd indices after split are matches)
if i % 2 == 1:
run.font.color.rgb = theme_color
def format_question_block(doc, question_num, question_text, choices, correct_answers, source, comment=None,
choice_commentaire=None, photo_q=None, photo_c=None, theme_color=None, theme_hex=None,
highlight_words=None, highlight_comment_words=None):
if theme_color is None:
theme_color = THEME_COLOR
if theme_hex is None:
theme_hex = THEME_COLOR_HEX
if highlight_words is None:
highlight_words = []
if highlight_comment_words is None:
highlight_comment_words = []
"""Format a single question block with reduced spacing and keep together formatting"""
if 'TinySpace' not in doc.styles:
tiny_style = doc.styles.add_style('TinySpace', WD_STYLE_TYPE.PARAGRAPH)
tiny_style.font.name = 'SF Pro'
tiny_style.font.size = Pt(5)
tiny_style.paragraph_format.line_spacing = Pt(5)
tiny_style.paragraph_format.space_before = Pt(0)
tiny_style.paragraph_format.space_after = Pt(0)
# Question title with reduced spacing and keep-together formatting
question_para = doc.add_paragraph()
question_para.paragraph_format.space_before = Pt(1)
question_para.paragraph_format.space_after = Pt(1.05)
question_para.paragraph_format.keep_with_next = True
question_para.paragraph_format.keep_together = True
question_para.paragraph_format.line_spacing = 1.05
question_para.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
# Question number in Axiforma Black
num_run = question_para.add_run(f"{question_num}. ")
num_run.font.name = 'Inter ExtraBold'
num_run.font.size = Pt(10.5)
num_run.font.bold = True
num_run.font.color.rgb = theme_color
# Add question text with highlighting (REMOVE THE DUPLICATE!)
highlight_words_in_text(question_para, question_text, highlight_words, theme_color,
font_name='Inter ExtraBold', font_size=10.5)
# Display ALL choices for this question with minimal spacing
# Filter out choices that are only X's
filtered_choices = [(letter, text) for letter, text in choices if not is_only_x_string(text)]
# Display filtered choices for this question with minimal spacing
choice_paragraphs = []
for i, (choice_letter, choice_text) in enumerate(filtered_choices):
choice_para = doc.add_paragraph()
choice_para.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
choice_para.paragraph_format.space_before = Pt(0)
choice_para.paragraph_format.space_after = Pt(1.05)
choice_para.paragraph_format.line_spacing = 1.05
choice_para.paragraph_format.keep_together = True
# Keep all choices together, and keep the last choice with the source
if i < len(choices) - 1:
choice_para.paragraph_format.keep_with_next = True
else:
# Last choice should stay with what comes next (Photo C or source)
choice_para.paragraph_format.keep_with_next = True
# Ensure each choice ends with a dot
if not str(choice_text).strip().endswith('.'):
choice_text = str(choice_text).strip() + '.'
# Choice letter (e.g., "A-")
letter_run = choice_para.add_run(f"{choice_letter}- ")
letter_run.font.name = 'Inter ExtraBold'
letter_run.font.size = Pt(10.5)
# Choice text
text_run = choice_para.add_run(choice_text)
text_run.font.name = 'Inter Display SemiBold'
text_run.font.size = Pt(10.5)
# Choice text with highlighting (REMOVE THE DUPLICATE AND FIX TYPO!)
# highlight_words_in_text(choice_para, choice_text, highlight_words, theme_color,
# font_name='Inter Display Medium', font_size=10.5)
# ADD Photo C HERE (right after choices, before source)
if photo_c:
photo_c_clean = str(photo_c).strip()
if photo_c_clean and photo_c_clean.lower() not in ['nan', 'none', '']:
if os.path.exists(photo_c_clean):
try:
print(f"DEBUG: Adding Photo C from: {photo_c_clean}")
photo_para = doc.add_paragraph()
photo_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
photo_para.paragraph_format.space_before = Pt(2)
photo_para.paragraph_format.space_after = Pt(2)
photo_para.paragraph_format.keep_with_next = True # Keep with source
run = photo_para.add_run()
run.add_picture(photo_c_clean, width=Inches(2.5))
print(f"DEBUG: Successfully added Photo C")
except Exception as e:
print(f"ERROR: Could not add Photo C: {e}")
# Add error message in document
error_para = doc.add_paragraph()
error_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
error_run = error_para.add_run(f"[Photo C error: {str(e)[:50]}]")
error_run.font.size = Pt(7)
error_run.font.italic = True
else:
print(f"WARNING: Photo C path does not exist: {photo_c_clean}")
# Source and Answer line
source_para = doc.add_paragraph()
source_para.alignment = WD_ALIGN_PARAGRAPH.RIGHT
source_para.paragraph_format.space_before = Pt(2)
source_para.paragraph_format.space_after = Pt(2)
source_para.paragraph_format.keep_together = True
# If there's a comment box, keep source with it
if comment and str(comment).strip() and str(comment).lower() != 'nan':
source_para.paragraph_format.keep_with_next = True
if choice_commentaire or photo_q:
source_para.paragraph_format.keep_with_next = True
# Source
source_run = source_para.add_run(f"Source:")
source_run.font.name = 'Inter ExtraBold'
source_run.font.size = Pt(8)
source_run.font.bold = True
source_run.font.underline = True
source_value_run = source_para.add_run(f" {source}")
source_value_run.font.name = 'Inter ExtraBold'
source_value_run.font.size = Pt(8)
source_value_run.font.bold = True
source_value_run.font.color.rgb = None
source_value_run.font.color.rgb = theme_color
# META.PY: Don't add comment box here - it will be added after answer tables
# Just add empty space
empty_para = doc.add_paragraph(' ', style='TinySpace')
empty_para.paragraph_format.space_before = Pt(0)
empty_para.paragraph_format.space_after = Pt(0)
empty_para.paragraph_format.line_spacing = Pt(7)
empty_run = empty_para.add_run(' ')
empty_run.font.size = Pt(7)
def add_page_numbers(doc, theme_hex=None):
"""Add page numbers to the footer of all pages (keeps existing module headers), starting from page 1 after TOC."""
if theme_hex is None:
theme_hex = THEME_COLOR_HEX
# Pre-encode logo PNG once, recolor to theme color
_logo_png_bytes = None
try:
import base64 as _b64
_logo_png_bytes = _b64.b64decode(get_themed_logo_b64(theme_hex))
print(f"\u2713 Logo PNG ready ({len(_logo_png_bytes)} bytes)")
except Exception as _e:
print(f"Logo setup failed: {_e}")
_logo_png_bytes = None
def create_footer_content(footer_elem, theme_hex):
"""Footer: original layout restored.
Logo absolutely positioned bottom-left (VML, original size/position),
wrapped in HYPERLINK field for clickability.
Page number centered. SOM link right. All via absolute VML shapes."""
empty_para = footer_elem.paragraphs[0]
empty_para.paragraph_format.space_before = Pt(0)
empty_para.paragraph_format.space_after = Pt(0)
empty_para.paragraph_format.line_spacing = 1.0
paragraph = footer_elem.add_paragraph()
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
paragraph.paragraph_format.space_before = Pt(0)
paragraph.paragraph_format.space_after = Pt(0)
# Page number centered
run = paragraph.add_run()
fldChar1 = OxmlElement('w:fldChar')
fldChar1.set(qn('w:fldCharType'), 'begin')
instrText = OxmlElement('w:instrText')
instrText.set(qn('xml:space'), 'preserve')
instrText.text = "PAGE"
fldChar2 = OxmlElement('w:fldChar')
fldChar2.set(qn('w:fldCharType'), 'end')
run._r.append(fldChar1)
run._r.append(instrText)
run._r.append(fldChar2)
run.font.name = 'Montserrat'
run.font.size = Pt(14)
run.font.bold = True
run.font.color.rgb = RGBColor.from_string(theme_hex)
# Logo: HYPERLINK field wrapping a VML absolutely-positioned image
# Original size (87.3pt x 31.5pt) and position (margin-top:-9.9pt) preserved exactly
if _logo_png_bytes:
try:
from docx.opc.part import Part as _Part
from docx.opc.packuri import PackURI as _PackURI
_fp = footer_elem.part
_img_part = _Part(
_PackURI(f'/word/media/logo_footer_{id(_fp)}.png'),
'image/png', _logo_png_bytes
)
_r_id = _fp.relate_to(
_img_part,
'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image'
)
_ig_url = "https://www.instagram.com/cureology_/"
_hl_rid = _fp.relate_to(
_ig_url,
'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink',
is_external=True
)
logo_xml = f'''<w:r xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<w:pict>
<v:shape style="position:absolute;margin-left:0in;margin-top:-9.9pt;width:87.3pt;height:31.5pt;z-index:2;mso-position-horizontal:left;mso-position-horizontal-relative:margin;mso-position-vertical-relative:line"
filled="f" stroked="f">
<v:textbox inset="0,0,0,0">
<w:txbxContent>
<w:p>
<w:pPr>
<w:spacing w:before="0" w:after="0"/>
</w:pPr>
<w:hyperlink r:id="{_hl_rid}">
<w:r>
<w:pict>
<v:shape style="width:87.3pt;height:31.5pt" stroked="f" id="logoCure">
<v:imagedata r:id="{_r_id}" o:title="Cure"/>
</v:shape>
</w:pict>
</w:r>
</w:hyperlink>
</w:p>
</w:txbxContent>
</v:textbox>
</v:shape>
</w:pict>
</w:r>'''
logo_element = parse_xml(logo_xml)
paragraph._p.append(logo_element)
print(f"\u2713 Logo with clickable textbox (img={_r_id}, hl={_hl_rid})")
except Exception as _e:
import traceback; traceback.print_exc()
# SOM link right (absolutely positioned textbox)
toc_textbox_xml = f'''
<w:r xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:w10="urn:schemas-microsoft-com:office:word">
<w:pict>
<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">
<v:textbox inset="5pt,0pt,5pt,0pt" style="mso-fit-shape-to-text:t">
<w:txbxContent>
<w:p>
<w:pPr>
<w:jc w:val="right"/>
<w:spacing w:before="0" w:after="0"/>
</w:pPr>
<w:hyperlink w:anchor="TOC_BOOKMARK">
<w:r>
<w:rPr>
<w:rFonts w:ascii="Aptos" w:hAnsi="Aptos"/>
<w:sz w:val="28"/>
<w:color w:val="{theme_hex}"/>
</w:rPr>
<w:t>↗️</w:t>
</w:r>
<w:r>
<w:rPr>
<w:rFonts w:ascii="Montserrat" w:hAnsi="Montserrat"/>
<w:b/>
<w:sz w:val="18"/>
<w:color w:val="{theme_hex}"/>
<w:u w:val="single"/>
</w:rPr>
<w:t> SOM</w:t>
</w:r>
</w:hyperlink>
</w:p>
</w:txbxContent>
</v:textbox>
</v:shape>
</w:pict>
</w:r>
'''
paragraph._p.append(parse_xml(toc_textbox_xml))
for section_idx, section in enumerate(doc.sections):
header = section.header
header.is_linked_to_previous = False
section.header_distance = Cm(0.3)
if not header.paragraphs:
header.add_paragraph()
footer = section.footer
footer.is_linked_to_previous = False
section.footer_distance = Cm(0.4)
if footer.paragraphs:
footer.paragraphs[0].clear()
else:
footer.add_paragraph()
if section_idx == 0:
continue
if section_idx == 1:
sectPr = section._sectPr
pgNumType = sectPr.find(qn('w:pgNumType'))
if pgNumType is None:
pgNumType = OxmlElement('w:pgNumType')
sectPr.append(pgNumType)
pgNumType.set(qn('w:start'), '1')
# Single footer for all pages (no evenAndOddHeaders)
create_footer_content(footer, theme_hex)
def add_toc_bookmark(doc, toc_title_para):
"""Add a bookmark to the TOC title paragraph"""
bookmark_start = OxmlElement('w:bookmarkStart')
bookmark_start.set(qn('w:id'), '0')
bookmark_start.set(qn('w:name'), 'TOC_BOOKMARK')
toc_title_para._p.insert(0, bookmark_start)
bookmark_end = OxmlElement('w:bookmarkEnd')
bookmark_end.set(qn('w:id'), '0')
toc_title_para._p.append(bookmark_end)
def set_module_header(doc, module_name):
"""Update the top-left header text with the current module name."""
for section in doc.sections:
header = section.header
header.is_linked_to_previous = False
if not header.paragraphs:
header.add_paragraph()
header.paragraphs[0].clear()
para = header.paragraphs[0]
para.alignment = WD_ALIGN_PARAGRAPH.LEFT
run = para.add_run(f"{module_name.upper()}")
run.font.name = 'Montserrat'
run.font.size = Pt(10)
run.font.bold = True
run.font.color.rgb = RGBColor(0, 0, 0)
def set_zero_spacing(paragraph):
"""Force paragraph spacing to 0 before and after."""
paragraph.paragraph_format.space_before = Pt(0)
paragraph.paragraph_format.space_after = Pt(0)
def is_valid_cours_number(cours_value):
"""Check if cours value is valid (numeric and not 'S2')"""
if pd.isna(cours_value):
return False
cours_str = str(cours_value).strip().upper()
# Skip S2 courses and other specific invalid values
if cours_str in ['S2', 'NAN', '']:
return False
# Try to convert to numeric - if it works and is positive, it's valid
try:
numeric_value = float(cours_str)
# Check if it's a positive number (courses should be positive integers)
return numeric_value > 0 and numeric_value == int(numeric_value)
except (ValueError, TypeError, OverflowError):
return False
def check_if_course_has_e_choices(course_questions):
"""Check if any question in the course has an E choice"""
for q_data in course_questions:
for choice in q_data['choices']:
if choice['letter'].upper() == 'E':
return True
return False
def create_comment_boxes_section(doc, questions_by_course, cours_titles, module_name, theme_color=None, theme_hex=None):
"""Create comment boxes for all questions that have comments, organized by course
This appears after the answer tables"""
if theme_color is None:
theme_color = THEME_COLOR
if theme_hex is None:
theme_hex = THEME_COLOR_HEX
# Check if there are any comments at all
has_any_comments = False
for cours_num, course_questions in questions_by_course.items():
for q_data in course_questions:
if (q_data.get('comment') or q_data.get('choice_commentaire') or q_data.get('photo_q')):
has_any_comments = True
break
if has_any_comments:
break
if not has_any_comments:
return
# Add title for comments section
add_column_break(doc) # Start in new column
title_para = doc.add_paragraph()
title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
title_para.paragraph_format.space_before = Pt(12)
title_para.paragraph_format.space_after = Pt(8)
# Calculate width based on text length
comment_text = "COMMENTAIRES"
text_length = len(comment_text)
estimated_width = (text_length * 12) + 60
# Create rounded rectangle shape for COMMENTAIRES
shape_xml = f'''
<w:r xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
xmlns:v="urn:schemas-microsoft-com:vml">
<w:pict>
<v:roundrect style="width:{estimated_width}pt;height:31pt"
arcsize="50%" fillcolor="#{theme_hex}" stroked="f">
<v:textbox inset="10pt,0pt,10pt,0pt" style="v-text-anchor:middle">
<w:txbxContent>
<w:p>
<w:pPr>
<w:jc w:val="center"/>
<w:spacing w:before="0" w:after="0"/>
</w:pPr>
<w:r>
<w:rPr>
<w:rFonts w:ascii="Montserrat" w:hAnsi="Montserrat"/>
<w:b/>
<w:sz w:val="35"/>
<w:color w:val="FFFFFF"/>
</w:rPr>
<w:t>{comment_text}</w:t>
</w:r>
</w:p>
</w:txbxContent>
</v:textbox>
</v:roundrect>
</w:pict>
</w:r>
'''
shape_element = parse_xml(shape_xml)
title_para._p.append(shape_element)
# Track overall question number
overall_question_number = 1
# Process each course
for cours_num in sorted(questions_by_course.keys()):
course_questions = questions_by_course[cours_num]
course_title = cours_titles.get(cours_num, f"COURSE {cours_num}")
# Check if this course has any comments
course_has_comments = False
for q_data in course_questions:
if (q_data.get('comment') or q_data.get('choice_commentaire') or q_data.get('photo_q')):
course_has_comments = True
break
if not course_has_comments:
overall_question_number += len(course_questions)
continue
# Add course title
course_title_para = doc.add_paragraph()
course_title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
course_title_para.paragraph_format.space_before = Pt(8)
course_title_para.paragraph_format.space_after = Pt(4)
course_title_run = course_title_para.add_run(f"{cours_num}. {course_title}")
course_title_run.font.name = 'Montserrat'
course_title_run.font.size = Pt(13)
course_title_run.font.bold = True
course_title_run.font.color.rgb = theme_color
# Add comment boxes for questions in this course
for q_data in course_questions:
question_num = overall_question_number
comment = q_data.get('comment')
choice_commentaire = q_data.get('choice_commentaire')
photo_q = q_data.get('photo_q')
# Only add if there are comments or photo
if comment or choice_commentaire or photo_q:
add_choice_commentaire_section(doc, choice_commentaire, photo_q, theme_color, theme_hex,
general_comment=comment, question_num=question_num)
overall_question_number += 1
def create_answer_tables(doc, questions_by_course, cours_titles, module_name, bookmark_id, theme_hex=None,
highlight_words=None):
"""Create multiple choice answer tables organized by course in two-column layout
Each course table is split in half with two tables side by side
Comment boxes appear directly after each course's answer table"""
if highlight_words is None:
highlight_words = []
if theme_hex is None:
theme_hex = THEME_COLOR_HEX
theme_color = RGBColor.from_string(theme_hex)
# Continue with two-column layout for answer tables
continue_two_column_layout(doc)
# Add title for answer section with rounded frame
title_para = doc.add_paragraph()
title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
title_para.paragraph_format.space_before = Pt(12)
title_para.paragraph_format.space_after = Pt(8)
# Calculate width based on text length
response_text = "RÉPONSES"
text_length = len(response_text)
estimated_width = (text_length * 12) + 60
# Create rounded rectangle shape for RÉPONSES
shape_xml = f'''
<w:r xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
xmlns:v="urn:schemas-microsoft-com:vml">
<w:pict>
<v:roundrect style="width:{estimated_width}pt;height:31pt"
arcsize="50%" fillcolor="#{theme_hex}" stroked="f">
<v:textbox inset="10pt,0pt,10pt,0pt" style="v-text-anchor:middle">
<w:txbxContent>
<w:p>
<w:pPr>
<w:jc w:val="center"/>
<w:spacing w:before="0" w:after="0"/>
</w:pPr>
<w:r>
<w:rPr>
<w:rFonts w:ascii="Montserrat" w:hAnsi="Montserrat"/>
<w:b/>
<w:sz w:val="35"/>
<w:color w:val="FFFFFF"/>
</w:rPr>
<w:t>{response_text}</w:t>
</w:r>
</w:p>
</w:txbxContent>
</v:textbox>
</v:roundrect>
</w:pict>
</w:r>
'''
shape_element = parse_xml(shape_xml)
title_para._p.append(shape_element)
# Add bookmark to the responses section with module name
bm_responses_name = sanitize_bookmark_name(f"RESPONSES_{module_name}")
add_bookmark_to_paragraph(title_para, bm_responses_name, bookmark_id)
# Create the TOC entry information
toc_entry = {'level': 'responses', 'text': f"RÉPONSES - {module_name}", 'bm': bm_responses_name}
bookmark_id += 1
# Process each course
overall_question_number = 1
for cours_num in sorted(questions_by_course.keys()):
course_questions = questions_by_course[cours_num]
course_title = cours_titles.get(cours_num, f"COURSE {cours_num}")
# Add course title with keep_with_next
course_title_para = doc.add_paragraph()
course_title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
course_title_para.paragraph_format.space_before = Pt(8)
course_title_para.paragraph_format.space_after = Pt(4)
course_title_para.paragraph_format.keep_with_next = True
course_title_para.paragraph_format.keep_together = True
course_title_para.paragraph_format.page_break_before = False
# Add widow/orphan control
pPr = course_title_para._element.get_or_add_pPr()
widowControl = OxmlElement('w:widowControl')
widowControl.set(qn('w:val'), '1')
pPr.append(widowControl)
num_questions = len(course_questions)
circled_num = get_circled_number(num_questions)
if num_questions == 0:
continue
# 1. The Course Number (e.g., "101.")
run_num = course_title_para.add_run(f"{cours_num}. ")
run_num.font.name = 'Inter ExtraBold'
run_num.font.size = Pt(13)
run_num.font.bold = True
run_num.font.color.rgb = theme_color
# 2. The Course Title (e.g., "Introduction to Python")
run_name = course_title_para.add_run(f"{course_title} ")
run_name.font.name = 'Montserrat'
run_name.font.size = Pt(13)
run_name.font.bold = True
run_name.font.color.rgb = theme_color
# 3. The Circled Number (e.g., "①")
run_circle = course_title_para.add_run(f"{circled_num}")
run_circle.font.name = 'MS UI ghotic'
run_circle.font.size = Pt(13) # Making the circle smaller
run_circle.font.bold = True
run_circle.font.color.rgb = theme_color
# Check if this course has E choices
has_e_choices = check_if_course_has_e_choices(course_questions)
# Determine number of columns and headers
if has_e_choices:
num_cols = 6
headers = ['', 'A', 'B', 'C', 'D', 'E']
choice_letters = ['A', 'B', 'C', 'D', 'E']
else:
num_cols = 5
headers = ['', 'A', 'B', 'C', 'D']
choice_letters = ['A', 'B', 'C', 'D']
# Split questions in half
mid_point = (num_questions + 1) // 2
first_half = course_questions[:mid_point]
second_half = course_questions[mid_point:]
# Create container table
container_table = doc.add_table(rows=1, cols=2)
container_table.alignment = WD_TABLE_ALIGNMENT.CENTER
container_table.allow_autofit = False
# Set table properties to prevent splitting
tblPr = container_table._tbl.tblPr
if tblPr is None:
tblPr = OxmlElement('w:tblPr')
container_table._tbl.insert(0, tblPr)
cantSplit = OxmlElement('w:cantSplit')
tblPr.append(cantSplit)
for row in container_table.rows:
for cell in row.cells:
tcPr = cell._tc.get_or_add_tcPr()
for para in cell.paragraphs:
para.paragraph_format.keep_together = True
para.paragraph_format.keep_with_next = True
# Set container borders to none
tblBorders = parse_xml(f'''
<w:tblBorders {nsdecls("w")}>
<w:top w:val="none"/>
<w:left w:val="none"/>
<w:bottom w:val="none"/>
<w:right w:val="none"/>
<w:insideH w:val="none"/>
<w:insideV w:val="none"/>
</w:tblBorders>
''')
tblPr.append(tblBorders)
# Create tables
left_cell = container_table.rows[0].cells[0]
create_half_answer_table(left_cell, first_half, num_cols, headers, choice_letters, 1, has_e_choices)
right_cell = container_table.rows[0].cells[1]
create_half_answer_table(right_cell, second_half, num_cols, headers, choice_letters, mid_point + 1,
has_e_choices)
# Add spacing after the container table
spacing_para = doc.add_paragraph()
spacing_para.paragraph_format.space_after = Pt(12)
spacing_para.paragraph_format.keep_together = True
# META.PY: Add comment boxes for this course after the answer table
for q_data in course_questions:
choice_commentaire = q_data.get('choice_commentaire', {})
photo_q = q_data.get('photo_q', None)
comment = q_data.get('comment', None)
# Only add comment box if there's something to show
if comment or choice_commentaire or photo_q:
# Use overall_question_number for the question number in the comment box
question_num_in_course = course_questions.index(q_data) + 1
add_choice_commentaire_section(
doc,
choice_commentaire,
photo_q,
theme_color,
theme_hex,
general_comment=comment,
question_num=question_num_in_course,
highlight_words=highlight_words
)
# Update overall counter AFTER processing all questions in this course
overall_question_number += num_questions
# Return both bookmark_id and toc_entry
return bookmark_id, toc_entry
def create_half_answer_table(cell, questions, num_cols, headers, choice_letters, start_q_num, has_e_choices):
"""Create one half of an answer table inside a cell"""
if len(questions) == 0:
return
num_questions = len(questions)
# Fixed Q column width to match the exact measurements from the document
q_col_width = Inches(0.75) # Fixed width for Q column to fit all numbers
# Remove the default empty paragraph in the cell
if len(cell.paragraphs) > 0:
p = cell.paragraphs[0]._element
p.getparent().remove(p)
# Create table inside the cell
table = cell.add_table(rows=num_questions + 1, cols=num_cols)
table.alignment = WD_TABLE_ALIGNMENT.CENTER
table.style = None
table.allow_autofit = False
# CRITICAL: Apply cantSplit to inner table as well
tblPr = table._tbl.tblPr
if tblPr is None:
tblPr = OxmlElement('w:tblPr')
table._tbl.insert(0, tblPr)
# Prevent table from splitting across pages
cantSplit = OxmlElement('w:cantSplit')
tblPr.append(cantSplit)
tbl = table._tbl
tblRows = tbl.xpath(".//w:tr")
if tblRows:
first_row = tblRows[0]
trPr = first_row.get_or_add_trPr()
tblHeader = OxmlElement('w:tblHeader')
trPr.append(tblHeader)
# CRITICAL: Make header row not splittable
cantSplit_row = OxmlElement('w:cantSplit')
trPr.append(cantSplit_row)
# Add table-level border
tblBorders = parse_xml(f'''
<w:tblBorders {nsdecls("w")}>
<w:bottom w:val="single" w:sz="4" w:space="0" w:color="000000"/>
</w:tblBorders>
''')
tblPr.append(tblBorders)
# CRITICAL: Apply keep-together to all rows
for row_idx, row in enumerate(table.rows):
# Get or create row properties
trPr = row._tr.get_or_add_trPr()
# Add cantSplit to each row to prevent it from breaking
cantSplit_row = OxmlElement('w:cantSplit')
trPr.append(cantSplit_row)
for cell_item in row.cells:
for paragraph in cell_item.paragraphs:
paragraph.paragraph_format.keep_together = True
# Keep all rows together by keeping each with next
if row_idx < len(table.rows) - 1:
paragraph.paragraph_format.keep_with_next = True
else:
paragraph.paragraph_format.keep_with_next = False
# Set exact column widths matching the document measurements
choice_col_width = Inches(0.1) # Equal width for all choice columns (A, B, C, D, E)
for row in table.rows:
for col_idx, cell_item in enumerate(row.cells):
if col_idx == 0:
cell_item.width = q_col_width
else:
cell_item.width = choice_col_width
# Header row
header_cells = table.rows[0].cells
for i, header in enumerate(headers):
header_cells[i].text = header
paragraph = header_cells[i].paragraphs[0]
set_zero_spacing(paragraph)
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = paragraph.runs[0] if paragraph.runs else paragraph.add_run(header)
run.font.name = 'Inter SemiBold'
run.font.size = Pt(11)
header_cells[i].vertical_alignment = WD_ALIGN_VERTICAL.CENTER
# Borders
if i == 0:
set_cell_borders(header_cells[i], top=True, bottom=True, left=True, right=False)
elif i == len(headers) - 1:
set_cell_borders(header_cells[i], top=True, bottom=True, left=False, right=True)
else:
set_cell_borders(header_cells[i], top=True, bottom=True, left=False, right=False)
# Gray shading
shading_elm = OxmlElement('w:shd')
shading_elm.set(qn('w:val'), 'clear')
shading_elm.set(qn('w:color'), 'auto')
shading_elm.set(qn('w:fill'), 'D9D9D9')
header_cells[i]._tc.get_or_add_tcPr().append(shading_elm)
# Fill data rows
for row_idx, q_data in enumerate(questions, 1):
row_cells = table.rows[row_idx].cells
is_last_row = (row_idx == num_questions)
# Question number
q_num = start_q_num + row_idx - 1
paragraph = row_cells[0].paragraphs[0]
paragraph.clear()
set_zero_spacing(paragraph)
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = paragraph.add_run(f"Q{q_num}")
run.font.name = 'Inter ExtraBold'
run.font.size = Pt(7.5)
run.font.bold = True
row_cells[0].vertical_alignment = WD_ALIGN_VERTICAL.CENTER
set_cell_borders(row_cells[0], top=False, bottom=is_last_row, left=True, right=False)
# Get correct answers and available choices
correct_answers = [choice['letter'] for choice in q_data['choices'] if choice['is_correct']]
available_choices = [choice['letter'].upper() for choice in q_data['choices']]
has_no_answers = len(correct_answers) == 0
# Fill choice columns
for i, letter in enumerate(choice_letters, 1):
if letter not in available_choices:
row_cells[i].text = ''
elif has_no_answers:
row_cells[i].text = '▨'
elif letter in correct_answers:
row_cells[i].text = '☒'
else:
row_cells[i].text = '☐'
paragraph = row_cells[i].paragraphs[0]
set_zero_spacing(paragraph)
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
if row_cells[i].text:
run = paragraph.runs[0] if paragraph.runs else paragraph.add_run(row_cells[i].text)
run.font.name = 'Calibri'
run.font.size = Pt(11)
run.font.bold = True
row_cells[i].vertical_alignment = WD_ALIGN_VERTICAL.CENTER
# Borders
if i == len(choice_letters):
set_cell_borders(row_cells[i], top=False, bottom=is_last_row, left=False, right=True)
else:
set_cell_borders(row_cells[i], top=False, bottom=is_last_row, left=False, right=False)
def create_empty_course_table(doc, course_questions, course_num, overall_start_num):
"""Create an empty answer table for all questions of one course with dynamic E column
Split in half with two tables side by side, matching create_answer_tables layout"""
num_questions = len(course_questions)
if num_questions == 0:
return overall_start_num
# Check if this course has E choices
has_e_choices = check_if_course_has_e_choices(course_questions)
# Determine number of columns and headers
if has_e_choices:
num_cols = 6 # Q, A, B, C, D, E
headers = ['', 'A', 'B', 'C', 'D', 'E']
choice_letters = ['A', 'B', 'C', 'D', 'E']
else:
num_cols = 5 # Q, A, B, C, D
headers = ['', 'A', 'B', 'C', 'D']
choice_letters = ['A', 'B', 'C', 'D']
# Split questions in half
mid_point = (num_questions + 1) // 2 # Round up for first half
first_half = course_questions[:mid_point]
second_half = course_questions[mid_point:]
print(
f"DEBUG: Empty table for Course {course_num} - Total questions: {num_questions}, Split: {len(first_half)} + {len(second_half)}")
# Create a container table with 1 row and 2 columns to hold both tables side by side
container_table = doc.add_table(rows=1, cols=2)
container_table.alignment = WD_TABLE_ALIGNMENT.CENTER
container_table.allow_autofit = False
# Set table properties to prevent splitting
tblPr = container_table._tbl.tblPr
if tblPr is None:
tblPr = OxmlElement('w:tblPr')
container_table._tbl.insert(0, tblPr)
# Add cantSplit property to prevent table from breaking across pages
cantSplit = OxmlElement('w:cantSplit')
tblPr.append(cantSplit)
# Apply to all cells in the container to reinforce keep-together
for row in container_table.rows:
for cell in row.cells:
tcPr = cell._tc.get_or_add_tcPr()
for para in cell.paragraphs:
para.paragraph_format.keep_together = True
para.paragraph_format.keep_with_next = True
# Set container borders to none
tblBorders = parse_xml(f'''
<w:tblBorders {nsdecls("w")}>
<w:top w:val="none"/>
<w:left w:val="none"/>
<w:bottom w:val="none"/>
<w:right w:val="none"/>
<w:insideH w:val="none"/>
<w:insideV w:val="none"/>
</w:tblBorders>
''')
tblPr.append(tblBorders)
# Create left table (first half)
left_cell = container_table.rows[0].cells[0]
create_half_empty_table(left_cell, first_half, num_cols, headers, choice_letters, overall_start_num, has_e_choices)
# Create right table (second half)
right_cell = container_table.rows[0].cells[1]
start_q_num_right = overall_start_num + len(first_half)
create_half_empty_table(right_cell, second_half, num_cols, headers, choice_letters, start_q_num_right,
has_e_choices)
# Add spacing after the container table
spacing_para = doc.add_paragraph()
spacing_para.paragraph_format.space_after = Pt(12)
spacing_para.paragraph_format.keep_together = True
return overall_start_num + num_questions
def create_half_empty_table(cell, questions, num_cols, headers, choice_letters, start_q_num, has_e_choices):
"""Create one half of an empty answer table inside a cell"""
if len(questions) == 0:
return
num_questions = len(questions)
# Fixed Q column width to match answer tables
q_col_width = Inches(0.75) # Fixed width for Q column
# Remove the default empty paragraph in the cell
if len(cell.paragraphs) > 0:
p = cell.paragraphs[0]._element
p.getparent().remove(p)
# Create table inside the cell
table = cell.add_table(rows=num_questions + 1, cols=num_cols)
table.alignment = WD_TABLE_ALIGNMENT.CENTER
table.style = None
table.allow_autofit = False
# CRITICAL: Apply cantSplit to inner table as well
tblPr = table._tbl.tblPr
if tblPr is None:
tblPr = OxmlElement('w:tblPr')
table._tbl.insert(0, tblPr)
# Prevent table from splitting across pages
cantSplit = OxmlElement('w:cantSplit')
tblPr.append(cantSplit)
# Mark first row as header row
tbl = table._tbl
tblRows = tbl.xpath(".//w:tr")
if tblRows:
first_row = tblRows[0]
trPr = first_row.get_or_add_trPr()
tblHeader = OxmlElement('w:tblHeader')
trPr.append(tblHeader)
# Make header row not splittable
cantSplit_row = OxmlElement('w:cantSplit')
trPr.append(cantSplit_row)
# Add table-level border
tblBorders = parse_xml(f'''
<w:tblBorders {nsdecls("w")}>
<w:bottom w:val="single" w:sz="4" w:space="0" w:color="000000"/>
</w:tblBorders>
''')
tblPr.append(tblBorders)
# CRITICAL: Apply keep-together to all rows
for row_idx, row in enumerate(table.rows):
# Get or create row properties
trPr = row._tr.get_or_add_trPr()
# Add cantSplit to each row to prevent it from breaking
cantSplit_row = OxmlElement('w:cantSplit')
trPr.append(cantSplit_row)
for cell_item in row.cells:
for paragraph in cell_item.paragraphs:
paragraph.paragraph_format.keep_together = True
# Keep all rows together by keeping each with next
if row_idx < len(table.rows) - 1:
paragraph.paragraph_format.keep_with_next = True
else:
paragraph.paragraph_format.keep_with_next = False
# Set exact column widths matching the answer table measurements
choice_col_width = Inches(0.1) # Equal width for all choice columns
for row in table.rows:
for col_idx, cell_item in enumerate(row.cells):
if col_idx == 0:
cell_item.width = q_col_width
else:
cell_item.width = choice_col_width
# Header row
header_cells = table.rows[0].cells
for i, header in enumerate(headers):
header_cells[i].text = header
paragraph = header_cells[i].paragraphs[0]
set_zero_spacing(paragraph)
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = paragraph.runs[0] if paragraph.runs else paragraph.add_run(header)
run.font.name = 'Inter SemiBold'
run.font.size = Pt(11)
header_cells[i].vertical_alignment = WD_ALIGN_VERTICAL.CENTER
# Borders
if i == 0:
set_cell_borders(header_cells[i], top=True, bottom=True, left=True, right=False)
elif i == len(headers) - 1:
set_cell_borders(header_cells[i], top=True, bottom=True, left=False, right=True)
else:
set_cell_borders(header_cells[i], top=True, bottom=True, left=False, right=False)
# Gray shading
shading_elm = OxmlElement('w:shd')
shading_elm.set(qn('w:val'), 'clear')
shading_elm.set(qn('w:color'), 'auto')
shading_elm.set(qn('w:fill'), 'D9D9D9')
header_cells[i]._tc.get_or_add_tcPr().append(shading_elm)
# Fill data rows with empty checkboxes
for row_idx, q_data in enumerate(questions, 1):
row_cells = table.rows[row_idx].cells
is_last_row = (row_idx == num_questions)
# Question number
q_num = start_q_num + row_idx - 1
paragraph = row_cells[0].paragraphs[0]
paragraph.clear()
set_zero_spacing(paragraph)
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = paragraph.add_run(f"Q{q_num}")
run.font.name = 'Inter ExtraBold'
run.font.size = Pt(7.5)
run.font.bold = True
row_cells[0].vertical_alignment = WD_ALIGN_VERTICAL.CENTER
set_cell_borders(row_cells[0], top=False, bottom=is_last_row, left=True, right=False)
# Get available choices for this specific question
available_choices = [choice['letter'].upper() for choice in q_data['choices']]
# Fill choice columns with empty checkboxes
for i, letter in enumerate(choice_letters, 1):
if letter not in available_choices:
# Choice doesn't exist - leave empty
row_cells[i].text = ''
else:
# Choice exists - show empty checkbox
row_cells[i].text = '☐'
paragraph = row_cells[i].paragraphs[0]
set_zero_spacing(paragraph)
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
if row_cells[i].text:
run = paragraph.runs[0] if paragraph.runs else paragraph.add_run(row_cells[i].text)
run.font.name = 'Calibri'
run.font.size = Pt(11)
run.font.bold = True
row_cells[i].vertical_alignment = WD_ALIGN_VERTICAL.CENTER
# Borders
if i == len(choice_letters):
set_cell_borders(row_cells[i], top=False, bottom=is_last_row, left=False, right=True)
else:
set_cell_borders(row_cells[i], top=False, bottom=is_last_row, left=False, right=False)
def sanitize_bookmark_name(text):
"""Create a safe bookmark name (letters, numbers, underscores)."""
name = re.sub(r'[^A-Za-z0-9_]', '_', str(text))
# Word has bookmark name length limits — keep it short
return name[:40]
def add_bookmark_to_paragraph(paragraph, bookmark_name, bm_id):
"""Wrap the paragraph with a Word bookmark (start & end)."""
# bookmarkStart: should be before the paragraph text
bookmark_start = OxmlElement('w:bookmarkStart')
bookmark_start.set(qn('w:id'), str(bm_id))
bookmark_start.set(qn('w:name'), bookmark_name)
paragraph._p.insert(0, bookmark_start)
# bookmarkEnd: appended after paragraph content
bookmark_end = OxmlElement('w:bookmarkEnd')
bookmark_end.set(qn('w:id'), str(bm_id))
paragraph._p.append(bookmark_end)
def add_pagenumber_field_in_paragraph(paragraph, bookmark_name, right_inch=Inches(6.5)):
"""
Insert a PAGEREF field pointing to bookmark_name.
This function also adds a right tab stop with dotted leader and a tab character
so the page number appears at the right edge with dot leaders.
"""
# add a right aligned tab stop with dots
try:
paragraph.paragraph_format.tab_stops.add_tab_stop(right_inch, WD_TAB_ALIGNMENT.RIGHT, WD_TAB_LEADER.DOTS)
except Exception:
# If the tab_stop API differs, ignore and still try to insert the field
pass
# Add a tab character so the PAGEREF sits at the right tab stop
tab_run = paragraph.add_run('\t')
# Create field: begin -> instrText -> end
fldChar1 = OxmlElement('w:fldChar');
fldChar1.set(qn('w:fldCharType'), 'begin')
instrText = OxmlElement('w:instrText');
instrText.set(qn('xml:space'), 'preserve')
instrText.text = f"PAGEREF {bookmark_name} \\h"
fldChar2 = OxmlElement('w:fldChar');
fldChar2.set(qn('w:fldCharType'), 'end')
tab_run._r.append(fldChar1)
tab_run._r.append(instrText)
tab_run._r.append(fldChar2)
def estimate_content_length(questions_by_course, cours_titles):
"""Estimate relative content length for each question to better balance columns"""
question_lengths = []
total_estimated_lines = 0
for cours_num in sorted(questions_by_course.keys()):
course_questions = questions_by_course[cours_num]
course_title = cours_titles.get(cours_num, f"COURSE {cours_num}")
# Add course title weight (approximately 2-3 lines)
course_weight = 3
total_estimated_lines += course_weight
for q_data in course_questions:
# Estimate lines for this question
question_lines = 2 # Question line + spacing
question_lines += len(q_data['choices']) # Choice lines
question_lines += 2 # Source/answer line + spacing
if q_data.get('comment') and str(q_data['comment']).strip() and str(q_data['comment']).lower() != 'nan':
question_lines += 2 # Comment lines
question_lengths.append({
'cours': cours_num,
'question': q_data,
'estimated_lines': question_lines
})
total_estimated_lines += question_lines
return question_lengths, total_estimated_lines
def read_course_titles_from_module_sheet(excel_file_path, module_name):
"""Read course titles from a module-specific sheet (case-insensitive)"""
cours_titles = {}
print(f" DEBUG: Looking for sheet matching module '{module_name}'")
# Get all sheet names from the Excel file
xls = pd.ExcelFile(excel_file_path)
sheet_names = xls.sheet_names
# Find matching sheet (case-insensitive)
target_sheet = None
module_name_lower = str(module_name).strip().lower()
print(f" DEBUG: Module name (lowercase): '{module_name_lower}'")
print(f" DEBUG: Available sheets: {sheet_names}")
for sheet in sheet_names:
sheet_lower = sheet.strip().lower()
print(f" DEBUG: Comparing '{module_name_lower}' with '{sheet_lower}'")
if sheet_lower == module_name_lower:
target_sheet = sheet
print(f" DEBUG: MATCH FOUND! Using sheet '{target_sheet}'")
break
if target_sheet is None:
print(f" DEBUG: No sheet found matching module '{module_name}'")
return cours_titles
# Read the matching sheet
cours_df = pd.read_excel(excel_file_path, sheet_name=target_sheet)
print(f" DEBUG: Sheet '{target_sheet}' has {len(cours_df)} rows")
print(f" DEBUG: Sheet columns: {list(cours_df.columns)}")
if not cours_df.empty and 'cours' in cours_df.columns and 'titre' in cours_df.columns:
for idx, row in cours_df.iterrows():
print(f" DEBUG: Row {idx}: cours={row['cours']}, titre={row.get('titre', 'N/A')}")
if pd.notna(row['cours']) and pd.notna(row['titre']):
# Only store valid numeric courses
if is_valid_cours_number(row['cours']):
cours_num = int(float(str(row['cours']).strip()))
cours_titles[cours_num] = row['titre']
print(f" DEBUG: Added cours {cours_num}: {row['titre']}")
else:
print(f" DEBUG: Skipped invalid cours: {row['cours']}")
print(f" DEBUG: Final count: {len(cours_titles)} course titles from sheet '{target_sheet}'")
else:
print(f" DEBUG: Sheet '{target_sheet}' doesn't have expected structure")
print(f" DEBUG: Has 'cours' column: {'cours' in cours_df.columns}")
print(f" DEBUG: Has 'titre' column: {'titre' in cours_df.columns}")
return cours_titles
def enable_odd_even_headers(doc):
"""Ensure evenAndOddHeaders is ABSENT. We use IF+MOD(PAGE,2) fields instead,
which flip left/right text on every page with zero blank-page side effects."""
try:
settings_element = doc.settings.element
even_odd = settings_element.find(qn('w:evenAndOddHeaders'))
if even_odd is not None:
settings_element.remove(even_odd)
print("✓ evenAndOddHeaders removed - IF field flip active")
else:
print("✓ evenAndOddHeaders absent - IF field flip active")
except Exception as e:
print(f"Note: {e}")
def create_flexible_header(section, module_name, sheet_name, display_name=None, left_margin_inches=0,
right_margin_inches=0, theme_hex=None):
"""Single header using IF+MOD(PAGE,2) fields to flip left/right text on each page.
No evenAndOddHeaders needed. Works in Word, Adobe, ilovepdf with zero blank pages.
Odd pages (PAGE mod 2 = 1): sheet_name LEFT, module_name RIGHT
Even pages (PAGE mod 2 = 0): module_name LEFT, sheet_name RIGHT
"""
if theme_hex is None:
theme_hex = THEME_COLOR_HEX
section.header_distance = Cm(0.6)
module_name_str = str(module_name).upper()
if display_name:
sheet_name_str = str(display_name).upper()
else:
sheet_name_str = str(sheet_name).upper()
module_name_str = html.escape(module_name_str)
sheet_name_str = html.escape(sheet_name_str)
right_tab_twips = 11000
def make_if_mod_page_field(odd_text, even_text, color):
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
def run(instr=None, text=None, fld_type=None):
r_xml = (
f'<w:r xmlns:w="{W}">'
f'<w:rPr><w:rFonts w:ascii="Montserrat" w:hAnsi="Montserrat"/>'
f'<w:b/><w:sz w:val="26"/><w:color w:val="{color}"/></w:rPr>'
)
if fld_type:
r_xml += f'<w:fldChar w:fldCharType="{fld_type}"/>'
if instr is not None:
r_xml += f'<w:instrText xml:space="preserve">{instr}</w:instrText>'
if text is not None:
r_xml += f'<w:t>{text}</w:t>'
r_xml += '</w:r>'
return parse_xml(r_xml)
elements = []
elements.append(run(fld_type='begin'))
elements.append(run(instr=' IF '))
elements.append(run(fld_type='begin'))
elements.append(run(instr=' = MOD('))
elements.append(run(fld_type='begin'))
elements.append(run(instr=' PAGE '))
elements.append(run(fld_type='separate'))
elements.append(run(text='1'))
elements.append(run(fld_type='end'))
elements.append(run(instr=',2) '))
elements.append(run(fld_type='separate'))
elements.append(run(text='1'))
elements.append(run(fld_type='end'))
elements.append(run(instr=f' = 1 "{odd_text}" "{even_text}" '))
elements.append(run(fld_type='separate'))
elements.append(run(text=odd_text))
elements.append(run(fld_type='end'))
return elements
def build_flipping_header(header):
header.is_linked_to_previous = False
if not header.paragraphs:
header.add_paragraph()
para = header.paragraphs[0]
para.clear()
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
pPr_xml = (
f'<w:pPr xmlns:w="{W}">'
'<w:spacing w:before="0" w:after="0"/>'
'<w:tabs>'
f'<w:tab w:val="right" w:pos="{right_tab_twips}"/>'
'</w:tabs>'
'</w:pPr>'
)
existing_pPr = para._p.find('{%s}pPr' % W)
if existing_pPr is not None:
para._p.remove(existing_pPr)
para._p.insert(0, parse_xml(pPr_xml))
for elem in make_if_mod_page_field(sheet_name_str, module_name_str, theme_hex):
para._p.append(elem)
tab_xml = (
f'<w:r xmlns:w="{W}">'
'<w:rPr><w:sz w:val="26"/></w:rPr>'
'<w:tab/>'
'</w:r>'
)
para._p.append(parse_xml(tab_xml))
for elem in make_if_mod_page_field(module_name_str, sheet_name_str, theme_hex):
para._p.append(elem)
build_flipping_header(section.header)
print(f"Flipping header set: odd=[{sheet_name_str} | {module_name_str}] even=[{module_name_str} | {sheet_name_str}]")
def extract_display_name_from_excel(excel_file_path):
"""Extract display name from Excel file - checks multiple locations"""
try:
xls = pd.ExcelFile(excel_file_path)
first_sheet_name = xls.sheet_names[0]
df = pd.read_excel(excel_file_path, sheet_name=first_sheet_name, nrows=5)
# Strategy 1: Look for a cell with "Name:", "Display Name:", etc.
for col in df.columns:
for idx, val in df[col].items():
if pd.notna(val):
val_str = str(val).strip().lower()
if any(keyword in val_str for keyword in ['name:', 'nom:', 'display name:', 'titre:']):
# Get the value from next cell or same row
try:
if ':' in str(val):
return str(val).split(':', 1)[1].strip()
elif idx + 1 < len(df):
next_val = df[col].iloc[idx + 1]
if pd.notna(next_val):
return str(next_val).strip()
except:
pass
# Strategy 2: Check for a dedicated "Info" or "Metadata" sheet
for sheet_name in xls.sheet_names:
if any(keyword in sheet_name.lower() for keyword in ['info', 'metadata', 'details', 'nom']):
info_df = pd.read_excel(excel_file_path, sheet_name=sheet_name, nrows=10)
for col in info_df.columns:
for idx, val in info_df[col].items():
if pd.notna(val) and 'name' in str(val).lower():
if idx + 1 < len(info_df):
next_val = info_df[col].iloc[idx + 1]
if pd.notna(next_val):
return str(next_val).strip()
# Strategy 3: Check first cell of first sheet
if not df.empty and pd.notna(df.iloc[0, 0]):
first_cell = str(df.iloc[0, 0]).strip()
if len(first_cell) < 50 and not any(char.isdigit() for char in first_cell[:10]):
return first_cell
# Fallback: Use filename without extension
return os.path.splitext(os.path.basename(excel_file_path))[0]
except Exception as e:
print(f"Error extracting display name: {e}")
# Ultimate fallback
return os.path.splitext(os.path.basename(excel_file_path))[0]
def add_colored_column_separator(section, theme_hex=None):
"""Add a custom colored vertical line between columns to both odd and even headers"""
if theme_hex is None:
theme_hex = THEME_COLOR_HEX
def add_line_to_header(header_elem, line_id="columnSeparator"):
"""Helper function to add the separator line to a header"""
# Find or create the first paragraph in header
if not header_elem.paragraphs:
header_elem.add_paragraph()
header_para = header_elem.paragraphs[0]
# Create a vertical line using VML shape
# The line starts AFTER the header and goes to the bottom
line_xml = f'''
<w:r xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
<w:pict>
<v:line id="{line_id}"
style="position:absolute;left:0;text-align:left;z-index:-1;
mso-position-horizontal:center;
mso-position-horizontal-relative:margin;
mso-position-vertical-relative:page"
from="0,0.49in" to="0,11.05in"
strokecolor="#{theme_hex}"
strokeweight="1.5pt">
<o:lock v:ext="edit" aspectratio="f"/>
</v:line>
</w:pict>
</w:r>
'''
line_element = parse_xml(line_xml)
header_para._p.append(line_element)
# Add line to odd/default header
header = section.header
add_line_to_header(header, "columnSeparatorOdd")
print("Added column separator")
def add_choice_commentaire_section(doc, choice_commentaire, photo_q_path, theme_color=None, theme_hex=None,
general_comment=None, question_num=None, highlight_words=None,
highlight_comment_words=None):
"""Add a framed section with general comment, choice commentaires and optional photo Q
Split into 2/3 for comments and 1/3 for photo (or full width if no photo)
WITH DASHED BORDER AND SHADED BACKGROUND"""
if highlight_words is None:
highlight_words = []
if highlight_comment_words is None:
highlight_comment_words = []
if theme_color is None:
theme_color = THEME_COLOR
if theme_hex is None:
theme_hex = THEME_COLOR_HEX
# Only add if there are comments or photo
if not choice_commentaire and not photo_q_path and not general_comment:
return
print(
f"DEBUG: add_choice_commentaire_section called with {len(choice_commentaire) if choice_commentaire else 0} comments")
# Check if photo exists and is valid
has_photo = False
if photo_q_path:
# Clean the path
photo_q_path_clean = str(photo_q_path).strip()
print(f"DEBUG: Checking photo path: '{photo_q_path_clean}'")
if photo_q_path_clean and photo_q_path_clean.lower() not in ['nan', 'none', '']:
# Check file existence
if os.path.exists(photo_q_path_clean):
has_photo = True
print(f"DEBUG: ✓ Photo Q exists: {photo_q_path_clean}")
# Check if it's a valid image file
valid_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff']
file_ext = os.path.splitext(photo_q_path_clean)[1].lower()
if file_ext not in valid_extensions:
print(f"WARNING: File extension '{file_ext}' might not be supported. Valid: {valid_extensions}")
else:
print(f"DEBUG: ✗ Photo Q does NOT exist at: {photo_q_path_clean}")
print(f"DEBUG: Current working directory: {os.getcwd()}")
print(f"DEBUG: Absolute path would be: {os.path.abspath(photo_q_path_clean)}")
# Create a table with 1 row and 2 columns (or 1 if no photo)
if has_photo:
table = doc.add_table(rows=1, cols=2)
table.alignment = WD_TABLE_ALIGNMENT.LEFT
table.allow_autofit = False
# Set column widths: 2/3 for text, 1/3 for photo
left_cell = table.rows[0].cells[0]
right_cell = table.rows[0].cells[1]
# Set explicit widths
left_cell.width = Inches(3.5) # 2/3 of available width
right_cell.width = Inches(1.75) # 1/3 of available width
# Set vertical alignment to top for both cells
left_cell.vertical_alignment = WD_ALIGN_VERTICAL.TOP
right_cell.vertical_alignment = WD_ALIGN_VERTICAL.TOP
else:
table = doc.add_table(rows=1, cols=1)
table.alignment = WD_TABLE_ALIGNMENT.LEFT
left_cell = table.rows[0].cells[0]
left_cell.width = Inches(5.25) # Full width
# Add DASHED border to the table with theme color
tblPr = table._tbl.tblPr
if tblPr is None:
tblPr = OxmlElement('w:tblPr')
table._tbl.insert(0, tblPr)
# Use theme_hex (the input color) for borders
border_color = theme_hex
# Border size: 1.5pt = 12 eighths of a point (1.5 * 8 = 12)
tblBorders = parse_xml(f'''
<w:tblBorders {nsdecls("w")}>
<w:top w:val="dashed" w:sz="12" w:space="0" w:color="{border_color}"/>
<w:left w:val="dashed" w:sz="12" w:space="0" w:color="{border_color}"/>
<w:bottom w:val="dashed" w:sz="12" w:space="0" w:color="{border_color}"/>
<w:right w:val="dashed" w:sz="12" w:space="0" w:color="{border_color}"/>
<w:insideH w:val="dashed" w:sz="12" w:space="0" w:color="{border_color}"/>
<w:insideV w:val="dashed" w:sz="12" w:space="0" w:color="{border_color}"/>
</w:tblBorders>
''')
tblPr.append(tblBorders)
# Add padding to cells
for cell in table.rows[0].cells:
tcPr = cell._tc.get_or_add_tcPr()
tcMar = OxmlElement('w:tcMar')
for margin in ['top', 'left', 'bottom', 'right']:
mar = OxmlElement(f'w:{margin}')
mar.set(qn('w:w'), '80') # 80 twips = ~0.06 inches padding
mar.set(qn('w:type'), 'dxa')
tcMar.append(mar)
tcPr.append(tcMar)
# Add light gray shading to left cell
left_tcPr = left_cell._tc.get_or_add_tcPr()
shading_elm = OxmlElement('w:shd')
shading_elm.set(qn('w:val'), 'clear')
shading_elm.set(qn('w:color'), 'auto')
shading_elm.set(qn('w:fill'), 'F2F2F2') # Light gray (20% black)
left_tcPr.append(shading_elm)
# If there's a photo, also add shading to the right cell
if has_photo:
right_tcPr = right_cell._tc.get_or_add_tcPr()
shading_elm_right = OxmlElement('w:shd')
shading_elm_right.set(qn('w:val'), 'clear')
shading_elm_right.set(qn('w:color'), 'auto')
shading_elm_right.set(qn('w:fill'), 'F2F2F2') # Same light gray
right_tcPr.append(shading_elm_right)
# Clear the default empty paragraph first
if left_cell.paragraphs:
left_cell.paragraphs[0].clear()
comment_index = 0
# ADD GENERAL COMMENT FIRST if it exists
if question_num and general_comment and str(general_comment).strip() and str(general_comment).lower() != 'nan':
# Use the first paragraph for the general comment
if comment_index == 0 and left_cell.paragraphs:
question_num_para = left_cell.paragraphs[0]
else:
question_num_para = left_cell.add_paragraph()
question_num_para.paragraph_format.space_before = Pt(1)
question_num_para.paragraph_format.space_after = Pt(1)
question_num_para.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
# Add question number prefix "Qx: " in bold with theme color
q_num_run = question_num_para.add_run(f"Q{question_num}: ")
q_num_run.font.name = 'Inter ExtraBold'
q_num_run.font.size = Pt(8)
q_num_run.font.bold = True
q_num_run.font.color.rgb = theme_color
# Add the general comment text
text_run = question_num_para.add_run(f"{str(general_comment)}")
text_run.font.name = 'Inter SemiBold'
text_run.font.size = Pt(8)
comment_index += 1
# Add choice commentaires
if choice_commentaire:
# Filter out comments that are only X's
filtered_commentaire = {letter: text for letter, text in choice_commentaire.items()
if not is_only_x_string(text)}
print(f"DEBUG: Adding {len(filtered_commentaire)} choice comments")
for choice_letter in sorted(filtered_commentaire.keys()):
comment_text = filtered_commentaire[choice_letter]
print(f"DEBUG: Adding comment {choice_letter}: {comment_text[:50]}...")
# Use the first paragraph if no general comment, otherwise add new
if comment_index == 0 and left_cell.paragraphs:
comment_para = left_cell.paragraphs[0]
else:
comment_para = left_cell.add_paragraph()
comment_para.paragraph_format.space_before = Pt(1)
comment_para.paragraph_format.space_after = Pt(0)
comment_para.paragraph_format.line_spacing = 1.0
comment_para.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
# Add question number prefix "Qx: " ONLY for the first comment (comment_index == 0)
if comment_index == 0:
q_num_run = comment_para.add_run(f"Q{question_num}: ")
q_num_run.font.name = 'Inter ExtraBold'
q_num_run.font.size = Pt(8)
q_num_run.font.bold = True
q_num_run.font.color.rgb = theme_color
# Choice letter in bold with theme color
letter_run = comment_para.add_run(f"{choice_letter}- ")
letter_run.font.name = 'Inter ExtraBold'
letter_run.font.size = Pt(8)
letter_run.font.bold = True
letter_run.font.color.rgb = theme_color
# Comment text
if highlight_comment_words:
highlight_words_in_text(comment_para, comment_text, highlight_comment_words, theme_color,
font_name='Inter Display SemiBold', font_size=8)
else:
text_run = comment_para.add_run(comment_text)
text_run.font.name = 'Inter Display SemiBold'
text_run.font.size = Pt(8)
comment_index += 1
# If no comments at all but has photo, add placeholder text
if comment_index == 0:
print("DEBUG: No comments found, adding placeholder")
placeholder_para = left_cell.paragraphs[0] if left_cell.paragraphs else left_cell.add_paragraph()
placeholder_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
placeholder_run = placeholder_para.add_run("[See image]")
placeholder_run.font.name = 'Inter Display'
placeholder_run.font.size = Pt(9)
placeholder_run.font.italic = True
# Add photo to right cell if exists
if has_photo:
try:
print(f"DEBUG: Attempting to add photo: {photo_q_path_clean}")
# Clear the default empty paragraph and reuse it
if right_cell.paragraphs:
photo_para = right_cell.paragraphs[0]
photo_para.clear()
else:
photo_para = right_cell.add_paragraph()
photo_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
photo_para.paragraph_format.space_before = Pt(0)
photo_para.paragraph_format.space_after = Pt(0)
run = photo_para.add_run()
# Try different image sizes
try:
run.add_picture(photo_q_path_clean, width=Inches(1.5))
print(f"DEBUG: ✓ Successfully added Photo Q at 1.5 inches width")
except Exception as e1:
print(f"DEBUG: Failed at 1.5 inches, trying height-based: {e1}")
run.add_picture(photo_q_path_clean, height=Inches(2.0))
print(f"DEBUG: ✓ Successfully added Photo Q at 2.0 inches height")
except Exception as e:
# If photo fails to load, add error text
print(f"ERROR: Failed to add Photo Q: {type(e).__name__}: {str(e)}")
error_para = right_cell.add_paragraph()
error_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
error_run = error_para.add_run(f"[Photo error: {type(e).__name__}]")
error_run.font.size = Pt(7)
error_run.font.italic = True
error_run.font.color.rgb = RGBColor(255, 0, 0)
# Add spacing after the table
empty_para = doc.add_paragraph(' ', style='TinySpace')
empty_para.paragraph_format.space_before = Pt(0)
empty_para.paragraph_format.space_after = Pt(0)
empty_para.paragraph_format.line_spacing = Pt(7)
empty_run = empty_para.add_run(' ')
empty_run.font.size = Pt(7)
def extract_embedded_images_info(excel_file_path):
"""
Inform user about embedded images in Excel.
Excel formulas like =DISPIMG() cannot be extracted programmatically with pandas.
"""
print("\n" + "!" * 60)
print("IMPORTANT: EMBEDDED IMAGES DETECTED")
print("!" * 60)
print("Your Excel file contains embedded images using =DISPIMG() formulas.")
print("These images are stored INSIDE the Excel file and cannot be accessed")
print("as file paths.")
print()
print("TO FIX THIS:")
print("1. Open your Excel file")
print("2. Save the images as separate files (right-click > Save as Picture)")
print("3. Update the 'Photo Q' and 'Photo C' columns with the file paths")
print(" Example: 'images/question1.png' instead of '=DISPIMG(...)'")
print()
print("Alternative: Use OneDrive/SharePoint links or export images first")
print("!" * 60 + "\n")
def process_excel_to_word(excel_file_path, output_word_path, image_folder, display_name=None, use_two_columns=True,
add_separator_line=True, balance_method="dynamic", theme_hex=None, highlight_words=None,
highlight_comment_words=None):
"""Main function to process Excel and create a Word document with TOC on the first page"""
if highlight_words is None:
highlight_words = []
if highlight_comment_words is None:
highlight_comment_words = []
if theme_hex is None:
theme_hex = THEME_COLOR_HEX
theme_color = RGBColor.from_string(theme_hex)
# Prepare image folder (extract if ZIP)
actual_image_folder, is_temp, temp_dir_obj = prepare_image_folder(image_folder)
# Map images from the prepared folder
question_photos = map_images_from_excel(excel_file_path, actual_image_folder)
# Read the Excel file
xls = pd.ExcelFile(excel_file_path)
first_sheet_name = xls.sheet_names[0] # Get the first sheet name
questions_df = pd.read_excel(excel_file_path, sheet_name=first_sheet_name)
# Extract display name if not provided
if display_name is None:
display_name = extract_display_name_from_excel(excel_file_path)
print(f"Extracted display name: {display_name}")
# Get unique modules from Questions sheet (case-insensitive)
module_col = None
for col in questions_df.columns:
if col.lower().strip() == 'module':
module_col = col
break
if module_col:
xls_temp = pd.ExcelFile(excel_file_path)
all_sheets = xls_temp.sheet_names
modules_in_questions = questions_df[module_col].dropna().unique()
# Create a mapping from lowercase module name to actual sheet name
module_to_sheet = {}
for module in modules_in_questions:
module_lower = str(module).strip().lower()
for sheet in all_sheets:
if sheet.strip().lower() == module_lower:
module_to_sheet[module] = sheet
break
# Normalize all module names in the dataframe
questions_df[module_col] = questions_df[module_col].apply(
lambda x: module_to_sheet.get(x, x) if pd.notna(x) else x
)
# Get unique modules in sheet order
modules = []
seen = set()
for sheet in all_sheets:
sheet_lower = sheet.strip().lower()
for module in modules_in_questions:
if str(module).strip().lower() == sheet_lower and sheet not in seen:
modules.append(sheet)
seen.add(sheet)
break
else:
modules = []
# Read course titles from module-specific sheets
modules_data = {}
xls = pd.ExcelFile(excel_file_path)
for module in modules:
try:
cours_titles_for_module = read_course_titles_from_module_sheet(excel_file_path, module)
modules_data[module] = cours_titles_for_module
except Exception as e:
print(f"DEBUG: Error reading module '{module}': {e}")
# Clean column names
questions_df.columns = questions_df.columns.str.strip()
# Check if photo columns exist
has_photo_q_col = 'Photo Q' in questions_df.columns
has_photo_c_col = 'Photo C' in questions_df.columns
if not has_photo_q_col and not has_photo_c_col:
print("ℹ️ No photo columns found in Excel - images will be skipped")
elif not has_photo_q_col:
print("ℹ️ 'Photo Q' column not found - question images will be skipped")
elif not has_photo_c_col:
print("ℹ️ 'Photo C' column not found - choice images will be skipped")
# Create Word document
doc = Document()
enable_odd_even_headers(doc)
# Force TOC section break to nextPage (prevents blank page with any PDF converter)
toc_sectPr = doc.sections[0]._sectPr
toc_type_elem = toc_sectPr.find(qn('w:type'))
if toc_type_elem is None:
toc_type_elem = OxmlElement('w:type')
toc_sectPr.insert(0, toc_type_elem)
toc_type_elem.set(qn('w:val'), 'nextPage')
print("TOC section break set to nextPage")
core_props = doc.core_properties
core_props.author = "Natural Killer"
core_props.title = "Manhattan Project"
core_props.subject = "QCM"
core_props.comments = "Created By NK"
core_props.last_modified_by = "NK"
core_props.generator = "Microsoft Word"
set_page_size(doc.sections[0], PAPER_SIZES['A4_WIDE'][0], PAPER_SIZES['A4'][1])
# ========================================
# ADD THREE EMPTY PAGES AT THE BEGINNING
# ========================================
for i in range(3):
doc.add_paragraph() # Add empty paragraph
if i < 2: # Add page breaks for first 2 pages (3rd page leads to TOC)
doc.add_page_break()
# TOC helpers
toc_entries = []
bookmark_id = 1
# Set page margins
for section in doc.sections:
section.top_margin = Inches(0.5)
section.bottom_margin = Inches(0.5)
section.left_margin = Cm(1.1)
section.right_margin = Cm(1.1)
# ========================================
# CREATE TOC SECTION FIRST (TWO COLUMNS - SPLIT PAGE)
# ========================================
toc_section = doc.sections[0]
sectPr = toc_section._sectPr
cols = sectPr.find(qn('w:cols'))
if cols is None:
cols = OxmlElement('w:cols')
sectPr.append(cols)
cols.set(qn('w:num'), '2')
cols.set(qn('w:space'), '432') # 0.3 inch spacing between columns
# Add TOC title
toc_title = doc.add_paragraph()
toc_title.alignment = WD_ALIGN_PARAGRAPH.CENTER
toc_title.paragraph_format.space_after = Pt(12)
toc_title_run = toc_title.add_run("Sommaire")
toc_title_run.font.name = 'Montserrat'
toc_title_run.font.size = Pt(16)
toc_title_run.font.bold = True
toc_title_run.font.color.rgb = theme_color
# Add bookmark to TOC title
add_toc_bookmark(doc, toc_title)
# Remember position to insert TOC entries later
toc_insert_index = len(doc.paragraphs)
# ========================================
# START NEW SECTION FOR CONTENT (TWO COLUMNS)
# ========================================
doc.add_section(WD_SECTION.NEW_PAGE)
# Process questions
processed_questions = []
current_question = None
current_choices = []
skipped_s2_questions = 0
for idx, row in questions_df.iterrows():
numero = row['Numero']
if pd.notna(numero):
if current_question is not None and current_choices and is_valid_cours_number(current_cours):
processed_questions.append({
'numero': current_question,
'question_text': current_question_text,
'source': current_source,
'comment': current_comment,
'cours': int(float(str(current_cours).strip())),
'module': current_module,
'choices': current_choices.copy(),
'choice_commentaire': current_choice_commentaire,
'photo_q': question_photos.get(current_question, {}).get('photo_q', None), # LINKED!
'photo_c': question_photos.get(current_question, {}).get('photo_c', None) # LINKED!
})
elif current_question is not None and not is_valid_cours_number(current_cours):
skipped_s2_questions += 1
current_question = numero
current_question_text = str(row['Question']).strip()
current_source = str(row['Source']).strip() if pd.notna(row['Source']) else ""
current_comment = str(row['Comment']).strip() if pd.notna(row['Comment']) and str(
row['Comment']).lower() != 'nan' else None
current_cours = row['Cours'] if pd.notna(row['Cours']) else 1
current_module = row[module_col] if module_col and pd.notna(row[module_col]) else None
current_choices = []
current_choice_commentaire = {} # NEW: Initialize per question
# Initialize photo storage for this question
if current_question not in question_photos:
question_photos[current_question] = {'photo_q': None, 'photo_c': None}
current_choice_commentaire = {}
# CHECK FOR PHOTOS ON THIS ROW - Store DIRECTLY in question_photos dict
if has_photo_q_col and pd.notna(row.get('Photo Q', None)):
photo_q_raw = str(row['Photo Q']).strip()
if has_photo_c_col and pd.notna(row.get('Photo C', None)):
photo_c_raw = str(row['Photo C']).strip()
# Process each CHOICE row - CHECK FOR PHOTOS ON EVERY ROW!
if is_valid_cours_number(current_cours):
choice_letter = str(row['Order']).strip().upper()
choice_text = str(row['ChoiceText']).strip()
ct_value = str(row['CT']).strip().upper() if pd.notna(row['CT']) else ""
is_correct = ct_value == 'X'
# Read choice commentaire for THIS specific choice
if pd.notna(row.get('Choice commentaire', None)):
choice_comment = str(row['Choice commentaire']).strip()
if choice_comment and choice_comment.lower() not in ['nan', 'none', '']:
current_choice_commentaire[choice_letter] = choice_comment
# CHECK FOR PHOTOS ON THIS ROW (could be any choice row!)
# CRITICAL FIX: Store directly in question_photos, not in temporary variables
if has_photo_q_col and pd.notna(row.get('Photo Q', None)):
photo_q_raw = str(row['Photo Q']).strip()
if has_photo_c_col and pd.notna(row.get('Photo C', None)):
photo_c_raw = str(row['Photo C']).strip()
if choice_text and choice_text.lower() != 'nan' and choice_text != '':
current_choices.append({
'letter': choice_letter,
'text': choice_text,
'is_correct': is_correct
})
if current_question is not None and current_choices and is_valid_cours_number(current_cours):
processed_questions.append({
'numero': current_question,
'question_text': current_question_text,
'source': current_source,
'comment': current_comment,
'cours': int(float(str(current_cours).strip())),
'module': current_module,
'choices': current_choices.copy(),
'choice_commentaire': current_choice_commentaire,
'photo_q': question_photos.get(current_question, {}).get('photo_q', None), # LINKED!
'photo_c': question_photos.get(current_question, {}).get('photo_c', None) # LINKED!
})
elif current_question is not None and not is_valid_cours_number(current_cours):
skipped_s2_questions += 1
# Group questions by module and course
questions_by_module = {}
for q_data in processed_questions:
module_name = q_data['module']
cours_num = q_data['cours']
if module_name not in questions_by_module:
questions_by_module[module_name] = {}
if cours_num not in questions_by_module[module_name]:
questions_by_module[module_name][cours_num] = []
questions_by_module[module_name][cours_num].append(q_data)
# Check for E choices
total_e_choices = 0
for module_name, questions_by_course in questions_by_module.items():
for cours_num, course_questions in questions_by_course.items():
course_e_count = sum(1 for q_data in course_questions
for choice in q_data['choices']
if choice['letter'].upper() == 'E')
if course_e_count > 0:
total_e_choices += course_e_count
# Column balancing
column_break_after_question = 0
if use_two_columns and balance_method == "dynamic":
total_estimated_lines = 0
all_question_lengths = []
for module_name in modules:
if module_name not in questions_by_module:
continue
questions_by_course = questions_by_module[module_name]
cours_titles = modules_data.get(module_name, {})
total_estimated_lines += 5
question_lengths, module_lines = estimate_content_length(questions_by_course, cours_titles)
total_estimated_lines += module_lines
all_question_lengths.extend(question_lengths)
target_lines_first_column = total_estimated_lines * 0.52
cumulative_lines = 0
global_question_counter = 0
for module_name in modules:
if module_name not in questions_by_module:
continue
cumulative_lines += 5
questions_by_course = questions_by_module[module_name]
for cours_num in sorted(questions_by_course.keys()):
cumulative_lines += 3
course_questions = questions_by_course[cours_num]
for q_data in course_questions:
global_question_counter += 1
for q_length in all_question_lengths:
if q_length['question'] == q_data:
cumulative_lines += q_length['estimated_lines']
break
if cumulative_lines >= target_lines_first_column and column_break_after_question == 0:
column_break_after_question = global_question_counter
break
if column_break_after_question > 0:
break
if column_break_after_question > 0:
break
# Format questions grouped by module
overall_question_count = 1
global_question_counter = 0
column_break_added = False
for module_index, module_name in enumerate(modules):
if module_name not in questions_by_module:
continue
if module_index == 0:
section = doc.sections[-1]
else:
section = doc.add_section(WD_SECTION.NEW_PAGE)
if use_two_columns:
sectPr = section._sectPr
cols = sectPr.find(qn('w:cols'))
if cols is None:
cols = OxmlElement('w:cols')
sectPr.append(cols)
cols.set(qn('w:num'), '2')
cols.set(qn('w:space'), '432')
cols.set(qn('w:equalWidth'), '1')
if use_two_columns:
sectPr = section._sectPr
cols = sectPr.find(qn('w:cols'))
if cols is None:
cols = OxmlElement('w:cols')
sectPr.append(cols)
cols.set(qn('w:num'), '2')
cols.set(qn('w:space'), '432')
cols.set(qn('w:equalWidth'), '1')
create_flexible_header(section, module_name, first_sheet_name, display_name, theme_hex=theme_hex)
# ADD THE COLORED SEPARATOR
if add_separator_line:
add_colored_column_separator(section, theme_hex)
# ========== CUSTOMIZE MODULE TITLE APPEARANCE HERE ==========
MODULE_HEIGHT = 31 # Frame height in points
MODULE_ROUNDNESS = 50 # Corner roundness % (0=square, 50=pill)
MODULE_FONT_SIZE = 35 # Font size in half-points (28=14pt, 24=12pt, 32=16pt)
MODULE_BG_COLOR = theme_hex # Purple background color
MODULE_TEXT_COLOR = "FFFFFF" # White text color
MODULE_PADDING = 60 # Extra width padding
# ============================================================
# Add module title as rounded shape
shape_para = doc.add_paragraph()
shape_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
shape_para.paragraph_format.space_before = Pt(12)
shape_para.paragraph_format.space_after = Pt(8)
# Calculate width based on text length
text_length = len(module_name.upper())
estimated_width = (text_length * 12) + MODULE_PADDING
module_name_escaped = html.escape(module_name.upper())
# Create rounded rectangle shape
shape_xml = f'''
<w:r xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
xmlns:v="urn:schemas-microsoft-com:vml">
<w:pict>
<v:roundrect style="width:{estimated_width}pt;height:{MODULE_HEIGHT}pt"
arcsize="{MODULE_ROUNDNESS}%" fillcolor="#{MODULE_BG_COLOR}" stroked="f">
<v:textbox inset="10pt,0pt,10pt,0pt" style="v-text-anchor:middle">
<w:txbxContent>
<w:p>
<w:pPr>
<w:jc w:val="center"/>
<w:spacing w:before="0" w:after="0"/>
</w:pPr>
<w:r>
<w:rPr>
<w:rFonts w:ascii="Montserrat" w:hAnsi="Montserrat"/>
<w:b/>
<w:sz w:val="{MODULE_FONT_SIZE}"/>
<w:color w:val="{MODULE_TEXT_COLOR}"/>
</w:rPr>
<w:t>{module_name_escaped}</w:t>
</w:r>
</w:p>
</w:txbxContent>
</v:textbox>
</v:roundrect>
</w:pict>
</w:r>
'''
shape_element = parse_xml(shape_xml)
shape_para._p.append(shape_element)
# Add bookmark
bm_name = sanitize_bookmark_name(f"MOD_{module_name}")
add_bookmark_to_paragraph(shape_para, bm_name, bookmark_id)
toc_entries.append({'level': 'module', 'text': f"{module_name}", 'bm': bm_name})
bookmark_id += 1
questions_by_course = questions_by_module[module_name]
cours_titles = modules_data.get(module_name, {})
for natural_num, cours_num in enumerate(sorted(questions_by_course.keys()), start=1):
course_questions = questions_by_course[cours_num]
course_question_count = 1
course_title = cours_titles.get(cours_num, f"COURSE {cours_num}")
num_questions = len(course_questions)
course_para = create_course_title(doc, natural_num, course_title, theme_color, theme_hex=theme_hex,
question_count=num_questions)
bm_course_name = sanitize_bookmark_name(f"COURSE_{module_name}_{cours_num}")
add_bookmark_to_paragraph(course_para, bm_course_name, bookmark_id)
toc_entries.append({'level': 'course', 'text': f"{natural_num}. {course_title}", 'bm': bm_course_name})
bookmark_id += 1
for q_data in course_questions:
global_question_counter += 1
if (use_two_columns and balance_method == "dynamic" and
not column_break_added and global_question_counter == column_break_after_question):
add_column_break(doc)
column_break_added = True
choices = [(choice['letter'], choice['text']) for choice in q_data['choices']]
choices.sort(key=lambda x: x[0])
correct_answers = [choice['letter'] for choice in q_data['choices'] if choice['is_correct']]
correct_answers_str = ''.join(sorted(correct_answers))
if choices:
format_question_block(
doc,
course_question_count,
q_data['question_text'],
choices,
correct_answers_str,
q_data['source'],
q_data['comment'],
q_data.get('choice_commentaire', {}), # NEW
q_data.get('photo_q', None), # NEW
q_data.get('photo_c', None), # NEW
theme_color,
theme_hex,
highlight_words,
highlight_comment_words
)
course_question_count += 1
overall_question_count += 1
# META.PY: NO EMPTY TABLES - create_empty_course_table(doc, course_questions, cours_num, 1)
bookmark_id, responses_toc_entry = create_answer_tables(doc, questions_by_course, cours_titles, module_name,
bookmark_id, theme_hex, highlight_words)
toc_entries.append(responses_toc_entry)
# ========================================
# INSERT TOC ENTRIES IN THE FIRST SECTION
# ========================================
# We need to insert TOC entries in the FIRST section, before the section break
# Get the body element
body = doc._element.body
# Find where to insert - right after toc_title, before the section break
toc_title_element = toc_title._element
insert_index = list(body).index(toc_title_element) + 1
# In the TOC generation section, update the formatting code:
# Generate the TOC entries and insert them at the correct position
# Mark last course entries for each module (for spacing)
for i, entry in enumerate(toc_entries):
entry['is_last_course_in_module'] = False
if entry['level'] == 'course':
# Check if next entry is a module or responses (or if this is the last entry)
if i + 1 >= len(toc_entries) or toc_entries[i + 1]['level'] in ['module', 'responses']:
entry['is_last_course_in_module'] = True
for entry in toc_entries:
# Create a new paragraph element
new_p = body.makeelement(qn('w:p'), nsmap=body.nsmap)
# Set paragraph properties
pPr = new_p.makeelement(qn('w:pPr'), nsmap=new_p.nsmap)
# Alignment - LEFT (for two-column layout)
jc = pPr.makeelement(qn('w:jc'), nsmap=pPr.nsmap)
jc.set(qn('w:val'), 'left')
pPr.append(jc)
# Set spacing
spacing = pPr.makeelement(qn('w:spacing'), nsmap=pPr.nsmap)
# Add spacing before module entries to separate module blocks
if entry['level'] == 'module':
spacing.set(qn('w:before'), '180') # 9pt spacing before module entries
else:
spacing.set(qn('w:before'), '0')
spacing.set(qn('w:after'), '0')
pPr.append(spacing)
# Add tab stops with dotted leader
tabs = pPr.makeelement(qn('w:tabs'), nsmap=pPr.nsmap)
tab = tabs.makeelement(qn('w:tab'), nsmap=tabs.nsmap)
tab.set(qn('w:val'), 'right')
tab.set(qn('w:leader'), 'dot') # This adds the dots!
tab.set(qn('w:pos'), '5040') # 3.5 inches in twentieths of a point (adjusted for two-column layout)
tabs.append(tab)
pPr.append(tabs)
# Indent course entries and responses entries
if entry['level'] == 'course':
ind = pPr.makeelement(qn('w:ind'), nsmap=pPr.nsmap)
ind.set(qn('w:left'), '360') # 0.25 inches
pPr.append(ind)
elif entry['level'] == 'responses':
ind = pPr.makeelement(qn('w:ind'), nsmap=pPr.nsmap)
ind.set(qn('w:left'), '360') # 0.25 inches - same as course
pPr.append(ind)
new_p.append(pPr)
# Add text run with font formatting
r = new_p.makeelement(qn('w:r'), nsmap=new_p.nsmap)
# Add run properties (font)
rPr = r.makeelement(qn('w:rPr'), nsmap=r.nsmap)
# Font family
rFonts = rPr.makeelement(qn('w:rFonts'), nsmap=rPr.nsmap)
rFonts.set(qn('w:ascii'), 'Montserrat')
rFonts.set(qn('w:hAnsi'), 'Montserrat')
rPr.append(rFonts)
# Font size and styling based on level
sz = rPr.makeelement(qn('w:sz'), nsmap=rPr.nsmap)
if entry['level'] == 'module':
sz.set(qn('w:val'), '22') # 11pt
# Bold for module
b = rPr.makeelement(qn('w:b'), nsmap=rPr.nsmap)
rPr.append(b)
# Color for module
color = rPr.makeelement(qn('w:color'), nsmap=rPr.nsmap)
color.set(qn('w:val'), theme_hex)
rPr.append(color)
elif entry['level'] == 'responses':
sz.set(qn('w:val'), '20') # 10pt
# Bold and italic for responses
b = rPr.makeelement(qn('w:b'), nsmap=rPr.nsmap)
rPr.append(b)
i = rPr.makeelement(qn('w:i'), nsmap=rPr.nsmap)
rPr.append(i)
# Purple color for responses to match the box
color = rPr.makeelement(qn('w:color'), nsmap=rPr.nsmap)
color.set(qn('w:val'), theme_hex)
rPr.append(color)
else: # course level
sz.set(qn('w:val'), '20') # 10pt
rPr.append(sz)
r.append(rPr)
# Add text
t = r.makeelement(qn('w:t'), nsmap=r.nsmap)
t.set(qn('xml:space'), 'preserve')
t.text = entry['text']
r.append(t)
new_p.append(r)
# Add tab run (this triggers the dotted leader)
r_tab = new_p.makeelement(qn('w:r'), nsmap=new_p.nsmap)
tab_char = r_tab.makeelement(qn('w:tab'), nsmap=r_tab.nsmap)
r_tab.append(tab_char)
new_p.append(r_tab)
# Add PAGEREF field runs with theme color and Montserrat font formatting
r_field_begin = new_p.makeelement(qn('w:r'), nsmap=new_p.nsmap)
# Add formatting to field begin
rPr_field = r_field_begin.makeelement(qn('w:rPr'), nsmap=r_field_begin.nsmap)
# Add Montserrat font
rFonts_field = rPr_field.makeelement(qn('w:rFonts'), nsmap=rPr_field.nsmap)
rFonts_field.set(qn('w:ascii'), 'Montserrat')
rFonts_field.set(qn('w:hAnsi'), 'Montserrat')
rPr_field.append(rFonts_field)
# Add bold
b_field = rPr_field.makeelement(qn('w:b'), nsmap=rPr_field.nsmap)
rPr_field.append(b_field)
color_field = rPr_field.makeelement(qn('w:color'), nsmap=rPr_field.nsmap)
color_field.set(qn('w:val'), theme_hex)
rPr_field.append(color_field)
r_field_begin.append(rPr_field)
fldChar1 = r_field_begin.makeelement(qn('w:fldChar'), nsmap=r_field_begin.nsmap)
fldChar1.set(qn('w:fldCharType'), 'begin')
r_field_begin.append(fldChar1)
new_p.append(r_field_begin)
r_instr = new_p.makeelement(qn('w:r'), nsmap=new_p.nsmap)
# Add formatting to instruction text
rPr_instr = r_instr.makeelement(qn('w:rPr'), nsmap=r_instr.nsmap)
# Add Montserrat font
rFonts_instr = rPr_instr.makeelement(qn('w:rFonts'), nsmap=rPr_instr.nsmap)
rFonts_instr.set(qn('w:ascii'), 'Montserrat')
rFonts_instr.set(qn('w:hAnsi'), 'Montserrat')
rPr_instr.append(rFonts_instr)
# Add bold
b_instr = rPr_instr.makeelement(qn('w:b'), nsmap=rPr_instr.nsmap)
rPr_instr.append(b_instr)
color_instr = rPr_instr.makeelement(qn('w:color'), nsmap=rPr_instr.nsmap)
color_instr.set(qn('w:val'), theme_hex)
rPr_instr.append(color_instr)
r_instr.append(rPr_instr)
instrText = r_instr.makeelement(qn('w:instrText'), nsmap=r_instr.nsmap)
instrText.set(qn('xml:space'), 'preserve')
instrText.text = f"PAGEREF {entry['bm']} \\h"
r_instr.append(instrText)
new_p.append(r_instr)
r_field_end = new_p.makeelement(qn('w:r'), nsmap=new_p.nsmap)
# Add formatting to field end
rPr_end = r_field_end.makeelement(qn('w:rPr'), nsmap=r_field_end.nsmap)
# Add Montserrat font
rFonts_end = rPr_end.makeelement(qn('w:rFonts'), nsmap=rPr_end.nsmap)
rFonts_end.set(qn('w:ascii'), 'Montserrat')
rFonts_end.set(qn('w:hAnsi'), 'Montserrat')
rPr_end.append(rFonts_end)
# Add bold
b_end = rPr_end.makeelement(qn('w:b'), nsmap=rPr_end.nsmap)
rPr_end.append(b_end)
color_end = rPr_end.makeelement(qn('w:color'), nsmap=rPr_end.nsmap)
color_end.set(qn('w:val'), theme_hex)
rPr_end.append(color_end)
r_field_end.append(rPr_end)
fldChar2 = r_field_end.makeelement(qn('w:fldChar'), nsmap=r_field_end.nsmap)
fldChar2.set(qn('w:fldCharType'), 'end')
r_field_end.append(fldChar2)
new_p.append(r_field_end)
# Insert the paragraph at the correct position
body.insert(insert_index, new_p)
insert_index += 1 # Increment for next insertion
# ── Insert Fréquence & Traqueur stats table into right TOC column ──────
# Derive course order: preserve insertion order from questions_by_module
_mcorder = {m: list(questions_by_module[m].keys()) for m in questions_by_module}
insert_index = create_stats_table_in_toc(
doc, body, insert_index,
modules, questions_by_module,
modules_data, _mcorder,
theme_hex, theme_color
)
# Add page numbers
add_page_numbers(doc, theme_hex)
# Call it before generating the document:
verify_photo_associations(question_photos)
# Save document
doc.save(output_word_path)
print(f"\n🎉 SUCCESS: Document saved as: {output_word_path}")
print(f"📊 Total questions processed: {overall_question_count - 1}")
print(f"🚫 Total S2/invalid questions skipped: {skipped_s2_questions}")
if total_e_choices > 0:
print(f"✨ Dynamic E columns added for courses with 5-choice questions")
# Clean up temporary folder if it was created
if is_temp and temp_dir_obj is not None:
print(f"\n🧹 Cleaning up temporary folder...")
try:
temp_dir_obj.cleanup()
print(f" ✓ Temporary files removed")
except Exception as e:
print(f" ⚠️ Could not clean up: {e}")
def debug_excel_structure(excel_file_path):
"""Debug function to analyze Excel structure"""
print("=== DEBUGGING EXCEL STRUCTURE ===")
xls = pd.ExcelFile(excel_file_path)
first_sheet_name = xls.sheet_names[0] # Get the first sheet name
questions_df = pd.read_excel(excel_file_path, sheet_name=first_sheet_name)
print(f"Total rows: {len(questions_df)}")
print(f"Columns: {list(questions_df.columns)}")
# Check unique values in key columns
if 'Numero' in questions_df.columns:
try:
print(f"Unique Numero values: {sorted(questions_df['Numero'].dropna().unique())}")
except Exception as e:
print(f"Unique Numero values: {list(questions_df['Numero'].dropna().unique())} (couldn't sort: {e})")
if 'Order' in questions_df.columns:
try:
unique_orders = sorted(questions_df['Order'].dropna().unique())
print(f"Unique Order values: {unique_orders}")
# Check specifically for E choices
e_count = sum(1 for order in questions_df['Order'].dropna() if str(order).strip().upper() == 'E')
print(f"Total E choices found: {e_count}")
except Exception as e:
print(f"Unique Order values: {list(questions_df['Order'].dropna().unique())} (couldn't sort: {e})")
if 'Cours' in questions_df.columns:
unique_cours = questions_df['Cours'].dropna().unique()
# Convert all to strings first for display, then separate by validity
unique_cours_str = [str(c) for c in unique_cours]
print(f"Unique Cours values: {unique_cours_str}")
# Check which cours values are valid vs invalid
valid_cours = []
invalid_cours = []
for c in unique_cours:
if is_valid_cours_number(c):
valid_cours.append(c)
else:
invalid_cours.append(str(c))
# Sort valid ones (numeric) and invalid ones (as strings) separately
try:
valid_cours_sorted = sorted([float(c) for c in valid_cours])
print(f"Valid cours values: {valid_cours_sorted}")
except Exception:
print(f"Valid cours values: {valid_cours}")
try:
invalid_cours_sorted = sorted(invalid_cours)
print(f"Invalid/S2 cours values: {invalid_cours_sorted}")
except Exception:
print(f"Invalid/S2 cours values: {invalid_cours}")
# Check module column and corresponding sheets
if 'module' in questions_df.columns:
unique_modules = questions_df['module'].dropna().unique()
print(f"\nUnique Module values: {list(unique_modules)}")
# Check if sheets exist for each module
xls = pd.ExcelFile(excel_file_path)
sheet_names = xls.sheet_names
sheet_names_lower = [s.lower() for s in sheet_names]
print("\nModule sheet availability:")
for module in unique_modules:
module_lower = str(module).strip().lower()
if module_lower in sheet_names_lower:
actual_sheet = sheet_names[sheet_names_lower.index(module_lower)]
print(f" ✓ Module '{module}' -> Sheet '{actual_sheet}' found")
# Try to read and show course info from this sheet
try:
module_df = pd.read_excel(excel_file_path, sheet_name=actual_sheet)
if 'cours' in module_df.columns and 'titre' in module_df.columns:
print(f" Courses in this module:")
for _, row in module_df.iterrows():
if pd.notna(row['cours']):
print(f" - {row['cours']}: {row.get('titre', 'N/A')}")
except Exception as e:
print(f" Error reading sheet: {e}")
else:
print(f" ✗ Module '{module}' -> No matching sheet found")
# Check Cours sheet
try:
cours_df = pd.read_excel(excel_file_path, sheet_name='Cours')
print(f"\nCours sheet - Total rows: {len(cours_df)}")
print(f"Cours sheet columns: {list(cours_df.columns)}")
if not cours_df.empty:
print("Course titles:")
for _, row in cours_df.iterrows():
cours_val = row.get('cours', 'N/A')
is_valid = is_valid_cours_number(cours_val)
status = "✓" if is_valid else "✗ (SKIPPED)"
print(f" Course {cours_val}: {row.get('titre', 'N/A')} {status}")
except Exception as e:
print(f"Error reading Cours sheet: {e}")
def test_excel_photo_columns(excel_file_path):
"""Test function to check what's actually in your Excel file"""
print("\n" + "=" * 60)
print("TESTING EXCEL PHOTO AND COMMENT COLUMNS")
print("=" * 60)
xls = pd.ExcelFile(excel_file_path)
first_sheet = xls.sheet_names[0]
df = pd.read_excel(excel_file_path, sheet_name=first_sheet, nrows=10)
print(f"\nColumns in sheet '{first_sheet}':")
for col in df.columns:
print(f" - {col}")
has_embedded_images = False
# Check for Choice commentaire
if 'Choice commentaire' in df.columns:
print("\n✓ Found 'Choice commentaire' column")
print("NOTE: Each row has ONE comment for ONE choice (A, B, C, D, or E)")
for idx, val in enumerate(df['Choice commentaire'].head()):
if pd.notna(val):
order = df['Order'].iloc[idx] if 'Order' in df.columns else '?'
print(f" Row {idx} (Choice {order}): {repr(str(val)[:100])}")
else:
print("\n✗ 'Choice commentaire' column NOT found")
# Check for Photo Q
if 'Photo Q' in df.columns:
print("\n✓ Found 'Photo Q' column")
for idx, val in enumerate(df['Photo Q'].head()):
if pd.notna(val):
val_str = str(val).strip()
if val_str.startswith('=DISPIMG'):
print(f" Row {idx}: EMBEDDED IMAGE (formula: {val_str[:50]}...)")
has_embedded_images = True
else:
exists = os.path.exists(val_str)
print(f" Row {idx}: '{val_str}' - Exists: {exists}")
else:
print("\n✗ 'Photo Q' column NOT found")
# Check for Photo C
if 'Photo C' in df.columns:
print("\n✓ Found 'Photo C' column")
for idx, val in enumerate(df['Photo C'].head()):
if pd.notna(val):
val_str = str(val).strip()
if val_str.startswith('=DISPIMG'):
print(f" Row {idx}: EMBEDDED IMAGE (formula: {val_str[:50]}...)")
has_embedded_images = True
else:
exists = os.path.exists(val_str)
print(f" Row {idx}: '{val_str}' - Exists: {exists}")
else:
print("\n✗ 'Photo C' column NOT found")
print("=" * 60 + "\n")
if has_embedded_images:
extract_embedded_images_info(excel_file_path)
def verify_photo_associations(question_photos):
"""Debug function to verify all photo-question associations"""
print("\n" + "=" * 60)
print("PHOTO-QUESTION ASSOCIATIONS")
print("=" * 60)
for q_num in sorted(question_photos.keys()):
photos = question_photos[q_num]
photo_q = photos.get('photo_q')
photo_c = photos.get('photo_c')
if photo_q or photo_c:
print(f"\nQuestion {q_num}:")
if photo_q:
exists = "✓" if os.path.exists(photo_q) else "✗"
print(f" Photo Q: {exists} {photo_q}")
if photo_c:
exists = "✓" if os.path.exists(photo_c) else "✗"
print(f" Photo C: {exists} {photo_c}")
print("=" * 60 + "\n")