Spaces:
Sleeping
Sleeping
| """ | |
| 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") |