diff --git "a/meta.py" "b/meta.py" --- "a/meta.py" +++ "b/meta.py" @@ -1,5 +1,14 @@ -import os +""" +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 @@ -11,11 +20,317 @@ 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" # color Hex version for XML elements +THEME_COLOR_HEX = "5FFFDF" # Hex version for XML elements THEME_COLOR = RGBColor.from_string(THEME_COLOR_HEX) +# 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 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""" @@ -35,19 +350,6 @@ def set_page_size(section, width_inches, height_inches): pgSz.set(qn('w:h'), str(height_twips)) -# 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 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 @@ -133,7 +435,7 @@ def add_page_break(doc): doc.add_page_break() -def create_course_title(doc, course_number, course_title, theme_color=None, theme_hex=None): +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: @@ -152,7 +454,8 @@ def create_course_title(doc, course_number, course_title, theme_color=None, them course_para.paragraph_format.keep_together = True # Format the text - full_text = f"{course_number}. {course_title}" + 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 ========== @@ -170,73 +473,118 @@ def create_course_title(doc, course_number, course_title, theme_color=None, them # 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'' + if needs_two_lines: - # Split text intelligently at a good breaking point + # Split text intelligently words = course_title.split() mid_point = len(words) // 2 # Try to split at middle, but prefer breaking after shorter first line - first_line = f"{course_number}. " + " ".join(words[:mid_point]) - second_line = " ".join(words[mid_point:]) + # (We calculate lengths including the number to match your width logic) + prefix_len = len(f"{course_number}. ") - # Adjust if first line is too long - while len(first_line) > MAX_CHARS_SINGLE_LINE and mid_point > 1: + 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_line = f"{course_number}. " + " ".join(words[:mid_point]) - second_line = " ".join(words[mid_point:]) - - text_escaped_line1 = html.escape(first_line) - text_escaped_line2 = html.escape(second_line) - - # Use max of both lines for width calculation - max_line_length = max(len(first_line), len(second_line)) - estimated_width = min((max_line_length * 8), MAX_WIDTH_PT) + 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 - print(f'Text: {course_title}, charachters: {text_length}') - print(f'split: {first_line}, {len(first_line)}, {second_line}, {len(second_line)}') - - # Two-line XML + # Two-line XML with 5 separate runs to handle fonts and line break text_content = f''' - - - - - - - - {text_escaped_line1} - - - - - - - - - - - - {text_escaped_line2} - ''' + + + + + {xml_size_color} + + {esc_num} + + + + + + {xml_size_color} + + {esc_title_1} + + + + + + + + + {xml_size_color} + + {esc_title_2} + + + + + + {xml_size_color} + + {esc_circle} + ''' + else: # Single line estimated_width = min((text_length * 9) + 20, MAX_WIDTH_PT) frame_height = SINGLE_LINE_HEIGHT - text_escaped = html.escape(full_text) - print(f'Text: {text_escaped}, charachters: {text_length}') + # 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''' - - - - - - - - {text_escaped} - ''' + + + + + {xml_size_color} + + {esc_num} + + + + + + {xml_size_color} + + {esc_title} + + + + + + {xml_size_color} + + {esc_circle} + ''' # Create rounded rectangle shape (UNFILLED with stroke) shape_xml = f''' @@ -269,10 +617,65 @@ def create_course_title(doc, course_number, course_title, theme_color=None, them return course_para -def format_question_block(doc, question_num, question_text, choices, correct_answers, source, comment=None, theme_color=None): - """Format a single question block with reduced spacing and keep together formatting""" +def highlight_words_in_text(paragraph, text, highlight_words, theme_color, font_name='Inter Display Medium', + font_size=10.5, bold=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 + + # Create pattern for matching (escape each string to treat as literal text) + import re + # Escape each word/phrase to match it literally, then join with OR + 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): 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 = [] + + """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) @@ -285,9 +688,10 @@ def format_question_block(doc, question_num, question_text, choices, correct_ans # 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(0) - question_para.paragraph_format.keep_with_next = True # Keep question with choices - question_para.paragraph_format.keep_together = True # Prevent splitting within paragraph + 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 @@ -297,61 +701,104 @@ def format_question_block(doc, question_num, question_text, choices, correct_ans num_run.font.bold = True num_run.font.color.rgb = theme_color - # Question text in SF UI Display Med - text_run = question_para.add_run(question_text) - text_run.font.name = 'Inter ExtraBold' - text_run.font.size = Pt(10.5) + # 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(choices): + 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(1) - choice_para.paragraph_format.space_after = Pt(1) - choice_para.paragraph_format.keep_together = True # Prevent splitting within paragraph + 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 source line + # 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_run = choice_para.add_run(f"{choice_letter}- {choice_text}") - choice_run.font.name = 'Inter Display Medium' - choice_run.font.size = Pt(10.5) - - choice_paragraphs.append(choice_para) + # 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 # Prevent splitting within paragraph + source_para.paragraph_format.keep_together = True - # If there's a comment, keep source with comment + # 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 SemiBold' - source_run.font.size = Pt(8.5) + 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 Display Medium' - source_value_run.font.size = Pt(8.5) + 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) @@ -359,68 +806,23 @@ def format_question_block(doc, question_num, question_text, choices, correct_ans empty_run = empty_para.add_run(' ') empty_run.font.size = Pt(7) - # Add comment if exists - if comment and str(comment).strip() and str(comment).lower() != 'nan': - comment_para = doc.add_paragraph() - comment_para.paragraph_format.left_indent = Inches(0.2) - comment_para.paragraph_format.space_before = Pt(1) - comment_para.paragraph_format.space_after = Pt(2) - comment_para.paragraph_format.keep_together = True # Prevent splitting within paragraph - # Comment is the last element, so no keep_with_next needed - - comment_run = comment_para.add_run(f"Commentaire : {comment}") - comment_run.font.name = 'Inter Display' - comment_run.font.size = Pt(6) - comment_run.font.italic = True - 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 - for section_idx, section in enumerate(doc.sections): - # ===== HEADER (keep existing text like module name) ===== - header = section.header - header.is_linked_to_previous = False - section.header_distance = Cm(0.3) - - # If header is empty, add a blank paragraph - if not header.paragraphs: - header.add_paragraph() - - # ===== FOOTER (page numbers + TOC link) ===== - footer = section.footer - footer.is_linked_to_previous = False - section.footer_distance = Cm(0.5) # Distance from bottom of page to footer - - # Clear existing text in footer - if footer.paragraphs: - footer.paragraphs[0].clear() - else: - footer.add_paragraph() - - # Skip page numbers for the first section (TOC) - if section_idx == 0: - continue - - # For the second section (first content page), restart numbering at 1 - 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') # Start at page 1 - + def create_footer_content(footer_elem, theme_hex): + """Helper function to create footer content with page number and TOC link""" # Add an empty line above the page number - empty_para = footer.paragraphs[0] + 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 # Add the page number paragraph - paragraph = footer.add_paragraph() + paragraph = footer_elem.add_paragraph() paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER # Set vertical alignment to center @@ -448,10 +850,10 @@ def add_page_numbers(doc, theme_hex=None): run.font.name = 'Montserrat' run.font.size = Pt(14) run.font.bold = True - run.font.color.rgb = RGBColor(0, 0, 0) + run.font.color.rgb = RGBColor.from_string(theme_hex) # ===== ADD TOC LINK IN TEXT BOX (BOTTOM RIGHT) ===== - # Create TOC link text box similar to header style + # Create TOC link text box - absolutely positioned, does not affect page number centering toc_textbox_xml = f''' - + - ↗️ @@ -479,7 +880,7 @@ def add_page_numbers(doc, theme_hex=None): - + @@ -497,51 +898,203 @@ def add_page_numbers(doc, theme_hex=None): toc_textbox_element = parse_xml(toc_textbox_xml) paragraph._p.append(toc_textbox_element) - -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: + for section_idx, section in enumerate(doc.sections): + # ===== HEADER (keep existing text like module name) ===== header = section.header header.is_linked_to_previous = False + section.header_distance = Cm(0.3) + # If header is empty, add a blank paragraph 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) + # ===== FOOTER FOR ODD/DEFAULT PAGES (page numbers + TOC link) ===== + footer = section.footer + footer.is_linked_to_previous = False + section.footer_distance = Cm(0.4) # Distance from bottom of page to footer -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) + # Clear existing text in footer + if footer.paragraphs: + footer.paragraphs[0].clear() + else: + footer.add_paragraph() + # Skip page numbers for the first section (TOC) + if section_idx == 0: + continue -def is_valid_cours_number(cours_value): - """Check if cours value is valid (numeric and not 'S2')""" - if pd.isna(cours_value): - return False + # For the second section (first content page), restart numbering at 1 + 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') # Start at page 1 - cours_str = str(cours_value).strip().upper() + # Create footer content for odd/default pages + create_footer_content(footer, theme_hex) + + # ===== CREATE EVEN PAGE FOOTER ===== + try: + # Check if even_page_footer property exists + if hasattr(section, 'even_page_footer'): + footer_even = section.even_page_footer + footer_even.is_linked_to_previous = False + if not footer_even.paragraphs: + footer_even.add_paragraph() + else: + footer_even.paragraphs[0].clear() + create_footer_content(footer_even, theme_hex) + print("✓ Created even page footer using built-in property") + else: + # Manual method - create even footer via XML + from docx.opc.packuri import PackURI + from docx.opc.part import XmlPart + + # Build even footer XML with same structure as odd footer + even_ftr_xml = f''' + + + + + + + + + + + + + PAGE + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ↗️ + + + + + + + + + + SOM + + + + + + + + + +''' + + # Create part + partname = PackURI(f'/word/footer_even_{id(section)}.xml') + element = parse_xml(even_ftr_xml) + content_type = 'application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml' + package = section.part.package + even_part = XmlPart(partname, content_type, element, package) + + # Create relationship + rId = section.part.relate_to(even_part, + 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer') + + # Add footer reference + sectPr = section._sectPr + # Remove any existing even footer references + for ref in list(sectPr.findall(qn('w:footerReference'))): + if ref.get(qn('w:type')) == 'even': + sectPr.remove(ref) + + ftr_ref = OxmlElement('w:footerReference') + ftr_ref.set(qn('w:type'), 'even') + ftr_ref.set(qn('r:id'), rId) + sectPr.append(ftr_ref) + + print("✓ Created even page footer via manual part creation") + + except Exception as e: + print(f"Warning: Could not create even page footer: {e}") + import traceback + traceback.print_exc() + + +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', '']: @@ -565,25 +1118,133 @@ def check_if_course_has_e_choices(course_questions): return False -def create_answer_tables(doc, questions_by_course, cours_titles, module_name, bookmark_id, theme_color=None, theme_hex=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 +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""" - Args: - doc: Document object - questions_by_course: Dictionary of questions organized by course - cours_titles: Dictionary of course titles - module_name: Name of the current module (for unique bookmarks) - bookmark_id: Current bookmark ID counter - - Returns: - tuple: (updated bookmark_id, toc_entry dict) - """ 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''' + + + + + + + + + + + + + + + + + + {comment_text} + + + + + + + + ''' + + 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) @@ -596,7 +1257,7 @@ def create_answer_tables(doc, questions_by_course, cours_titles, module_name, bo # Calculate width based on text length response_text = "RÉPONSES" text_length = len(response_text) - estimated_width = (text_length * 12) + 60 # Same padding as module + estimated_width = (text_length * 12) + 60 # Create rounded rectangle shape for RÉPONSES shape_xml = f''' @@ -640,7 +1301,7 @@ def create_answer_tables(doc, questions_by_course, cours_titles, module_name, bo toc_entry = {'level': 'responses', 'text': f"RÉPONSES - {module_name}", 'bm': bm_responses_name} bookmark_id += 1 - # Process each course (only valid numeric courses) + # Process each course overall_question_number = 1 for cours_num in sorted(questions_by_course.keys()): @@ -662,101 +1323,383 @@ def create_answer_tables(doc, questions_by_course, cours_titles, module_name, bo widowControl.set(qn('w:val'), '1') pPr.append(widowControl) - 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 + 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''' + + + + + + + + + ''') + 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''' + + + + ''') + 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 - num_questions = len(course_questions) - if num_questions == 0: - continue + # 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) - # 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'] +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""" - # Split questions in half - mid_point = (num_questions + 1) // 2 - first_half = course_questions[:mid_point] - second_half = course_questions[mid_point:] + num_questions = len(course_questions) + if num_questions == 0: + return overall_start_num - print(f"DEBUG: Course {cours_num} - Total questions: {num_questions}, Split: {len(first_half)} + {len(second_half)}") + # Check if this course has E choices + has_e_choices = check_if_course_has_e_choices(course_questions) - # Create container table - container_table = doc.add_table(rows=1, cols=2) - container_table.alignment = WD_TABLE_ALIGNMENT.CENTER - container_table.allow_autofit = False + # 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'] - # 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) + # 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:] - cantSplit = OxmlElement('w:cantSplit') - tblPr.append(cantSplit) + print( + f"DEBUG: Empty table for Course {course_num} - Total questions: {num_questions}, Split: {len(first_half)} + {len(second_half)}") - tblPr_keepNext = parse_xml(f'') + # 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 - 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 table properties to prevent splitting + tblPr = container_table._tbl.tblPr + if tblPr is None: + tblPr = OxmlElement('w:tblPr') + container_table._tbl.insert(0, tblPr) - # Set container borders to none - tblBorders = parse_xml(f''' - - - - - - - - - ''') - tblPr.append(tblBorders) + # Add cantSplit property to prevent table from breaking across pages + cantSplit = OxmlElement('w:cantSplit') + tblPr.append(cantSplit) - # 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) + # 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 - 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) + # Set container borders to none + tblBorders = parse_xml(f''' + + + + + + + + + ''') + tblPr.append(tblBorders) - # 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 + # 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) - overall_question_number += num_questions + # 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) - # Return both bookmark_id and toc_entry - return bookmark_id, toc_entry + # 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_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""" + +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 the exact measurements from the document - q_col_width = Inches(0.75) # Fixed width for Q column to fit all numbers + # 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 @@ -773,6 +1716,7 @@ def create_half_answer_table(cell, questions, num_cols, headers, choice_letters, cantSplit = OxmlElement('w:cantSplit') tblPr.append(cantSplit) + # Mark first row as header row tbl = table._tbl tblRows = tbl.xpath(".//w:tr") if tblRows: @@ -781,7 +1725,7 @@ def create_half_answer_table(cell, questions, num_cols, headers, choice_letters, tblHeader = OxmlElement('w:tblHeader') trPr.append(tblHeader) - # CRITICAL: Make header row not splittable + # Make header row not splittable cantSplit_row = OxmlElement('w:cantSplit') trPr.append(cantSplit_row) @@ -811,8 +1755,8 @@ def create_half_answer_table(cell, questions, num_cols, headers, choice_letters, 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) + # 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): @@ -848,7 +1792,7 @@ def create_half_answer_table(cell, questions, num_cols, headers, choice_letters, shading_elm.set(qn('w:fill'), 'D9D9D9') header_cells[i]._tc.get_or_add_tcPr().append(shading_elm) - # Fill data rows + # 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) @@ -862,26 +1806,22 @@ def create_half_answer_table(cell, questions, num_cols, headers, choice_letters, run = paragraph.add_run(f"Q{q_num}") run.font.name = 'Inter ExtraBold' - run.font.size = Pt(8) + 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']] + # Get available choices for this specific question available_choices = [choice['letter'].upper() for choice in q_data['choices']] - has_no_answers = len(correct_answers) == 0 - # Fill choice columns + # 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 = '' - elif has_no_answers: - row_cells[i].text = '▨' - elif letter in correct_answers: - row_cells[i].text = '☒' else: + # Choice exists - show empty checkbox row_cells[i].text = '☐' paragraph = row_cells[i].paragraphs[0] @@ -939,18 +1879,19 @@ def add_pagenumber_field_in_paragraph(paragraph, bookmark_name, right_inch=Inche 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') + 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') + 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 = [] @@ -1037,22 +1978,44 @@ def read_course_titles_from_module_sheet(excel_file_path, module_name): return cours_titles -def create_flexible_header(section, module_name, sheet_name, display_name=None, left_margin_inches=0, right_margin_inches=0, theme_hex=None): - """Create flexible header text boxes that adapt to content size""" +def enable_odd_even_headers(doc): + """Enable different odd and even page headers/footers for the entire document""" + try: + # Access the document settings + settings = doc.settings + settings_element = settings.element + + # Add evenAndOddHeaders element if it doesn't exist + even_odd = settings_element.find(qn('w:evenAndOddHeaders')) + if even_odd is None: + even_odd = OxmlElement('w:evenAndOddHeaders') + # Insert at the beginning of settings + settings_element.insert(0, even_odd) + print("✓ Enabled odd/even page headers in document settings") + else: + print("✓ Odd/even page headers already enabled") + except Exception as e: + print(f"Warning: Could not enable odd/even headers: {e}") + # Try alternative method - modify the XML directly + try: + doc_element = doc.element + body = doc_element.body + # Find or create sectPr + sectPr = body.sectPr + if sectPr is not None: + print("✓ Document structure ready for odd/even headers") + except Exception as e2: + print(f"Warning: Alternative method also failed: {e2}") + + +def create_flexible_header(section, module_name, sheet_name, display_name=None, left_margin_inches=0, + right_margin_inches=0, theme_hex=None): + """Create flexible header text boxes that switch positions on odd/even pages""" if theme_hex is None: theme_hex = THEME_COLOR_HEX - header = section.header - header.is_linked_to_previous = False section.header_distance = Cm(0.6) - if not header.paragraphs: - header.add_paragraph() - - # Clear the first paragraph - header_para = header.paragraphs[0] - header_para.clear() - module_name_str = str(module_name).upper() # Use display_name if provided, otherwise use sheet_name @@ -1065,17 +2028,18 @@ def create_flexible_header(section, module_name, sheet_name, display_name=None, sheet_name_str = html.escape(sheet_name_str) # Calculate approximate widths based on text length - # Rough estimate: ~7pt per character for Montserrat Bold size 10 - module_width = max(len(module_name_str) * 10 + 60, 100) # Minimum 60pt - sheet_width = max(len(sheet_name_str) * 10 + 60, 100) # Minimum 60pt + module_width = max(len(module_name_str) * 10 + 60, 100) + sheet_width = max(len(sheet_name_str) * 10 + 60, 100) - # LEFT text box (module name) - flexible width - left_textbox_xml = f''' - - + @@ -1090,23 +2054,20 @@ def create_flexible_header(section, module_name, sheet_name, display_name=None, - {module_name_str} + {left_text} - - ''' + ''' - # RIGHT text box (sheet name) - flexible width - right_textbox_xml = f''' - - + @@ -1121,6 +2082,79 @@ def create_flexible_header(section, module_name, sheet_name, display_name=None, + {right_text} + + + + + + + ''' + + paragraph._p.append(parse_xml(left_xml)) + paragraph._p.append(parse_xml(right_xml)) + + # ========== CREATE DEFAULT/ODD PAGES HEADER (Sheet Left, Module Right) ========== + header_odd = section.header + header_odd.is_linked_to_previous = False + if not header_odd.paragraphs: + header_odd.add_paragraph() + + create_header_content(header_odd.paragraphs[0], sheet_name_str, sheet_width, module_name_str, module_width) + + # ========== CREATE EVEN PAGES HEADER (Module Left, Sheet Right) ========== + try: + # Check if even_page_header property exists + if hasattr(section, 'even_page_header'): + header_even = section.even_page_header + header_even.is_linked_to_previous = False + if not header_even.paragraphs: + header_even.add_paragraph() + create_header_content(header_even.paragraphs[0], module_name_str, module_width, sheet_name_str, sheet_width) + print("✓ Created even page header using built-in property") + else: + # Manual method + from docx.opc.packuri import PackURI + from docx.opc.part import XmlPart + + # Build even header XML + even_hdr_xml = f''' + + + + + + + + + + + + + + + {module_name_str} + + + + + + + + + + + + + + + + + + + {sheet_name_str} @@ -1129,15 +2163,37 @@ def create_flexible_header(section, module_name, sheet_name, display_name=None, - ''' + +''' + + # Create part + partname = PackURI(f'/word/header_even_{id(section)}.xml') + element = parse_xml(even_hdr_xml) + content_type = 'application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml' + package = section.part.package + even_part = XmlPart(partname, content_type, element, package) + + # Create relationship + rId = section.part.relate_to(even_part, + 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/header') + + # Add header reference + sectPr = section._sectPr + for ref in list(sectPr.findall(qn('w:headerReference'))): + if ref.get(qn('w:type')) == 'even': + sectPr.remove(ref) + + hdr_ref = OxmlElement('w:headerReference') + hdr_ref.set(qn('w:type'), 'even') + hdr_ref.set(qn('r:id'), rId) + sectPr.append(hdr_ref) - # Parse both XML elements - left_textbox_element = parse_xml(left_textbox_xml) - right_textbox_element = parse_xml(right_textbox_xml) + print("✓ Created even page header via manual part creation") - # Append BOTH text boxes to the SAME paragraph - header_para._p.append(left_textbox_element) - header_para._p.append(right_textbox_element) + except Exception as e: + print(f"Warning: Could not create even header: {e}") + import traceback + traceback.print_exc() def extract_display_name_from_excel(excel_file_path): @@ -1192,51 +2248,385 @@ def extract_display_name_from_excel(excel_file_path): def add_colored_column_separator(section, theme_hex=None): - """Add a custom colored vertical line between columns""" + """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''' + + + + + + + + ''' + + 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") - # Find or create the first paragraph in header - if not header.paragraphs: - header.add_paragraph() + # Add line to even header + try: + # Check if even_page_header property exists + if hasattr(section, 'even_page_header'): + header_even = section.even_page_header + add_line_to_header(header_even, "columnSeparatorEven") + print("✓ Added column separator to even page header using built-in property") + else: + # Manual method - we need to add the line to the already-created even header + # Find the even header part + sectPr = section._sectPr + even_header_refs = [ref for ref in sectPr.findall(qn('w:headerReference')) + if ref.get(qn('w:type')) == 'even'] + + if even_header_refs: + # Get the relationship ID + rId = even_header_refs[0].get(qn('r:id')) + # Get the header part + even_header_part = section.part.related_parts[rId] + + # Find the first paragraph in the even header + even_header_element = even_header_part.element + paras = even_header_element.findall(qn('w:p')) + + if paras: + # Add the line to the first paragraph + line_xml_content = f''' + + + + + + ''' + line_element = parse_xml(line_xml_content) + paras[0].append(line_element) + print("✓ Added column separator to even page header via manual part access") + else: + print("⚠ No even header reference found - skipping even page separator line") + except Exception as e: + print(f"Warning: Could not add separator line to even page header: {e}") + import traceback + traceback.print_exc() - header_para = header.paragraphs[0] - # Create a vertical line using VML shape - # The line starts AFTER the header and goes to the bottom - # Adjust the "from" value to start below header (e.g., 0.8in from top) - line_xml = f''' - - - - - - - - ''' +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): + """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 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) - line_element = parse_xml(line_xml) - header_para._p.append(line_element) + # 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''' + + + + + + + + + ''') + 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 + text_run = comment_para.add_run(comment_text) + text_run.font.name = 'Inter Display SemiBold' + text_run.font.size = Pt(8) + + # highlight_words_in_text(comment_para, comment_text, highlight_words, theme_color, font_name='Inter Display SemiBold', font_size=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 process_excel_to_word(excel_file_path, output_word_path, display_name=None, use_two_columns=True, add_separator_line=True, balance_method="dynamic", theme_hex=None): +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): """Main function to process Excel and create a Word document with TOC on the first page""" + if highlight_words is None: + highlight_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 @@ -1301,8 +2691,20 @@ def process_excel_to_word(excel_file_path, output_word_path, display_name=None, # 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) core_props = doc.core_properties core_props.author = "Natural Killer" @@ -1330,11 +2732,11 @@ def process_excel_to_word(excel_file_path, output_word_path, display_name=None, for section in doc.sections: section.top_margin = Inches(0.5) section.bottom_margin = Inches(0.5) - section.left_margin = Cm(1.27) - section.right_margin = Cm(1.27) + section.left_margin = Cm(1.1) + section.right_margin = Cm(1.1) # ======================================== - # CREATE TOC SECTION FIRST (SINGLE COLUMN) + # CREATE TOC SECTION FIRST (TWO COLUMNS - SPLIT PAGE) # ======================================== toc_section = doc.sections[0] sectPr = toc_section._sectPr @@ -1342,7 +2744,8 @@ def process_excel_to_word(excel_file_path, output_word_path, display_name=None, if cols is None: cols = OxmlElement('w:cols') sectPr.append(cols) - cols.set(qn('w:num'), '1') + 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() @@ -1383,7 +2786,10 @@ def process_excel_to_word(excel_file_path, output_word_path, display_name=None, 'comment': current_comment, 'cours': int(float(str(current_cours).strip())), 'module': current_module, - 'choices': current_choices.copy() + '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 @@ -1396,13 +2802,41 @@ def process_excel_to_word(excel_file_path, output_word_path, display_name=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, @@ -1418,7 +2852,10 @@ def process_excel_to_word(excel_file_path, output_word_path, display_name=None, 'comment': current_comment, 'cours': int(float(str(current_cours).strip())), 'module': current_module, - 'choices': current_choices.copy() + '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 @@ -1532,7 +2969,6 @@ def process_excel_to_word(excel_file_path, output_word_path, display_name=None, cols.set(qn('w:space'), '432') cols.set(qn('w:equalWidth'), '1') - # Use the new flexible header function create_flexible_header(section, module_name, first_sheet_name, display_name, theme_hex=theme_hex) # ADD THE COLORED SEPARATOR @@ -1543,7 +2979,7 @@ def process_excel_to_word(excel_file_path, output_word_path, display_name=None, 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 + MODULE_BG_COLOR = theme_hex # Purple background color MODULE_TEXT_COLOR = "FFFFFF" # White text color MODULE_PADDING = 60 # Extra width padding # ============================================================ @@ -1596,7 +3032,7 @@ def process_excel_to_word(excel_file_path, output_word_path, display_name=None, # 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: {module_name}", 'bm': bm_name}) + toc_entries.append({'level': 'module', 'text': f"{module_name}", 'bm': bm_name}) bookmark_id += 1 questions_by_course = questions_by_module[module_name] @@ -1607,7 +3043,9 @@ def process_excel_to_word(excel_file_path, output_word_path, display_name=None, course_question_count = 1 course_title = cours_titles.get(cours_num, f"COURSE {cours_num}") - course_para = create_course_title(doc, natural_num, course_title, theme_color, theme_hex=theme_hex) + 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) @@ -1637,13 +3075,21 @@ def process_excel_to_word(excel_file_path, output_word_path, display_name=None, correct_answers_str, q_data['source'], q_data['comment'], - theme_color + 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 ) course_question_count += 1 overall_question_count += 1 - bookmark_id, responses_toc_entry = create_answer_tables(doc, questions_by_course, cours_titles, module_name, bookmark_id, theme_color, theme_hex) + # 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) # ======================================== @@ -1660,6 +3106,14 @@ def process_excel_to_word(excel_file_path, output_word_path, display_name=None, # 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) @@ -1667,14 +3121,18 @@ def process_excel_to_word(excel_file_path, output_word_path, display_name=None, # Set paragraph properties pPr = new_p.makeelement(qn('w:pPr'), nsmap=new_p.nsmap) - # Alignment - CENTER + # Alignment - LEFT (for two-column layout) jc = pPr.makeelement(qn('w:jc'), nsmap=pPr.nsmap) - jc.set(qn('w:val'), 'center') + jc.set(qn('w:val'), 'left') pPr.append(jc) # Set spacing spacing = pPr.makeelement(qn('w:spacing'), nsmap=pPr.nsmap) - spacing.set(qn('w:before'), '0') + # 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) @@ -1683,7 +3141,7 @@ def process_excel_to_word(excel_file_path, output_word_path, display_name=None, 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'), '9360') # 6.5 inches in twentieths of a point + 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) @@ -1752,14 +3210,42 @@ def process_excel_to_word(excel_file_path, output_word_path, display_name=None, r_tab.append(tab_char) new_p.append(r_tab) - # Add PAGEREF field runs + # 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" @@ -1767,6 +3253,20 @@ def process_excel_to_word(excel_file_path, output_word_path, display_name=None, 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) @@ -1779,6 +3279,9 @@ def process_excel_to_word(excel_file_path, output_word_path, display_name=None, # 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}") @@ -1787,6 +3290,15 @@ def process_excel_to_word(excel_file_path, output_word_path, display_name=None, 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""" @@ -1889,4 +3401,90 @@ def debug_excel_structure(excel_file_path): 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}") \ No newline at end of file + 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") \ No newline at end of file