#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Convertisseur QCM HTML vers LaTeX pour auto-multiple-choice Gère les extraits de code Python avec minted """ import re import os from bs4 import BeautifulSoup from pathlib import Path import html class QCMConverter: def __init__(self, output_dir="codes", debug=False, layout_threshold=50, force_layout=None, use_multicol=False, multicol_columns=2, use_csv=False, element_group="general"): self.output_dir = Path(output_dir) self.output_dir.mkdir(exist_ok=True) self.code_counter = 0 self.debug = debug self.layout_threshold = layout_threshold self.force_layout = force_layout # 'horizontal', 'vertical', ou None self.use_multicol = use_multicol self.multicol_columns = multicol_columns self.use_csv = use_csv self.element_group = element_group def escape_latex_chars(self, text): """Échappe les caractères spéciaux LaTeX""" # Ordre important : & avant les autres car on utilise \& replacements = [ ('\\', '\\textbackslash{}'), # \ doit être en premier ('&', '\\&'), ('%', '\\%'), ('$', '\\$'), ('#', '\\#'), ('^', '\\textasciicircum{}'), ('_', '\\_'), ('{', '\\{'), ('}', '\\}'), ('~', '\\textasciitilde{}'), ] for char, replacement in replacements: text = text.replace(char, replacement) return text def clean_html_tags(self, text): """Supprime les balises HTML et décode les entités""" # Supprimer les balises HTML text = re.sub(r'<[^>]+>', '', text) # Décoder les entités HTML text = html.unescape(text) # Nettoyer les espaces multiples et les sauts de ligne text = re.sub(r'\s+', ' ', text).strip() # Échapper les caractères spéciaux LaTeX text = self.escape_latex_chars(text) return text def format_code_with_line_breaks(self, code, max_line_length=60): """Formate le code en ajoutant des sauts de ligne si les lignes sont trop longues""" lines = code.split('\n') formatted_lines = [] for line in lines: # Si la ligne est plus courte que la limite, la garder telle quelle if len(line) <= max_line_length: formatted_lines.append(line) else: # Essayer de couper intelligemment current_line = line while len(current_line) > max_line_length: # Points de coupure préférés en Python break_points = [', ', ' = ', ' + ', ' - ', ' * ', ' / ', ' and ', ' or ', ' if ', ' for ', ' in '] best_break = -1 for bp in break_points: # Chercher le dernier point de coupure avant la limite pos = current_line[:max_line_length].rfind(bp) if pos > best_break and pos > max_line_length // 2: # Au moins à mi-chemin best_break = pos + len(bp) if best_break > 0: # Couper au meilleur point trouvé formatted_lines.append(current_line[:best_break].rstrip()) current_line = ' ' + current_line[best_break:].lstrip() # Indenter la suite else: # Pas de bon point de coupure, couper brutalement formatted_lines.append(current_line[:max_line_length]) current_line = ' ' + current_line[max_line_length:] # Indenter la suite # Ajouter le reste de la ligne if current_line.strip(): formatted_lines.append(current_line) return '\n'.join(formatted_lines) def extract_code_from_pre(self, pre_element): """Extrait le code Python d'un élément
"""
        code_element = pre_element.find('code')
        if not code_element:
            return ""
        
        # Extraire le texte en préservant la structure
        code_lines = []
        for line in code_element.get_text().split('\n'):
            # Supprimer les espaces en début de ligne de façon cohérente
            line = line.rstrip()
            code_lines.append(line)
        
        # Supprimer les lignes vides au début et à la fin
        while code_lines and not code_lines[0].strip():
            code_lines.pop(0)
        while code_lines and not code_lines[-1].strip():
            code_lines.pop()
        
        raw_code = '\n'.join(code_lines)
        
        # Formater le code avec des sauts de ligne
        formatted_code = self.format_code_with_line_breaks(raw_code)
        
        return formatted_code
    
    def save_code_to_file(self, code, filename):
        """Sauvegarde le code Python dans un fichier"""
        filepath = self.output_dir / filename
        with open(filepath, 'w', encoding='utf-8') as f:
            f.write(code)
        return filepath
    
    def process_code_blocks(self, element):
        """Traite les blocs de code et retourne le texte avec les références minted"""
        result_parts = []
        
        for child in element.children:
            if child.name == 'pre':
                # C'est un bloc de code
                code = self.extract_code_from_pre(child)
                if code.strip():
                    self.code_counter += 1
                    filename = f"code_{self.code_counter:03d}.py"
                    self.save_code_to_file(code, filename)
                    result_parts.append(f"\\inputminted[xleftmargin=20pt]{{python}}{{{self.output_dir}/{filename}}}")
            elif child.name:
                # Autre balise HTML
                result_parts.append(self.clean_html_tags(str(child)))
            else:
                # Texte simple
                result_parts.append(str(child).strip())
        
        return '\n\n'.join(part for part in result_parts if part.strip())
    
    def extract_inline_code(self, text):
        """Extrait et formate le code Python inline"""
        # D'abord échapper les caractères LaTeX normaux
        text = self.escape_latex_chars(text)
        
        # Ensuite traiter le code inline (après échappement pour éviter les conflits)
        # Recherche de code Python simple (variables, méthodes, etc.)
        patterns = [
            # Méthodes avec parenthèses
            (r'([a-zA-Z_][a-zA-Z0-9_]*\.[a-zA-Z_][a-zA-Z0-9_]*\([^)]*\))', r'\\texttt{\1}'),
            # Indexation avec crochets
            (r'([a-zA-Z_][a-zA-Z0-9_]*\[[^\]]+\])', r'\\texttt{\1}'),
            # Listes littérales
            (r'(\[[^\]]*\])', r'\\texttt{\1}'),
        ]
        
        for pattern, replacement in patterns:
            text = re.sub(pattern, replacement, text)
        
        return text
    
    def parse_question(self, question_html):
        """Parse une question HTML et retourne les données structurées"""
        soup = BeautifulSoup(question_html, 'html.parser')
        
        # Extraire le numéro de question
        question_match = re.search(r'Q(\d+)', question_html)
        if not question_match:
            return None
        
        question_num = question_match.group(1)
        
        # Vérifier s'il y a plusieurs questions dans ce HTML
        all_question_matches = list(re.finditer(r'Q(\d+)\s*-', question_html))
        if len(all_question_matches) > 1:
            # Il y a plusieurs questions, extraire seulement la première
            first_q_start = all_question_matches[0].start()
            second_q_start = all_question_matches[1].start()
            
            # Chercher le début du tag 

qui contient la première question p_start = question_html.rfind('

', 0, first_q_start + 50) if p_start != -1: first_q_start = p_start question_html = question_html[first_q_start:second_q_start] soup = BeautifulSoup(question_html, 'html.parser') if self.debug: print(f"DEBUG Q{question_num} - Question tronquée (multiple détectée) de {first_q_start} à {second_q_start}") print(f"DEBUG Q{question_num} - HTML tronqué: {question_html[:150]}...") # Séparer le HTML en section question et section réponses responses_section_start = question_html.find("Réponses :") if responses_section_start != -1: question_html_part = question_html[:responses_section_start] responses_html_part = question_html[responses_section_start:] else: question_html_part = question_html responses_html_part = "" # Parser la section question de manière séquentielle question_soup = BeautifulSoup(question_html_part, 'html.parser') question_parts = [] # Si le HTML est mal formé (pas de

d'ouverture), le réparer if not question_html_part.strip().startswith('<'): question_html_part = '

' + question_html_part question_soup = BeautifulSoup(question_html_part, 'html.parser') # Traiter séquentiellement tous les éléments de la question for element in question_soup.find_all(['p', 'pre']): if element.name == 'p': text = element.get_text().strip() if f'Q{question_num}' in text: # Texte principal de la question (sans le numéro) # Trouver la position de Q{num} et prendre le texte après q_pos = text.find(f'Q{question_num}') if q_pos != -1: clean_text = text[q_pos + len(f'Q{question_num}'):].strip(' -').strip() clean_text = self.clean_html_tags(clean_text) if clean_text: question_parts.append(clean_text) elif text and text != "Réponses :" and not re.match(r'^Q\d+\s*-', text): # Texte additionnel de la question (éviter les questions parasites) clean_text = self.clean_html_tags(text) if clean_text: question_parts.append(clean_text) elif element.name == 'pre': # Bloc de code dans la question code = self.extract_code_from_pre(element) if code.strip(): self.code_counter += 1 filename = f"q{question_num}_{self.code_counter}.py" self.save_code_to_file(code, filename) question_parts.append(f"\\inputminted[xleftmargin=20pt]{{python}}{{{self.output_dir}/{filename}}}") # Parser les réponses de manière plus intelligente responses = [] if responses_html_part: responses_soup = BeautifulSoup(responses_html_part, 'html.parser') # Analyser séquentiellement tous les éléments après "Réponses :" response_elements = responses_soup.find_all(['p', 'pre']) current_response = None for element in response_elements: if element.name == 'p': text = element.get_text().strip() # Vérifier si c'est le début d'une nouvelle question (Q[0-9]+) if re.match(r'^Q\d+\s*-', text): if self.debug: print(f"DEBUG Q{question_num} - Nouvelle question détectée dans les réponses: {text[:50]}") break # Arrêter le parsing des réponses if self.debug: print(f"DEBUG Q{question_num} - Element P: {repr(text[:100])}") # Vérifier si c'est une nouvelle réponse (A-, B-, C-, D-) response_match = re.match(r'^(>|>)?([A-D])-\s*(.*)', text) if response_match: # Sauvegarder la réponse précédente si elle existe if current_response is not None: responses.append(current_response) # Commencer une nouvelle réponse prefix = response_match.group(1) or "" letter = response_match.group(2) response_text = response_match.group(3).strip() # Nettoyer le texte if response_text: response_text = self.clean_html_tags(response_text) # Vérifier si c'est la bonne réponse is_correct = element.find('span', class_='ok') is not None current_response = { 'letter': letter, 'text': response_text, 'correct': is_correct } if self.debug: print(f"DEBUG Q{question_num} - Nouvelle réponse {letter}: '{response_text}' (correct: {is_correct})") elif text == "Réponses :" or not text.strip(): # Ignorer les titres et paragraphes vides continue else: # Texte additionnel pour la réponse actuelle if current_response is not None and text: additional_text = self.clean_html_tags(text) if current_response['text']: current_response['text'] += " " + additional_text else: current_response['text'] = additional_text elif element.name == 'pre': # Bloc de code pour la réponse actuelle if current_response is not None: code = self.extract_code_from_pre(element) if code.strip(): self.code_counter += 1 filename = f"r{question_num}_{current_response['letter'].lower()}.py" self.save_code_to_file(code, filename) code_block = f"\\inputminted[xleftmargin=20pt]{{python}}{{{self.output_dir}/{filename}}}" if current_response['text']: current_response['text'] += "\n\n" + code_block else: current_response['text'] = code_block if self.debug: print(f"DEBUG Q{question_num} - Code ajouté à réponse {current_response['letter']}") # Ajouter la dernière réponse if current_response is not None: responses.append(current_response) # Vérification de sécurité : chercher des bonnes réponses manquées if not any(resp['correct'] for resp in responses): # Chercher dans le HTML brut des patterns de bonne réponse if '' in responses_html_part: # Chercher quel élément contient le span.ok ok_elements = responses_soup.find_all('span', class_='ok') for ok_element in ok_elements: parent_text = ok_element.parent.get_text() if ok_element.parent else "" # Déterminer quelle lettre correspond for letter in ['A', 'B', 'C', 'D']: if f'{letter}-' in parent_text or f'>{letter}-' in parent_text or f'>{letter}-' in parent_text: # Marquer cette réponse comme correcte for resp in responses: if resp['letter'] == letter: resp['correct'] = True if self.debug: print(f"DEBUG Q{question_num} - Correction: Réponse {letter} marquée comme correcte") break break # Convertir en format final (sans la lettre) final_responses = [] for resp in responses: final_responses.append({ 'text': resp['text'], 'correct': resp['correct'] }) responses = final_responses # S'assurer qu'on a exactement 4 réponses while len(responses) < 4: responses.append({ 'text': '', 'correct': False }) # Limiter à 4 réponses maximum responses = responses[:4] if self.debug: print(f"DEBUG Q{question_num} - HTML partie question: {question_html_part[:200]}...") print(f"DEBUG Q{question_num} - HTML partie réponses: {responses_html_part[:200]}...") print(f"DEBUG Q{question_num} - Question finale: {len(question_parts)} parties") print(f"DEBUG Q{question_num} - Parties de question: {[part[:50] + '...' if len(part) > 50 else part for part in question_parts]}") print(f"DEBUG Q{question_num} - Réponses finales: {len(responses)} réponses") for i, resp in enumerate(responses): correct_marker = "✓" if resp['correct'] else "✗" text_preview = resp['text'][:80] + ('...' if len(resp['text']) > 80 else '') print(f"DEBUG Q{question_num} - Réponse {['A','B','C','D'][i]}: {correct_marker} {text_preview}") return { 'number': question_num, 'text': '\n\n'.join(question_parts), 'responses': responses } def get_latex_header(self): """Retourne l'en-tête LaTeX complet pour AMC""" return """\\documentclass[12pt,a4paper]{article} \\usepackage{csvsimple}% \\usepackage[francais,bloc]{automultiplechoice} \\usepackage{minted} %\\usepackage{ulem} \\usepackage{multicol} \\let\\multicolmulticols\\multicols \\let\\endmulticolmulticols\\endmulticols \\RenewDocumentEnvironment{multicols}{mO{}} {% \\ifnum#1=1 #2% \\else % More than 1 column \\multicolmulticols{#1}[#2] \\fi } {% \\ifnum#1=1 \\else % More than 1 column \\endmulticolmulticols \\fi } \\usepackage{xcolor} \\definecolor{LightGray}{gray}{0.9} % Définition des commandes AMC pour éviter les erreurs \\def\\nom{NOM} \\def\\prenom{PRENOM} \\def\\id{NUMERO} \\newcommand{\\sujet}{ \\exemplaire{1}{% %%% debut de l'en-tête des copies : \\begin{center} \\noindent{}\\fbox{\\vspace*{3mm} \\Large\\bf\\nom{}~\\prenom{}\\normalsize{}% \\vspace*{3mm} } \\end{center} \\noindent{\\bf QCM \\hfill TEST} \\vspace*{.5cm} \\begin{minipage}{.4\\linewidth} \\centering\\large\\bf Test\\\\ Examen du 01/01/2008 \\end{minipage} \\begin{center}\\em Durée : 10 minutes. Aucun document n'est autorisé. L'usage de la calculatrice est interdit. Les questions faisant apparaître le symbole \\multiSymbole{} peuvent présenter zéro, une ou plusieurs bonnes réponses. Les autres ont une unique bonne réponse. Des points négatifs pourront être affectés à de \\emph{très mauvaises} réponses. \\end{center} \\vspace{1ex} %%% fin de l'en-tête \\restituegroupe{""" + self.element_group + """} \\AMCassociation{\\id} %\\AMCaddpagesto{3} } } %%%%§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§ \\begin{document} %%%Options \\AMCrandomseed{1237893} \\def\\AMCformQuestion#1{{\\sc Question #1 :}} \\setdefaultgroupmode{withoutreplacement} %%% Fin Options %%% groupes % Questions générées automatiquement""" def get_latex_footer(self): """Retourne le pied de page LaTeX pour AMC""" if self.use_csv: return f""" \\csvreader[head to column names]{{liste.csv}}{{}}{{\\sujet}} \\end{{document}}""" else: return f""" \\sujet \\end{{document}}""" def analyze_code_complexity(self, question_text, responses): """Analyse la complexité du code dans la question et les réponses""" # Compter les blocs de code dans la question question_code_blocks = question_text.count('\\inputminted') # Détecter les codes longs dans la question (plus de 2 blocs = complexe) has_long_question_code = question_code_blocks > 2 # Analyser les réponses response_code_blocks = sum(1 for resp in responses if '\\inputminted' in resp['text']) has_code_in_responses = response_code_blocks > 0 return { 'question_code_blocks': question_code_blocks, 'response_code_blocks': response_code_blocks, 'has_long_question_code': has_long_question_code, 'has_code_in_responses': has_code_in_responses } def should_use_horizontal_layout(self, question_text, responses): """Détermine si on doit utiliser un layout horizontal ou vertical""" # Si un layout est forcé, l'utiliser if self.force_layout == 'horizontal': return True elif self.force_layout == 'vertical': return False # Analyser la complexité du code code_analysis = self.analyze_code_complexity(question_text, responses) # Si la question a beaucoup de code, forcer vertical pour les réponses if code_analysis['has_long_question_code']: return False # Si les réponses contiennent du code, forcer vertical if code_analysis['has_code_in_responses']: return False # Vérifier si toutes les réponses sont courtes all_short = all(len(resp['text']) <= self.layout_threshold for resp in responses) # Vérifier si aucune réponse ne contient de code multilignes no_multiline_code = all('\\inputminted' not in resp['text'] and '\n' not in resp['text'] for resp in responses) return all_short and no_multiline_code def should_use_multicol(self, question_text, responses): """Détermine si on doit utiliser multicol pour cette question""" if not self.use_multicol: return False # Analyser la complexité du code code_analysis = self.analyze_code_complexity(question_text, responses) # Ne pas utiliser multicol si la question a beaucoup de code if code_analysis['has_long_question_code']: return False # Ne jamais utiliser multicol s'il y a du code dans les réponses if code_analysis['has_code_in_responses']: return False # Calculer la longueur moyenne des réponses valid_responses = [resp for resp in responses if resp['text'].strip()] if not valid_responses: return False avg_length = sum(len(resp['text']) for resp in valid_responses) / len(valid_responses) max_length = max(len(resp['text']) for resp in valid_responses) # Utiliser multicol seulement pour les réponses très courtes return avg_length <= 25 and max_length <= 35 def determine_multicol_columns(self, responses): """Détermine le nombre optimal de colonnes pour multicol""" valid_responses = [resp for resp in responses if resp['text'].strip()] if not valid_responses: return 2 avg_length = sum(len(resp['text']) for resp in valid_responses) / len(valid_responses) max_length = max(len(resp['text']) for resp in valid_responses) # Réponses très courtes -> 4 colonnes if avg_length <= 15 and max_length <= 20 and len(valid_responses) == 4: return 4 # Réponses courtes -> 2 colonnes elif avg_length <= 25 and max_length <= 35: return 2 else: return 2 # fallback def generate_latex(self, question_data): """Génère le code LaTeX pour une question""" latex_parts = [] # Encapsuler chaque question dans son propre element (même groupe pour toutes) latex_parts.append(f"\\element{{{self.element_group}}}{{") latex_parts.append(f"\\begin{{question}}{{q{question_data['number']}}}") latex_parts.append("") latex_parts.append(question_data['text']) latex_parts.append("") # Choisir le layout selon la longueur des réponses et la complexité du code use_horizontal = self.should_use_horizontal_layout(question_data['text'], question_data['responses']) use_multicol = self.should_use_multicol(question_data['text'], question_data['responses']) # Filtrer les réponses vides valid_responses = [resp for resp in question_data['responses'] if resp['text'].strip()] if use_multicol and len(valid_responses) > 0: # Déterminer le nombre optimal de colonnes optimal_columns = self.determine_multicol_columns(valid_responses) layout_env = "reponses" latex_parts.append(f" \\begin{{multicols}}{{{optimal_columns}}}") latex_parts.append(f" \\begin{{{layout_env}}}") else: layout_env = "reponseshoriz" if use_horizontal else "reponses" latex_parts.append(f" \\begin{{{layout_env}}}") if self.debug: code_analysis = self.analyze_code_complexity(question_data['text'], question_data['responses']) total_length = sum(len(resp['text']) for resp in valid_responses) avg_length = total_length / len(valid_responses) if valid_responses else 0 layout_info = f"{layout_env}" if use_multicol: optimal_columns = self.determine_multicol_columns(valid_responses) layout_info += f" + multicol({optimal_columns})" if self.force_layout: layout_info += f" (forcé: {self.force_layout})" if code_analysis['has_long_question_code']: layout_info += " (vertical: code long dans question)" elif code_analysis['has_code_in_responses']: layout_info += " (vertical: code dans réponses)" print(f"DEBUG Q{question_data['number']} - Layout: {layout_info}") print(f"DEBUG Q{question_data['number']} - Code: {code_analysis['question_code_blocks']} blocs question, {code_analysis['response_code_blocks']} blocs réponses") print(f"DEBUG Q{question_data['number']} - Réponses: {len(valid_responses)} valides, longueur moy: {avg_length:.1f}") for resp in valid_responses: command = "\\bonne" if resp['correct'] else "\\mauvaise" latex_parts.append(f" {command}{{{resp['text']}}}") if use_multicol and len(valid_responses) > 0: latex_parts.append(f" \\end{{{layout_env}}}") latex_parts.append(f" \\end{{multicols}}") else: latex_parts.append(f" \\end{{{layout_env}}}") latex_parts.append("\\end{question}") latex_parts.append("}") # Fermer l'element latex_parts.append("") return '\n'.join(latex_parts) def parse_html_file(self, html_content): """Parse un fichier HTML complet et extrait toutes les questions""" # D'abord essayer de séparer par


if '
' in html_content: questions_html = html_content.split('
') else: # Pas de
, chercher les questions par pattern Q[0-9]+ questions_html = [] # Chercher tous les patterns Q[numéro] dans le texte question_pattern = r'

\s*Q(\d+)\s*-' matches = list(re.finditer(question_pattern, html_content)) if matches: for i, match in enumerate(matches): start = match.start() # Fin = début de la question suivante ou fin du document end = matches[i + 1].start() if i + 1 < len(matches) else len(html_content) question_html = html_content[start:end] questions_html.append(question_html) if self.debug: question_num = match.group(1) print(f"DEBUG - Question Q{question_num} trouvée de {start} à {end}") else: # Fallback : traiter tout comme une seule question questions_html = [html_content] questions_data = [] for q_html in questions_html: if 'Q' in q_html and '- ' in q_html: # Vérifier qu'il y a bien une question question_data = self.parse_question(q_html) if question_data: questions_data.append(question_data) return questions_data def convert_file(self, input_file, output_file, full_document=False): """Convertit un fichier HTML en LaTeX""" with open(input_file, 'r', encoding='utf-8') as f: html_content = f.read() questions = self.parse_html_file(html_content) latex_content = [] if full_document: latex_content.append(self.get_latex_header()) else: latex_content.append("% Questions générées automatiquement") latex_content.append("") for question in questions: latex_content.append(self.generate_latex(question)) if full_document: latex_content.append(self.get_latex_footer()) with open(output_file, 'w', encoding='utf-8') as f: f.write('\n'.join(latex_content)) print(f"Conversion terminée !") print(f"- {len(questions)} questions converties") print(f"- {self.code_counter} fichiers de code créés dans {self.output_dir}/") print(f"- Fichier LaTeX généré : {output_file}") if full_document: print(f"- Document LaTeX complet généré (prêt à compiler)") if self.use_csv: print(f"- Mode CSV activé (nécessite liste.csv)") else: print(f"- Mode simple activé (\\sujet)") print(f"- Groupe d'éléments : {self.element_group}") if self.force_layout: print(f"- Layout forcé : {self.force_layout}") if self.use_multicol: print(f"- Multicol activé : {self.multicol_columns} colonnes pour réponses courtes") if self.debug: print(f"- Mode debug était activé") def main(): """Fonction principale""" import argparse parser = argparse.ArgumentParser(description='Convertit un QCM HTML vers LaTeX AMC') parser.add_argument('input_file', help='Fichier HTML d\'entrée') parser.add_argument('-o', '--output', default='qcm.tex', help='Fichier LaTeX de sortie') parser.add_argument('-c', '--codes-dir', default='codes', help='Répertoire pour les fichiers de code') parser.add_argument('-d', '--debug', action='store_true', help='Active le mode debug avec informations détaillées') parser.add_argument('-f', '--full-document', action='store_true', help='Génère un document LaTeX complet (avec documentclass, packages et structure AMC)') parser.add_argument('--csv', action='store_true', help='Utilise \\csvreader au lieu de \\sujet (nécessite liste.csv)') parser.add_argument('-g', '--element-group', default='general', help='Nom du groupe d\'éléments pour AMC (défaut: general)') # Options de layout layout_group = parser.add_argument_group('Options de mise en page') layout_group.add_argument('--layout-threshold', type=int, default=50, help='Seuil de longueur pour choisir le layout horizontal (défaut: 50)') layout_group.add_argument('--force-horizontal', action='store_true', help='Force le layout horizontal pour toutes les réponses') layout_group.add_argument('--force-vertical', action='store_true', help='Force le layout vertical pour toutes les réponses') layout_group.add_argument('--multicol', action='store_true', help='Active l\'utilisation de multicol pour les réponses très courtes') layout_group.add_argument('--multicol-columns', type=int, default=2, help='Nombre de colonnes pour multicol (défaut: 2)') args = parser.parse_args() # Vérifier les conflits d'options if args.force_horizontal and args.force_vertical: parser.error("--force-horizontal et --force-vertical ne peuvent pas être utilisés ensemble") # Déterminer le layout forcé force_layout = None if args.force_horizontal: force_layout = 'horizontal' elif args.force_vertical: force_layout = 'vertical' converter = QCMConverter( output_dir=args.codes_dir, debug=args.debug, layout_threshold=args.layout_threshold, force_layout=force_layout, use_multicol=args.multicol, multicol_columns=args.multicol_columns, use_csv=args.csv, element_group=args.element_group ) converter.convert_file(args.input_file, args.output, full_document=args.full_document) if __name__ == '__main__': main()