From 930c7c8185c0c1939e18e34e98c418daf3cb81f6 Mon Sep 17 00:00:00 2001 From: lprik Date: Wed, 20 Aug 2025 08:07:38 +0000 Subject: [PATCH] Ajouter html2latex.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit première version --- html2latex.py | 785 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 785 insertions(+) create mode 100644 html2latex.py diff --git a/html2latex.py b/html2latex.py new file mode 100644 index 0000000..cfe1a2f --- /dev/null +++ b/html2latex.py @@ -0,0 +1,785 @@ +#!/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() \ No newline at end of file